diff --git a/crates/core/src/launch/redact.rs b/crates/core/src/launch/redact.rs index bf3d4c6..565caba 100644 --- a/crates/core/src/launch/redact.rs +++ b/crates/core/src/launch/redact.rs @@ -28,6 +28,15 @@ //! base64 的正例全部仍被抹;但有三类形状是「旧行为抹、新行为留」——`=` 边界、填充不计段长、 //! 全小写点分串。三条都在 [`shape`] 的「旧行为抹、新行为留」一节里逐条写明理由, //! 前两条是本次要修的误杀,第三条是代价。 +//! +//! # 句读不是 token 本体(M243 BEARERDOT) +//! +//! `Bearer eyJ….eyJ….c2ln….`(行尾一个英文句点)在 M240 之后仍整串原样落盘:句点自己属于 +//! dotted 字符集,被算进 run 后 `split('.')` 多切出一个空段,「每段 ≥8」当场不成立。修法同样 +//! 不是抬阈值——判定前把**末尾**句读切出去([`shape::split_trailing_punct`]),搬运方 +//! ([`push_dotted`])再把句读原位写回,所以占位符只吃 token 本体,句子标点一个不丢。 +//! 只削末尾:中间的句点仍是 token 的一部分(JWT 的三段就是靠它切的);`(`、`)`、`,`、`;` +//! 本来就在字符集外,一并列进规则是为了「句读不参与判定」这件事只有一个出处。 use std::borrow::Cow; use std::path::Path; @@ -37,7 +46,8 @@ 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, + is_dns_host, is_dotted_token, is_dotted_token_char, is_opaque_char, is_path_run, + split_trailing_punct, MAX_OPAQUE_RUN, }; /// access token / session token 的替换标记。 @@ -238,7 +248,7 @@ pub fn has_dotted_token(text: &str) -> bool { fn run_has_dotted_token(run: &str) -> bool { match split_authority(run) { Some((_, rest)) => run_has_dotted_token(rest), - None => is_dotted_token(run), + None => is_dotted_token(split_trailing_punct(run).0), } } @@ -270,11 +280,14 @@ fn push_dotted(out: &mut String, run: &str) { push_dotted(out, rest); return; } - if is_dotted_token(run) { + // 句读不参与判定,但要原位保留:`Bearer .` 的句点不许被占位符吃掉。 + let (body, punct) = split_trailing_punct(run); + if is_dotted_token(body) { out.push_str(TOKEN_PLACEHOLDER); } else { - out.push_str(run); + out.push_str(body); } + out.push_str(punct); } /// 把 URL 形状的 run 切成(主机名前缀, 路径那半)。路径那半不含分隔用的那个 `/`。 diff --git a/crates/core/src/launch/redact/shape.rs b/crates/core/src/launch/redact/shape.rs index b8eacd0..9aa4dd1 100644 --- a/crates/core/src/launch/redact/shape.rs +++ b/crates/core/src/launch/redact/shape.rs @@ -18,6 +18,11 @@ //! 四条里前两条是**位置/字符集**判据,后两条是**形状**判据;都写成必要条件:判据不成立时 //! 仍然照抹,只有"这一串根本不可能是凭据"时才放行。 //! +//! 第五处边界(M243 BEARERDOT,同样不是抬阈值):**末尾句读不参与判定** +//! ([`split_trailing_punct`])。判定前把 `.` `,` `;` `)` 切出去,搬运方原位写回—— +//! 行尾句点原先会多切出一个空段,把真 token 整串放行。只有 `.` 会进入 dotted 字符集, +//! 其余三个本来就在外面;列全是为了「句读不参与判定」只有一处出处。 +//! //! 关于"必须含大写"的实际覆盖面(复审 P2-2 的怀疑成立一半):**DNS 形状**的全小写点分串 //! 早在首段 DNS 豁免那一条就被救下了,跟大写判据无关;大写判据唯一扛住的是**不是 DNS 形状** //! 的全小写点分串(标签带 `_`、标签超 63 字符、`/` 前只有一段主机名)。它保留下来,因为那类 @@ -75,6 +80,21 @@ pub(super) fn is_dotted_token(run: &str) -> bool { jwt_shaped(run) && !is_url_authority(run) } +/// 把 run 末尾的句读切出去:返回(判据只看的前半, 原样搬回的后半)。 +/// +/// 句读(`.` `,` `;` `)`)不属于 token 本体。`.` 是**唯一会进入** dotted 字符集的句读 +/// (见 [`is_dotted_token_char`]),所以行尾一个句点就会把 run 多切出一个空段: +/// 「每段 ≥8」当场不成立,整串原样落盘——BEARERDOT(M243)。`(` `,` `;` 早被字符集 +/// 切在 run 外,这里一并列出,是把「句读不参与判定」写成一条规则:字符集将来若放宽, +/// 判据不用再改一遍。 +/// +/// 只削**末尾**:中间的句点仍是 token 本体(JWT 的三段就是靠它切的)。 +/// 削短只会让形状判据更容易成立,方向在安全那一侧;搬运方负责把切出去的后半原位写回。 +pub(super) fn split_trailing_punct(run: &str) -> (&str, &str) { + let body = run.trim_end_matches(['.', ',', ';', ')']); + run.split_at(body.len()) +} + /// 「带点 token」的形状:段数 ≥3、每段 ≥8 字节、总长 ≥40(老规则,一个字都没放宽), /// 外加两条必要条件——首段不是主机名、**含大写字母**。 /// diff --git a/crates/core/src/launch/redact/tests.rs b/crates/core/src/launch/redact/tests.rs index a75382a..b0d4cea 100644 --- a/crates/core/src/launch/redact/tests.rs +++ b/crates/core/src/launch/redact/tests.rs @@ -3,11 +3,18 @@ //! 三组:**正例**(必须抹,占位符形状固定)、**误杀**(M239 实测的形状,必须逐字原样)、 //! **边界**(新判据最容易被拼出来的形状,以及 `has_*` / `mask_*` 两个入口的一致性)。 //! 语料组拿 M239 的回归语料(68 文件 / 243 行)当钉死的输入,要求一行都不许改。 +//! +//! BEARERDOT(token 后面跟句读,M243)单独一页:`tests/bearerdot.rs`。 use std::path::{Path, PathBuf}; use super::*; +// `tests.rs` 是以 `#[path]` 挂进来的文件模块,子模块按**它所在目录**(`redact/`)解析, +// 所以这里写全相对路径,文件仍归在 `tests/` 目录下。 +#[path = "tests/bearerdot.rs"] +mod bearerdot; + /// 真实形状的 JWT(`header.payload.signature`,三段各 ≥8)。 const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; /// 微软 MSA/XSTS 形状的 header(`{"typ":"JWT","alg":"none"}`)。 diff --git a/crates/core/src/launch/redact/tests/bearerdot.rs b/crates/core/src/launch/redact/tests/bearerdot.rs new file mode 100644 index 0000000..80f565e --- /dev/null +++ b/crates/core/src/launch/redact/tests/bearerdot.rs @@ -0,0 +1,96 @@ +//! BEARERDOT(M243):token 后面跟句读时也要抹,句读原位保留。 +//! +//! # 漏抹的形状 +//! +//! `Bearer eyJ….eyJ….c2ln….`(行尾一个英文句点):句点自己属于 dotted 字符集 +//! ([`is_dotted_token_char`]),扫描器把它算进 run,`split('.')` 多切出一个空段, +//! 「每段 ≥8」当场不成立,整串原样落盘。M240 实测 `old == new == 原文`, +//! 影响面是 core 的全部落盘/展示面(会话 JSONL、`--dry-run`、诊断规则输入)。 +//! +//! # 修法 +//! +//! 不是抬阈值,是**判定前把句读切出去**([`split_trailing_punct`]):判据只看 token 本体, +//! 句读由搬运方(`push_dotted`)原位写回。方向是对安全的那一侧:削短只会让形状判据 +//! 更容易成立,不会把真 token 放过去。`(`、`)`、`,`、`;` 早就在字符集外,一并钉住, +//! 免得下次改字符集时又把这四个位置漏掉。 +//! +//! # 另一半:句读不是「见到句点就抹」的入口 +//! +//! 削句读之后必须证明句点分隔的普通文本没被顺手拖进来(`Service.Unavailable.today.`), +//! 这一侧由 [`sentences_ending_in_punctuation_still_survive`] 钉住;否则修法等于用 +//! 「行尾有没有句点」当抹除判据,那是把 M239/M240 修掉的误杀换了个形状请回来。 + +use super::*; + +/// 真凭据 + 句读:token 照抹,句读原位保留(不是被占位符吃掉,也不是被当噪声丢掉)。 +#[test] +fn trailing_sentence_punctuation_does_not_hide_a_token() { + let cases = [ + // form ①:行尾句点 + ( + format!("Bearer {JWT}."), + format!("Bearer {TOKEN_PLACEHOLDER}."), + ), + // form ②:句点后还有正文(`"…c2ln…. then"`) + ( + format!("the token was {JWT}. then"), + format!("the token was {TOKEN_PLACEHOLDER}. then"), + ), + // form ③:逗号 + (format!("{JWT},"), format!("{TOKEN_PLACEHOLDER},")), + // form ④:括号(左右都在字符集外,钉住说明它们从来不是判据的一部分) + (format!("({JWT})"), format!("({TOKEN_PLACEHOLDER})")), + // MSA/XSTS 票据被抄进日志时正是这个位置:键 + 分号 + ( + format!("xsts: {MSA};"), + format!("xsts: {TOKEN_PLACEHOLDER};"), + ), + // URL 路径里的 token + 行尾句点(主机名豁免只管主机名那半) + ( + format!("https://cdn.example.com/{JWT}."), + format!("https://cdn.example.com/{TOKEN_PLACEHOLDER}."), + ), + ]; + for (input, expected) in cases { + assert_eq!(scrub(&input), expected, "输入:{input}"); + } +} + +/// 句点分隔的普通文本:削句读不许把「行尾有句点」变成新的抹除入口。 +/// +/// 每一条都是 `brand_and_property_strings_survive` / `long_paths_and_class_paths_survive` +/// 里已经钉过的形状,末尾加一个句点——断言 `scrub` 逐字原样,且闸门 `has_dotted_token` 不开。 +#[test] +fn sentences_ending_in_punctuation_still_survive() { + for text in [ + "Service.Unavailable.today.", + "the error came back. Service.Unavailable.today.", + "sophisticatedbackpacks.upgrades.reference.", + "xbl.xsts.entitlements.profile.", + "Java Path: /home/alice/.minecraft/runtime/java-runtime-delta/bin/java.", + ] { + assert!(!has_dotted_token(text), "闸门开了:{text}"); + assert_eq!(scrub(text), text, "误杀:{text}"); + } +} + +/// 两个入口在带句读的输入上仍要一致:`has_*` 开着闸门,`mask_*` 就必须真的改。 +/// +/// `scrub` 用 `has_dotted_token` 当闸门,闸门与搬运方对句读的看法一旦分叉(一边削、一边不削), +/// `scrub` 会静默走「闸门关着 → 什么都不抹」这条路,测试却全绿。 +#[test] +fn the_has_gate_agrees_on_trailing_punctuation() { + for case in [ + format!("Bearer {JWT}."), + format!("{JWT},"), + format!("({JWT})"), + format!("the token was {MSA}. then"), + "Service.Unavailable.today.".to_string(), + ] { + assert_eq!( + has_dotted_token(&case), + mask_dotted_tokens(&case) != case, + "has_dotted_token 与 mask_dotted_tokens 不一致:{case}" + ); + } +}