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:
1 parent
775bb75b92
commit
43f77d8865
3 files changed
+508
-96
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user