fix(core): 收窄日志脱敏的三类误杀(品牌串 / CDN 主机名 / 长路径)

M239 实测两条形状规则会误杀三类正常文本,被抹掉的正是用户要复制的面
(`--dry-run` 的 argv、`cmd_built.argv`、诊断规则引擎的输入)。修法不是抬阈值
(40 / 3×8 / 64 一个字没动),而是给每条规则加**必要条件**:

- `-D<key>=<品牌>` 被当三段式 token:`=` 是赋值边界、退出 dotted 字符集
  (JWT 的 `=` 只可能是末尾填充),键里的点号不再把值拖进段数统计。
- CDN/镜像 URL 变成 `https:<redacted:token>`:加"必须含大写"与"首段不是 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。
This commit is contained in:
AskaEth committed 2026-11-11 10:15:48 +08:00
1 parent 775bb75b92
commit 43f77d8865
3 files changed
+508 -96

No files matched your search

+90 -96
View File
@@ -8,12 +8,35 @@
//! `header.payload.signature`(XSTS/XBL 票据正是这个),点号把连续段切开,单看
//! 「连续不透明串 ≥64」这条会整串原样放过。规则只在这里实现一份,所有调用方
//! (launch 会话 / 诊断 / auth 错误摘要 …)共用,避免各自补一套后漂移。
//!
//! 形状判据本身在 [`shape`],本模块只做「扫描 + 搬运」:把文本切成连续段,
//! 逐段问一次判据,命中就换成占位符。
//!
//! # 判据只砍「真凭据不可能有的形状」(M240)
//!
//! 两条形状规则都是纯启发式,M239 实测它们会误杀三类正常文本(品牌串、CDN 主机名、长路径),
//! 而这三类被抹掉的面正是用户要复制/要读的面(`--dry-run` 的 argv、诊断规则的输入)。
//! 修法不是抬阈值,而是给每条规则加**必要条件**(详见 [`shape`] 的表):
//!
//! - `=` 不算 token 字符(赋值边界):`-D<key>=<品牌>` 不再被当成三段式 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 = "<redacted:token>";
/// 用户主目录的替换标记。
@@ -21,13 +44,6 @@ pub const HOME_PLACEHOLDER: &str = "<home>";
/// 形似 token 的长不透明串的替换标记。
pub const OPAQUE_PLACEHOLDER: &str = "<redacted:opaque>";
/// 判定“像 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<usize> = 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<usize> = 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 {
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);
}
}
/// 路径整条保留,但**段本身**够长的照抹。
///
/// 路径判据放行的是"目录/包名段",不许顺手放行一整段凭据:`/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(run);
out.push_str(chunk);
}
}
}
fn is_opaque_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '_' | '-')
}
/// 文本里是否含「带点 token」(≥3 段、每段 ≥8、总长 ≥40)。
/// 文本里是否含「带点 token」。
pub fn has_dotted_token(text: &str) -> bool {
let mut start: Option<usize> = 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 == '.'
/// 把 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;
}
/// 单段连写(无空白)的串是否像 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)
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;
+138
View File
@@ -0,0 +1,138 @@
//! 两条形状判据(`dotted` / `opaque`)的判定与阈值;[`super`] 的扫描器只负责搬运字符。
//!
//! 判据集中在这里一份,是因为它们是**同一件事的两面**:`dotted` 先跑、`opaque` 后跑,
//! 两者的字符集相互补充(`.` 只属于前者,`/` 属于两者),任何一边改了字符集,另一边的
//! 覆盖面就跟着变。
//!
//! # 三条"别抹"的判据(M239 的 D8 误杀)
//!
//! 每条都只砍掉**真凭据不可能有的形状**,且都写成"必要条件":命中的串仍然按老规则抹,
//! 只是多了几个"这一串根本不可能是一段凭据"的出口:
//!
//! | 误杀形状 | 出口判据 | 为什么真凭据不会从这里溜走 |
//! |---|---|---|
//! | `-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 |
/// 判定“像 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<key>=<值>` 就会被粘成一个
/// 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<char> = 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
}
+280
View File
@@ -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, "<home>/game.log: token=<redacted: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=<hex>` 不可区分,抹掉
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<PathBuf> {
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
}