Files
Unlucky-Launcher/crates/core/src/launch/redact/shape.rs
T
AskaEth 43f77d8865 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。
2026-11-11 10:15:48 +08:00

139 lines
6.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 两条形状判据(`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
}