From 43f77d88657366b1aaaa4dfc6858b8581631495c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 09:19:39 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(core):=20=E6=94=B6=E7=AA=84=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=84=B1=E6=95=8F=E7=9A=84=E4=B8=89=E7=B1=BB=E8=AF=AF?= =?UTF-8?q?=E6=9D=80=EF=BC=88=E5=93=81=E7=89=8C=E4=B8=B2=20/=20CDN=20?= =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E5=90=8D=20/=20=E9=95=BF=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M239 实测两条形状规则会误杀三类正常文本,被抹掉的正是用户要复制的面 (`--dry-run` 的 argv、`cmd_built.argv`、诊断规则引擎的输入)。修法不是抬阈值 (40 / 3×8 / 64 一个字没动),而是给每条规则加**必要条件**: - `-D=<品牌>` 被当三段式 token:`=` 是赋值边界、退出 dotted 字符集 (JWT 的 `=` 只可能是末尾填充),键里的点号不再把值拖进段数统计。 - CDN/镜像 URL 变成 `https:`:加"必须含大写"与"首段不是 DNS 主机名"两条必要条件(JWT 头是 JSON,base64url(`{"`) = `eyJ` 必含大写; base64url 不含 `/`)。扫描器先切主机名,路径那半继续判——JWT 放进 URL 路径里照样抹。 - ≥64 无点号长串吞掉整条路径:路径形状(被 `/` 切开且每段是词形名字)整条 保留,但**段本身**≥64 的照抹(`/…/objects/<64 位 hex>` 只抹那一段)。 真凭据的抹除能力不变:登记过明文值的替换路径没碰;正例(本仓 JWT / MSA JWT / `-DaccessToken=` 粘着 / URL 路径里的 JWT / 标准 base64 三段式 / sha256 hex / base64 ≥64 / `A`×64)全部仍被抹。回归语料(M239 的 68 文件 / 243 行)修前被改 1 行 → 修后 0 行。判据与扫描器拆成 redact.rs + redact/{shape,tests}.rs。 --- crates/core/src/launch/redact.rs | 186 ++++++++-------- crates/core/src/launch/redact/shape.rs | 138 ++++++++++++ crates/core/src/launch/redact/tests.rs | 280 +++++++++++++++++++++++++ 3 files changed, 508 insertions(+), 96 deletions(-) create mode 100644 crates/core/src/launch/redact/shape.rs create mode 100644 crates/core/src/launch/redact/tests.rs diff --git a/crates/core/src/launch/redact.rs b/crates/core/src/launch/redact.rs index 78b7425..92ca8e6 100644 --- a/crates/core/src/launch/redact.rs +++ b/crates/core/src/launch/redact.rs @@ -8,12 +8,35 @@ //! `header.payload.signature`(XSTS/XBL 票据正是这个),点号把连续段切开,单看 //! 「连续不透明串 ≥64」这条会整串原样放过。规则只在这里实现一份,所有调用方 //! (launch 会话 / 诊断 / auth 错误摘要 …)共用,避免各自补一套后漂移。 +//! +//! 形状判据本身在 [`shape`],本模块只做「扫描 + 搬运」:把文本切成连续段, +//! 逐段问一次判据,命中就换成占位符。 +//! +//! # 判据只砍「真凭据不可能有的形状」(M240) +//! +//! 两条形状规则都是纯启发式,M239 实测它们会误杀三类正常文本(品牌串、CDN 主机名、长路径), +//! 而这三类被抹掉的面正是用户要复制/要读的面(`--dry-run` 的 argv、诊断规则的输入)。 +//! 修法不是抬阈值,而是给每条规则加**必要条件**(详见 [`shape`] 的表): +//! +//! - `=` 不算 token 字符(赋值边界):`-D=<品牌>` 不再被当成三段式 token; +//! - 带点 token 必须含大写字母、且首段不是主机名(JWT 头 `eyJ…` 必含大写、JWT 不含 `/`); +//! - ≥64 的不透明串若是「路径形状」(被 `/` 切开且每段都是词形名字)就整条保留, +//! 但**段本身**仍够长的照抹(`/…/objects/<64 位 hex>` 里只有那段消失)。 +//! +//! 三条出口都只放行"整段不可能是凭据"的形状,[`Redactor::with_secret`] 登记过的明文值 +//! 走的是第一条替换路径,与这些启发式无关——真 token 的抹除能力没有下降。 use std::borrow::Cow; use std::path::Path; use serde_json::Value; +mod shape; + +use self::shape::{ + is_dns_host, is_dotted_token, is_dotted_token_char, is_opaque_char, is_path_run, MAX_OPAQUE_RUN, +}; + /// access token / session token 的替换标记。 pub const TOKEN_PLACEHOLDER: &str = ""; /// 用户主目录的替换标记。 @@ -21,13 +44,6 @@ pub const HOME_PLACEHOLDER: &str = ""; /// 形似 token 的长不透明串的替换标记。 pub const OPAQUE_PLACEHOLDER: &str = ""; -/// 判定“像 token”的最短连续长度。 -const MAX_OPAQUE_RUN: usize = 64; -/// 「带点 token」(JWT 三段式)判定的最短总长。 -const DOTTED_TOKEN_MIN_TOTAL: usize = 40; -/// 「带点 token」每一段的最短长度(挡住 `Service.Unavailable.today` 这类正常文案)。 -const DOTTED_TOKEN_MIN_SEGMENT: usize = 8; - /// 脱敏器:持有需要抹掉的敏感值(token、主目录)。 #[derive(Debug, Clone, Default)] pub struct Redactor { @@ -128,21 +144,28 @@ impl Redactor { /// 文本里是否含长度 ≥64 的“不透明串”(base64/hex token 的特征)。 pub fn has_opaque_run(text: &str) -> bool { - let mut run = 0usize; - for ch in text.chars() { + let mut start: Option = None; + for (idx, ch) in text.char_indices() { if is_opaque_char(ch) { - run += 1; - if run >= MAX_OPAQUE_RUN { + start.get_or_insert(idx); + continue; + } + if let Some(from) = start.take() { + if run_needs_masking(&text[from..idx]) { return true; } - } else { - run = 0; } } - false + start.is_some_and(|from| run_needs_masking(&text[from..])) } -/// 把长度 ≥64 的不透明串替换成 [`OPAQUE_PLACEHOLDER`]。 +/// 这一段不透明连续串要不要动手:够长,且(不是路径 或 路径里某一段自己就够长)。 +fn run_needs_masking(run: &str) -> bool { + run.len() >= MAX_OPAQUE_RUN + && (!is_path_run(run) || run.split('/').any(|chunk| chunk.len() >= MAX_OPAQUE_RUN)) +} + +/// 把长度 ≥64 的不透明串替换成 [`OPAQUE_PLACEHOLDER`](路径形状的串见 [`push_path_masked`])。 pub fn mask_opaque(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut run_start: Option = None; @@ -165,18 +188,33 @@ pub fn mask_opaque(text: &str) -> String { } fn push_masked(out: &mut String, run: &str) { - if run.len() >= MAX_OPAQUE_RUN { - out.push_str(OPAQUE_PLACEHOLDER); - } else { + if !run_needs_masking(run) { out.push_str(run); + } else if is_path_run(run) { + push_path_masked(out, run); + } else { + out.push_str(OPAQUE_PLACEHOLDER); } } -fn is_opaque_char(ch: char) -> bool { - ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '_' | '-') +/// 路径整条保留,但**段本身**够长的照抹。 +/// +/// 路径判据放行的是"目录/包名段",不许顺手放行一整段凭据:`/srv/mc/assets/objects/<64 位 hex>` +/// 里的目录留下,那个 64 字符的段照样变成占位符(它与无点号密钥在形状上不可区分)。 +fn push_path_masked(out: &mut String, run: &str) { + for (idx, chunk) in run.split('/').enumerate() { + if idx > 0 { + out.push('/'); + } + if chunk.len() >= MAX_OPAQUE_RUN { + out.push_str(OPAQUE_PLACEHOLDER); + } else { + out.push_str(chunk); + } + } } -/// 文本里是否含「带点 token」(≥3 段、每段 ≥8、总长 ≥40)。 +/// 文本里是否含「带点 token」。 pub fn has_dotted_token(text: &str) -> bool { let mut start: Option = None; for (idx, ch) in text.char_indices() { @@ -185,12 +223,20 @@ pub fn has_dotted_token(text: &str) -> bool { continue; } if let Some(from) = start.take() { - if is_dotted_token(&text[from..idx]) { + if run_has_dotted_token(&text[from..idx]) { return true; } } } - start.is_some_and(|from| is_dotted_token(&text[from..])) + start.is_some_and(|from| run_has_dotted_token(&text[from..])) +} + +/// 一段里有没有该抹的部分:URL 形状只判**路径那一半**,主机名放行。 +fn run_has_dotted_token(run: &str) -> bool { + match split_authority(run) { + Some((_, rest)) => run_has_dotted_token(rest), + None => is_dotted_token(run), + } } /// 把「带点 token」整串替换成 [`TOKEN_PLACEHOLDER`](段外文本原样保留)。 @@ -214,6 +260,13 @@ pub fn mask_dotted_tokens(text: &str) -> String { } fn push_dotted(out: &mut String, run: &str) { + // URL 形状先切:主机名原样留下,路径那半继续判——JWT 摆在 URL 路径里也照抹。 + if let Some((prefix, rest)) = split_authority(run) { + out.push_str(prefix); + out.push('/'); + push_dotted(out, rest); + return; + } if is_dotted_token(run) { out.push_str(TOKEN_PLACEHOLDER); } else { @@ -221,79 +274,20 @@ fn push_dotted(out: &mut String, run: &str) { } } -/// 「带点 token」的字符集:比不透明串多一个 `.`(JWT 的分段符)。 -fn is_dotted_token_char(ch: char) -> bool { - is_opaque_char(ch) || ch == '.' -} - -/// 单段连写(无空白)的串是否像 JWT:段数 ≥3、每段 ≥8 字节、总长 ≥40。 -fn is_dotted_token(run: &str) -> bool { - run.len() >= DOTTED_TOKEN_MIN_TOTAL - && run.split('.').count() >= 3 - && run - .split('.') - .all(|segment| segment.len() >= DOTTED_TOKEN_MIN_SEGMENT) +/// 把 URL 形状的 run 切成(主机名前缀, 路径那半)。路径那半不含分隔用的那个 `/`。 +/// +/// 前缀里含 scheme 之后的 `//`(`:` 会切断 run,所以 `https:` 不在 run 里)。 +/// 判据只看主机名部分是不是 DNS 形状,路径原样交回去继续判。 +fn split_authority(run: &str) -> Option<(&str, &str)> { + let slashes = run.len() - run.trim_start_matches('/').len(); + let (authority, _) = run[slashes..].split_once('/')?; + if !is_dns_host(authority) { + return None; + } + let cut = slashes + authority.len(); + Some((&run[..cut], &run[cut + 1..])) } #[cfg(test)] -mod tests { - use super::*; - - /// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。 - const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; - - #[test] - fn jwt_token_is_masked_without_any_registered_secret() { - let scrubbed = Redactor::new().scrub(JWT).into_owned(); - assert!(scrubbed.contains(TOKEN_PLACEHOLDER), "{scrubbed}"); - assert!(!scrubbed.contains("eyJhbGci"), "{scrubbed}"); - - // 嵌在别的内容里也只换 token 本体。 - let masked = Redactor::new() - .scrub(&format!("authorization: Bearer {JWT}, retry=1")) - .into_owned(); - assert_eq!( - masked, - format!("authorization: Bearer {TOKEN_PLACEHOLDER}, retry=1") - ); - } - - #[test] - fn dotted_token_rule_does_not_eat_normal_text() { - // 分段太短、总长不够、段数不够:三种都不算 token。 - for text in [ - "Service.Unavailable.today", - "1.2.0", - "xbl.xsts.entitlements.profile", - "eyJhbGciOiJIUzI1NiJ9.short.abc", - ] { - assert!(!has_dotted_token(text), "{text}"); - assert_eq!(Redactor::new().scrub(text), text, "{text}"); - } - } - - #[test] - fn existing_rules_still_apply() { - let redactor = Redactor::new() - .with_secret("SECRET-TOKEN-123456") - .with_home_str("/home/alice"); - let scrubbed = redactor - .scrub("/home/alice/game.log: token=SECRET-TOKEN-123456") - .into_owned(); - assert!(scrubbed.contains(HOME_PLACEHOLDER), "{scrubbed}"); - assert!(scrubbed.contains(TOKEN_PLACEHOLDER), "{scrubbed}"); - assert!( - !scrubbed.contains("alice") && !scrubbed.contains("SECRET"), - "{scrubbed}" - ); - - // 没有点号的长不透明串仍走 ≥64 规则,替换标记也不同。 - let long = "A".repeat(MAX_OPAQUE_RUN); - assert!(has_opaque_run(&long)); - assert!(!has_dotted_token(&long)); - assert_eq!( - Redactor::new().scrub(&long).into_owned(), - OPAQUE_PLACEHOLDER.to_string() - ); - } -} +#[path = "redact/tests.rs"] +mod tests; diff --git a/crates/core/src/launch/redact/shape.rs b/crates/core/src/launch/redact/shape.rs new file mode 100644 index 0000000..fe04854 --- /dev/null +++ b/crates/core/src/launch/redact/shape.rs @@ -0,0 +1,138 @@ +//! 两条形状判据(`dotted` / `opaque`)的判定与阈值;[`super`] 的扫描器只负责搬运字符。 +//! +//! 判据集中在这里一份,是因为它们是**同一件事的两面**:`dotted` 先跑、`opaque` 后跑, +//! 两者的字符集相互补充(`.` 只属于前者,`/` 属于两者),任何一边改了字符集,另一边的 +//! 覆盖面就跟着变。 +//! +//! # 三条"别抹"的判据(M239 的 D8 误杀) +//! +//! 每条都只砍掉**真凭据不可能有的形状**,且都写成"必要条件":命中的串仍然按老规则抹, +//! 只是多了几个"这一串根本不可能是一段凭据"的出口: +//! +//! | 误杀形状 | 出口判据 | 为什么真凭据不会从这里溜走 | +//! |---|---|---| +//! | `-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 | + +/// 判定“像 token”的最短连续长度。 +pub(super) const MAX_OPAQUE_RUN: usize = 64; +/// 「带点 token」(JWT 三段式)判定的最短总长。 +pub(super) const DOTTED_TOKEN_MIN_TOTAL: usize = 40; +/// 「带点 token」每一段的最短长度(挡住 `Service.Unavailable.today` 这类正常文案)。 +pub(super) const DOTTED_TOKEN_MIN_SEGMENT: usize = 8; + +/// 不透明串的字符集(base64 / hex / 路径分隔符)。`.` 不在内,`: \ @` 也不在内。 +pub(super) fn is_opaque_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '_' | '-') +} + +/// 「带点 token」的字符集:不透明串的字符加 `.`、减 `=`。 +/// +/// `=` 是**赋值边界**而不是 token 本体。把它留在字符集里,`-D=<值>` 就会被粘成一个 +/// run:键里的点号(`minecraft.launcher.brand`)会把值一起拖进段数统计,M239 实测的品牌串 +/// 误杀就是从这儿来的。JWT 自己的 `=` 只可能是末尾填充,拆出去不损伤覆盖面。 +pub(super) fn is_dotted_token_char(ch: char) -> bool { + ch != '=' && (is_opaque_char(ch) || ch == '.') +} + +/// 单段连写(无空白)的串是否像 JWT。 +pub(super) fn is_dotted_token(run: &str) -> bool { + jwt_shaped(run) && !is_url_authority(run) +} + +/// 「带点 token」的形状:段数 ≥3、每段 ≥8 字节、总长 ≥40(老规则,一个字都没放宽), +/// 外加两条必要条件——含大写、首段不是主机名。 +fn jwt_shaped(run: &str) -> bool { + run.len() >= DOTTED_TOKEN_MIN_TOTAL + && run.split('.').count() >= 3 + && run + .split('.') + .all(|segment| segment.len() >= DOTTED_TOKEN_MIN_SEGMENT) + && run.chars().any(|ch| ch.is_ascii_uppercase()) +} + +/// run 的第一段(到第一个 `/` 为止)是不是主机名:是的话这就是一条 URL,不是 token。 +/// +/// 扫描器(`super::push_dotted`)先按这条把主机名切走、只判路径那半;这里的判据留一份, +/// 是为了让「一段串是不是 token」单独成立(`has_dotted_token` 与单测直接用它)。 +pub(super) fn is_url_authority(run: &str) -> bool { + is_dns_host(run.split('/').next().unwrap_or(run)) +} + +/// 整串是不是一个 DNS 主机名(至少两段标签,标签只含小写字母/数字/连字符)。 +/// +/// JWT 是 base64url,**不含 `/`**;主机名的标签也不会大写(URL 里从没这么写过)。 +pub(super) fn is_dns_host(name: &str) -> bool { + let labels: Vec<&str> = name.split('.').collect(); + labels.len() >= 2 && labels.iter().all(|label| is_dns_label(label)) +} + +/// 单个 DNS 标签:小写字母/数字/连字符,首尾不是连字符(日志里的主机名不会写大写)。 +fn is_dns_label(label: &str) -> bool { + !label.is_empty() + && label.len() <= 63 + && label + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + && !label.starts_with('-') + && !label.ends_with('-') +} + +/// 长不透明串是不是一段路径/命名空间(目录、文件名、Java 包名)。 +/// +/// 两条:① 至少被 `/` 切开一次;② 每一段都是"名字"([`is_name_chunk`])。 +/// 判据里没有任何熵估计,"词形 vs 随机"就靠第 ② 条:随机 base64 的一段里每个连续字母块 +/// 都恰好是全小写/全大写/CamelCase 的概率可以忽略——所以一条 base64 密钥不会被当成路径。 +/// 方向也是安全的:判定失败的串照旧被抹,只是路径不再整条消失。 +pub(super) fn is_path_run(run: &str) -> bool { + run.split('/').count() >= 2 && run.split('/').all(is_name_chunk) +} + +/// 路径/包名的一段:只许字母数字与 `- _ .`,且每个连续字母块都是"词"([`is_word`])。 +fn is_name_chunk(chunk: &str) -> bool { + chunk + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + && chunk + .split(|ch: char| !ch.is_ascii_alphabetic()) + .all(is_word) +} + +/// 连续字母块是否像词:全小写 / 全大写(缩写)/ CamelCase(`OpenGL`、`AllTheMods`、`FabricMixin…`)。 +fn is_word(word: &str) -> bool { + let mut has_lower = false; + let mut has_upper = false; + for ch in word.chars() { + if ch.is_ascii_lowercase() { + has_lower = true; + } else if ch.is_ascii_uppercase() { + has_upper = true; + } else { + return false; + } + } + if !has_lower || !has_upper { + return true; + } + let chars: Vec = word.chars().collect(); + let mut idx = 0usize; + while idx < chars.len() { + if !chars[idx].is_ascii_uppercase() { + return false; + } + let mut end = idx + 1; + while end < chars.len() && chars[end].is_ascii_uppercase() { + end += 1; + } + let lowered = end < chars.len() && chars[end].is_ascii_lowercase(); + if lowered && end - idx != 1 { + return false; // `ABCDef`:缩写后面接小写,不是词 + } + idx = end; + while idx < chars.len() && chars[idx].is_ascii_lowercase() { + idx += 1; + } + } + true +} diff --git a/crates/core/src/launch/redact/tests.rs b/crates/core/src/launch/redact/tests.rs new file mode 100644 index 0000000..aebf616 --- /dev/null +++ b/crates/core/src/launch/redact/tests.rs @@ -0,0 +1,280 @@ +//! `launch::redact` 的判据测试:抹得掉真凭据,抹不着正常文本。 +//! +//! 三组:**正例**(必须抹,占位符形状固定)、**误杀**(M239 实测的形状,必须逐字原样)、 +//! **边界**(新判据最容易被拼出来的形状,以及 `has_*` / `mask_*` 两个入口的一致性)。 +//! 语料组拿 M239 的回归语料(68 文件 / 243 行)当钉死的输入,要求一行都不许改。 + +use std::path::{Path, PathBuf}; + +use super::*; + +/// 真实形状的 JWT(`header.payload.signature`,三段各 ≥8)。 +const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; +/// 微软 MSA/XSTS 形状的 header(`{"typ":"JWT","alg":"none"}`)。 +const MSA: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJ4dWlkIjoiMjUzNTQyOTk4OTg4OTg5OCJ9.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZw"; +/// 64 位 sha256(hex)。 +const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn scrub(text: &str) -> String { + Redactor::new().scrub(text).into_owned() +} + +// ───────────────────────── 正例:真凭据仍被抹 ───────────────────────── + +#[test] +fn jwts_are_masked_in_every_log_shape() { + let cases = [ + (JWT.to_string(), TOKEN_PLACEHOLDER.to_string()), + (MSA.to_string(), TOKEN_PLACEHOLDER.to_string()), + ( + format!("authorization: Bearer {JWT}, retry=1"), + format!("authorization: Bearer {TOKEN_PLACEHOLDER}, retry=1"), + ), + // `键=` 粘在 token 前面:键留下、token 照抹(`=` 是赋值边界,不是 token 本体) + ( + format!("-DaccessToken={JWT}"), + format!("-DaccessToken={TOKEN_PLACEHOLDER}"), + ), + ( + format!("--accessToken {MSA} --uuid 0123"), + format!("--accessToken {TOKEN_PLACEHOLDER} --uuid 0123"), + ), + // 藏在 URL 路径里也照抹(只有主机名被放行) + ( + format!("https://resources.download.minecraft.net/{JWT}"), + format!("https://resources.download.minecraft.net/{TOKEN_PLACEHOLDER}"), + ), + // 标准 base64 的三段式(带大写):不是 URL 形状,照抹 + ( + "QWxsLmJhc2U2NA.QWJjZGVmZ2hpamtsbW5vcA.ZGVmaWdoaWprbG1ub3BxcnN0dXZ3eHl6".to_string(), + TOKEN_PLACEHOLDER.to_string(), + ), + ]; + for (input, expected) in cases { + assert_eq!(scrub(&input), expected, "输入:{input}"); + } +} + +#[test] +fn opaque_secrets_are_masked_in_every_log_shape() { + let cases = [ + (format!("sha256={SHA256}"), OPAQUE_PLACEHOLDER.to_string()), + (SHA256.to_string(), OPAQUE_PLACEHOLDER.to_string()), + ("A".repeat(64), OPAQUE_PLACEHOLDER.to_string()), + ( + "dGhpcyBpcyBhIHZlcnkgbG9uZyBzZWNyZXQga2V5IHRoYXQgaXMgYmFzZTY0IGVuY29kZWQ=".to_string(), + OPAQUE_PLACEHOLDER.to_string(), + ), + // 带 `/` 与 `+` 的普通 base64:随机大小写,不是路径形状 + ( + "aB3xK9mQ2pL5vN8r/W1tY4uI7oP0sD6fGhJ2kL5zX8cV1bN4mQ7wE3rT6yU9iO2pA5sD8fG1hJ" + .to_string(), + OPAQUE_PLACEHOLDER.to_string(), + ), + ]; + for (input, expected) in cases { + assert_eq!(scrub(&input), expected, "输入:{input}"); + } +} + +#[test] +fn registered_secrets_and_home_still_apply() { + let redactor = Redactor::new() + .with_secret("SECRET-TOKEN-123456") + .with_home_str("/home/alice"); + let scrubbed = redactor + .scrub("/home/alice/game.log: token=SECRET-TOKEN-123456") + .into_owned(); + assert_eq!( + scrubbed, "/game.log: token=", + "登记过的明文值走替换路径,与形状启发式无关" + ); + assert!(Redactor::new().is_empty(), "空脱敏器不该登记任何规则"); +} + +// ───────────────────────── 误杀:必须逐字原样 ───────────────────────── + +/// M239 的 ①②③:品牌串 / 属性串(`=` 是赋值边界)。 +#[test] +fn brand_and_property_strings_survive() { + for text in [ + "-Dminecraft.launcher.brand=UnluckyLauncher", + "-Dminecraft.launcher.brand=minecraft", + "-Dminecraft.launcher.version=0.2.3", + "-Dsome.prop=com.example.UnluckyLauncher.ClientMain", + "com.mojang.authlib.GameProfile", + "net.minecraft.launchwrapper.Launch.launch(Launch.java:135)", + "1.20.1-forge-47.2.0-beta.20250115.123456", + "Service.Unavailable.today", + "xbl.xsts.entitlements.profile", + ] { + assert_eq!(scrub(text), text, "误杀:{text}"); + } +} + +/// 整条 HMCL 形状的 argv(`--dry-run` 的面):一处都不许动。 +#[test] +fn hmcl_shaped_argv_survives_verbatim() { + let argv = "javaw.exe -Dminecraft.launcher.brand=UnluckyLauncher -Dminecraft.launcher.version=0.2.3 \ + -cp /home/alice/.minecraft/libraries/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-universal.jar \ + net.minecraft.launchwrapper.Launch"; + assert_eq!(scrub(argv), argv); +} + +/// M239 的 U1–U6:CDN / 镜像主机名(DNS 授权段 + 小写必要条件)。 +#[test] +fn cdn_and_mirror_urls_survive() { + for text in [ + "https://resources.download.minecraft.net/ab/cdef", + "https://bmclapi2.bangbang93.com/assets/ab/abcdef0123456789abcdef", + "http://textures.minecraft.net/texture/8f4a1b2c3d4e5f60718293a4b5c6d7e8", + "https://libraries.minecraft.net/net/minecraft/launchwrapper/1.12/launchwrapper-1.12.jar", + "https://api.minecraftservices.com/entitlements/mcstore", + "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge.jar", + // 没有点号分段的最长主机名形状(全靠"必须含大写"这条必要条件挡住) + "resources.download.minecraft.example-cdn", + // 短域名 + 长路径(内容全是词形名字) + "https://short.host/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "mirror.example.com/downloads/0123456789abcdef0123456789abcdef0123456789abcdef", + ] { + assert_eq!(scrub(text), text, "URL/主机名被抹:{text}"); + } +} + +/// M239 的 O1–O3 / ⑥':长路径、Java 包名路径(段名是词形,不是随机串)。 +#[test] +fn long_paths_and_class_paths_survive() { + for text in [ + "Java Path: /home/alice/.minecraft/runtime/java-runtime-delta/linux/java-runtime-delta/bin/java", + "java.lang.NoClassDefFoundError: org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy", + "/srv/games/minecraft/instances/AllTheMods10/mods/sophisticatedbackpacks-universal.jar", + "/var/folders/xy/0123456789abcdef0123456789abcdef/T/unlucky/launch", + "net/minecraftforge/fml/loading/FMLEnvironment", + ] { + assert_eq!(scrub(text), text, "路径被抹:{text}"); + } +} + +/// 语料组:M239 的回归语料,形状规则**一行都不许改**。 +#[test] +fn sample_corpus_is_untouched() { + let files = corpus_files(); + let mut lines = 0usize; + let mut changed = Vec::new(); + for file in &files { + let text = std::fs::read_to_string(file).unwrap_or_default(); + for (idx, line) in text.lines().enumerate() { + lines += 1; + let out = scrub(line); + if out != line { + changed.push(format!("{}:{} → {out}", file.display(), idx + 1)); + } + } + } + assert_eq!(files.len(), 68, "语料文件数变了(M239 口径 68)"); + assert_eq!(lines, 243, "语料行数变了(M239 口径 243)"); + assert!(changed.is_empty(), "形状规则改了语料行:{changed:#?}"); +} + +// ───────────────────────── 边界 ───────────────────────── + +/// 判据的缝合线:路径里**段本身**够长时照抹,段不长就整条留下。 +#[test] +fn a_long_path_component_is_still_masked() { + // 63 字符的段:与无点号密钥的判定长度差一个字符,留下 + let short = format!("https://short.host/{}", "a".repeat(63)); + assert_eq!(scrub(&short), short); + // 64 字符的段:形状上与 `sha256=` 不可区分,抹掉 + let long = format!("https://short.host/{}", "a".repeat(64)); + assert_eq!( + scrub(&long), + format!("https://short.host/{OPAQUE_PLACEHOLDER}") + ); + // 路径里夹一段 64 位 hex:只有那一段消失 + assert_eq!( + scrub(&format!("/srv/mc/assets/objects/{SHA256}")), + format!("/srv/mc/assets/objects/{OPAQUE_PLACEHOLDER}") + ); +} + +/// 边界:**没有** `/` 的长文件名与无点号密钥同形,只能照抹(已知残留,见报告)。 +#[test] +fn a_bare_long_filename_is_still_masked() { + assert_eq!( + scrub("sophisticatedbackpacks-universal-fabric-loader-compatible-build-final.jar"), + format!("{OPAQUE_PLACEHOLDER}.jar") + ); +} + +/// 边界:路径里有一段**不是**词形的随机串 → 整条按凭据处理(安全方向)。 +#[test] +fn a_path_with_a_random_looking_component_is_masked() { + let secret = "srv/games/aB3xK9mQ2pL5vN8rW1tY4uI7oP0sD6fGhJ2kL5zX8cV1bN4mQ7wE3rT6yU9"; + assert_eq!(scrub(secret), OPAQUE_PLACEHOLDER); +} + +/// 边界:URL 主机名放行**不**等于整个 run 放行——JWT 摆在路径里照样抹; +/// 而主机名后面的长路径如果本身是词形,整条留下(`--dry-run` 要能复制)。 +#[test] +fn url_host_is_exempt_but_the_path_half_is_still_judged() { + assert_eq!( + scrub(&format!("https://cdn.example.com/{JWT}")), + format!("https://cdn.example.com/{TOKEN_PLACEHOLDER}") + ); + let url = "https://resources.download.minecraft.net/assets/objects/4b9d8b9d4e6f4c9a8b7d6e5f"; + assert_eq!(scrub(url), url); +} + +/// 两个入口必须一致:`scrub` 用 `has_*` 当闸门,闸门关着就永远不会调 `mask_*`。 +#[test] +fn the_has_gate_agrees_with_the_maskers() { + let cases = [ + JWT, + MSA, + "-Dminecraft.launcher.brand=UnluckyLauncher", + "https://resources.download.minecraft.net/ab/cdef", + "https://short.host/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "Java Path: /home/alice/.minecraft/runtime/java-runtime-delta/bin/java", + "java.lang.NoClassDefFoundError: org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy", + SHA256, + "1.2.0", + "", + ]; + for case in cases { + assert_eq!( + has_dotted_token(case), + mask_dotted_tokens(case) != case, + "has_dotted_token 与 mask_dotted_tokens 不一致:{case}" + ); + assert_eq!( + has_opaque_run(case), + mask_opaque(case) != case, + "has_opaque_run 与 mask_opaque 不一致:{case}" + ); + } +} + +/// 语料 = `rules.d/samples/**/*.log` + app 壳的两份示例日志(M239 的 68 文件口径)。 +fn corpus_files() -> Vec { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut files = Vec::new(); + let mut stack = vec![root.join("src/diagnose/rules.d/samples")]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|ext| ext == "log") { + files.push(path); + } + } + } + for name in ["sample_crash.log", "sample_live.log"] { + files.push(root.join("../app-iced/src/pages/logs").join(name)); + } + files.sort(); + files +} From 66929786c0b363a870f227a29591fe477a8cf7fd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 09:53:37 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(core):=20M240=20=E5=A4=8D=E5=AE=A1=20P?= =?UTF-8?q?2-1/P2-2/P2-3=20=E2=80=94=E2=80=94=20=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E6=94=B6=E7=AA=84=E3=80=81=E5=A4=A7=E5=86=99=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=E9=92=89=E4=BD=8F=E3=80=81=E6=89=BE=E5=9B=9E=E4=B8=A2=E6=8E=89?= =?UTF-8?q?=E7=9A=84=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() { From a07482f828c358c0491fabaea2a0a8c9d7d5162c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 10:07:11 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(core):=20M240=20r2=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=20=E2=80=94=E2=80=94=20=E4=B8=A4=E5=A4=84=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E5=8F=A3=E5=BE=84=EF=BC=88`=3D`=20=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E4=BB=A3=E4=BB=B7=E5=86=99=E6=B8=85=E3=80=81DNS=20=E5=BD=A2?= =?UTF-8?q?=E5=BD=92=E5=B1=9E=E6=94=B9=E6=AD=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯注释,判据/阈值/测试断言/扫描器零改动。 ① `is_dotted_token_char` 的 doc 原写「JWT 自己的 `=` 只可能是末尾填充,拆出去不损伤 覆盖面」——作为无条件说法不成立。改成有范围的措辞,并把这条例外并入模块文档 「旧行为抹、新行为留」第 1 条(不新开章节):`-DaccessToken=abcdefgh.ijklmnop.qrstuvwx` (整段 40 字符、三段各 ≥8,旧规则抹)现在 `=` 切段后值只剩 26 < 40 → 原样留下。 旧/新两侧都实测过(`git show 74be510` + rustc 探针 / 当前代码探针)。 ② `cdn_and_mirror_urls_survive` 里 `resources.download.minecraft.example-cdn` 的注释 原写「全靠必须含大写这条必要条件挡住」——不成立:它是 DNS 形状,由 `is_url_authority` → `is_dns_host` 豁免(删掉大写判据该测试仍绿)。改成正确归属,与 `lowercase_dotted_names_are_spared` / `the_uppercase_requirement_is_load_bearing` 的 doc 口径对齐(DNS 形靠主机名豁免、非 DNS 形靠必须含大写)。 --- crates/core/src/launch/redact/shape.rs | 12 +++++++++++- crates/core/src/launch/redact/tests.rs | 5 +++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/core/src/launch/redact/shape.rs b/crates/core/src/launch/redact/shape.rs index 1f40f05..b8eacd0 100644 --- a/crates/core/src/launch/redact/shape.rs +++ b/crates/core/src/launch/redact/shape.rs @@ -31,6 +31,10 @@ //! //! 1. **`=` 边界**:`-Dminecraft.launcher.brand=UnluckyLauncher` —— 旧行为把 `键=值` 粘成 //! 一段,键里的点号凑够段数/总长;现在两半各自不达标。(这就是要修的品牌串误杀。) +//! 同一处边界的**代价**:`-DaccessToken=abcdefgh.ijklmnop.qrstuvwx`(整段 40 字符、 +//! 三段各 ≥8,旧规则抹)现在 `=` 切段后值只剩 26 < 40 → 原样留下。也就是说 +//! **紧跟 `=`、自己又不够 40 的带点值**不再被形状规则兜住,只剩"登记明文值"那条路径, +//! 这是换品牌串保留付出的代价。 //! 2. **填充不算段长**:`eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbm==` —— //! 旧行为把 `==` 算进末段(8 ≥ 8),现在 `=` 在段外、末段实际 6 < 8,整串留下。 //! 真 JWT 的签名段是 43+ 字符(HS256)/ 342(RS256),6–7 字符的末段只可能是被裁剪或 @@ -55,7 +59,13 @@ pub(super) fn is_opaque_char(ch: char) -> bool { /// /// `=` 是**赋值边界**而不是 token 本体。把它留在字符集里,`-D=<值>` 就会被粘成一个 /// run:键里的点号(`minecraft.launcher.brand`)会把值一起拖进段数统计,M239 实测的品牌串 -/// 误杀就是从这儿来的。JWT 自己的 `=` 只可能是末尾填充,拆出去不损伤覆盖面。 +/// 误杀就是从这儿来的。 +/// +/// 这条边界的代价**有范围**,别读成"不损伤覆盖面":`=` 切段之后,**紧跟 `=`、自己又不够 +/// 40 字符**的带点值不再被形状规则兜住——`-DaccessToken=abcdefgh.ijklmnop.qrstuvwx`(整段 +/// 40 字符、三段各 ≥8,旧规则抹)现在切出 26 < 40 而原样留下。这是换品牌串保留的代价, +/// 记在模块文档「旧行为抹、新行为留」第 1 条的末句;真 token 仍由 [`super::Redactor::with_secret`] +/// 那条明文值替换路径扛住。 pub(super) fn is_dotted_token_char(ch: char) -> bool { ch != '=' && (is_opaque_char(ch) || ch == '.') } diff --git a/crates/core/src/launch/redact/tests.rs b/crates/core/src/launch/redact/tests.rs index b68ac94..a75382a 100644 --- a/crates/core/src/launch/redact/tests.rs +++ b/crates/core/src/launch/redact/tests.rs @@ -135,7 +135,7 @@ fn hmcl_shaped_argv_survives_verbatim() { assert_eq!(scrub(argv), argv); } -/// M239 的 U1–U6:CDN / 镜像主机名(DNS 授权段 + 小写必要条件)。 +/// M239 的 U1–U6:CDN / 镜像主机名(判据 = 首段是 DNS 主机名 → 只判路径那半)。 #[test] fn cdn_and_mirror_urls_survive() { for text in [ @@ -145,7 +145,8 @@ fn cdn_and_mirror_urls_survive() { "https://libraries.minecraft.net/net/minecraft/launchwrapper/1.12/launchwrapper-1.12.jar", "https://api.minecraftservices.com/entitlements/mcstore", "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge.jar", - // 没有点号分段的最长主机名形状(全靠"必须含大写"这条必要条件挡住) + // 40 字符的纯主机名形状:DNS 形 → 靠首段主机名豁免(不是靠大写判据, + // 大写判据只管非 DNS 形状,归属见 `lowercase_dotted_names_are_spared` 的 doc) "resources.download.minecraft.example-cdn", // 短域名 + 长路径(内容全是词形名字) "https://short.host/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",