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
@@ -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 {
|
||||
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<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 == '.'
|
||||
}
|
||||
|
||||
/// 单段连写(无空白)的串是否像 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;
|
||||
Reference in new issue
Block a user