Skip to main content

observer_storage/
redact.rs

1//! 导出/导入前脱敏:剥离/scrub 会话中的 PII(network body/headers、console args、url 等)。
2//!
3//! Rust 移植自 [`redact.ts`](../../packages/observer-sdk/src/redact.ts),供服务端
4//! `POST /sessions/import` 入库前集中脱敏(P9)。客户端 SDK 仍在导出前自脱敏,
5//! 服务端 redact 是第二道闸(per-tenant scrubbers),**不可逆**。
6//!
7//! 仅处理 `type:6` 诊断信号事件;DOM 快照等不动(与 TS 侧一致)。
8
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// 脱敏配置(来自 tenants.json,scrubbers 为 regex 源码字符串)。
14#[derive(Clone, Debug, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct RedactConfig {
17    #[serde(default = "default_true")]
18    pub strip_network_body: bool,
19    #[serde(default = "default_true")]
20    pub strip_network_headers: bool,
21    #[serde(default)]
22    pub drop_network: bool,
23    #[serde(default)]
24    pub drop_console: bool,
25    #[serde(default)]
26    pub scrubbers: Vec<String>,
27}
28
29impl Default for RedactConfig {
30    fn default() -> Self {
31        Self {
32            strip_network_body: true,
33            strip_network_headers: true,
34            drop_network: false,
35            drop_console: false,
36            scrubbers: vec![],
37        }
38    }
39}
40
41fn default_true() -> bool {
42    true
43}
44
45/// 编译后的脱敏选项(scrubbers 已编译为 [`Regex`],运行时零解析成本)。
46pub struct RedactOpts {
47    pub strip_network_body: bool,
48    pub strip_network_headers: bool,
49    pub drop_network: bool,
50    pub drop_console: bool,
51    pub scrubbers: Vec<Regex>,
52}
53
54impl Default for RedactOpts {
55    fn default() -> Self {
56        Self {
57            strip_network_body: true,
58            strip_network_headers: true,
59            drop_network: false,
60            drop_console: false,
61            scrubbers: vec![],
62        }
63    }
64}
65
66impl RedactConfig {
67    /// 编译 scrubbers regex,构建运行时 [`RedactOpts`]。
68    pub fn to_opts(&self) -> Result<RedactOpts, String> {
69        let scrubbers = self
70            .scrubbers
71            .iter()
72            .map(|s| {
73                Regex::new(s).map_err(|e| format!("非法 scrubber regex {s:?}: {e}"))
74            })
75            .collect::<Result<Vec<_>, _>>()?;
76        Ok(RedactOpts {
77            strip_network_body: self.strip_network_body,
78            strip_network_headers: self.strip_network_headers,
79            drop_network: self.drop_network,
80            drop_console: self.drop_console,
81            scrubbers,
82        })
83    }
84}
85
86/// 对 bundle 就地脱敏(segments 内 `type:6` 信号事件 + session.url)。
87/// 改变传入 bundle,不返回新对象。
88pub fn redact_bundle(bundle: &mut Value, opts: &RedactOpts) {
89    if let Some(segs) = bundle.get_mut("segments").and_then(|v| v.as_object_mut()) {
90        for events_val in segs.values_mut() {
91            let Some(arr) = events_val.as_array_mut() else {
92                continue;
93            };
94            let mut out: Vec<Value> = Vec::with_capacity(arr.len());
95            for e in arr.drain(..) {
96                if let Some(kept) = redact_event(e, opts) {
97                    out.push(kept);
98                }
99            }
100            *events_val = Value::Array(out);
101        }
102    }
103    // session.url 可能带 query token,过一遍 scrubbers
104    if !opts.scrubbers.is_empty() {
105        if let Some(url) = bundle
106            .get_mut("session")
107            .and_then(|s| s.get_mut("url"))
108            .and_then(|u| u.as_str().map(|s| s.to_string()))
109        {
110            let scrubbed = scrub_str(&url, &opts.scrubbers);
111            if let Some(url_val) = bundle.get_mut("session").and_then(|s| s.get_mut("url")) {
112                *url_val = Value::String(scrubbed);
113            }
114        }
115    }
116}
117
118/// 单个事件脱敏;返回 None 表示丢弃该事件。
119fn redact_event(e: Value, opts: &RedactOpts) -> Option<Value> {
120    // 非 type:6(DOM 快照等)不动
121    if e.get("type").and_then(|t| t.as_i64()) != Some(6) {
122        return Some(e);
123    }
124    let mut e = e;
125    let data = e.get_mut("data")?;
126    let plugin = data
127        .get("plugin")
128        .and_then(|p| p.as_str())
129        .unwrap_or("")
130        .to_string();
131    if plugin == "network" && opts.drop_network {
132        return None;
133    }
134    if plugin == "console" && opts.drop_console {
135        return None;
136    }
137    if let Some(payload) = data.get_mut("payload") {
138        if plugin == "network" {
139            if let Some(obj) = payload.as_object_mut() {
140                if opts.strip_network_body {
141                    obj.remove("reqBody");
142                    obj.remove("resBody");
143                }
144                if opts.strip_network_headers {
145                    obj.remove("reqHeaders");
146                    obj.remove("resHeaders");
147                }
148            }
149        }
150        if !opts.scrubbers.is_empty() {
151            scrub_value(payload, &opts.scrubbers);
152        }
153    }
154    Some(e)
155}
156
157fn scrub_str(s: &str, scrubbers: &[Regex]) -> String {
158    let mut out = s.to_string();
159    for re in scrubbers {
160        out = re.replace_all(&out, "[REDACTED]").to_string();
161    }
162    out
163}
164
165/// 递归对字符串值套用 scrubbers;非字符串原样返回。
166fn scrub_value(v: &mut Value, scrubbers: &[Regex]) {
167    match v {
168        Value::String(s) => {
169            *s = scrub_str(s, scrubbers);
170        }
171        Value::Array(arr) => {
172            for x in arr {
173                scrub_value(x, scrubbers);
174            }
175        }
176        Value::Object(obj) => {
177            for (_, val) in obj.iter_mut() {
178                scrub_value(val, scrubbers);
179            }
180        }
181        _ => {}
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use serde_json::json;
189
190    fn bundle_with_events(events: Vec<Value>) -> Value {
191        json!({
192            "format": "prism-session",
193            "version": 1,
194            "session": { "id": "s1", "url": "https://app.example.com/?token=secret123" },
195            "windows": [],
196            "segments": { "web#1": events },
197            "annotations": [],
198        })
199    }
200
201    /// network body/headers 被剥离,scrubbers 命中 url query token。
202    #[test]
203    fn redact_strips_network_and_scrubs() {
204        let mut bundle = bundle_with_events(vec![
205            json!({ "type": 2, "timestamp": 1 }),
206            json!({
207                "type": 6, "timestamp": 2,
208                "data": { "plugin": "network", "payload": {
209                    "url": "https://api.example.com/me",
210                    "reqBody": "{\"password\":\"hunter2\"}",
211                    "resBody": "{\"email\":\"a@b.com\"}",
212                    "reqHeaders": { "Authorization": "Bearer xyz" },
213                    "method": "GET"
214                }}
215            }),
216            json!({
217                "type": 6, "timestamp": 3,
218                "data": { "plugin": "console", "payload": { "level": "error", "args": ["boom"] } }
219            }),
220        ]);
221        let opts = RedactConfig {
222            scrubbers: vec![r"token=\w+".to_string(), r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+".to_string()],
223            ..Default::default()
224        }
225        .to_opts()
226        .unwrap();
227
228        redact_bundle(&mut bundle, &opts);
229
230        let segs = bundle["segments"]["web#1"].as_array().unwrap();
231        assert_eq!(segs.len(), 3); // 都保留(dropNetwork/dropConsole 默认 false)
232        // DOM 事件不动
233        assert_eq!(segs[0]["type"], 2);
234
235        let net_payload = &segs[1]["data"]["payload"];
236        assert!(net_payload.get("reqBody").is_none(), "reqBody 应被剥离");
237        assert!(net_payload.get("resBody").is_none(), "resBody 应被剥离");
238        assert!(net_payload.get("reqHeaders").is_none(), "reqHeaders 应被剥离");
239        assert_eq!(net_payload["method"], "GET"); // 非敏感字段保留
240        assert_eq!(net_payload["url"], "https://api.example.com/me"); // 未命中 scrubber
241
242        // session.url 的 query token 被 scrub
243        assert_eq!(
244            bundle["session"]["url"],
245            "https://app.example.com/?[REDACTED]"
246        );
247    }
248
249    /// dropNetwork 丢弃整个 network 事件,console 保留。
250    #[test]
251    fn redact_drops_network_events() {
252        let mut bundle = bundle_with_events(vec![
253            json!({ "type": 6, "timestamp": 1, "data": { "plugin": "network", "payload": {} } }),
254            json!({ "type": 6, "timestamp": 2, "data": { "plugin": "console", "payload": {} } }),
255        ]);
256        let opts = RedactOpts {
257            drop_network: true,
258            ..Default::default()
259        };
260        redact_bundle(&mut bundle, &opts);
261        let segs = bundle["segments"]["web#1"].as_array().unwrap();
262        assert_eq!(segs.len(), 1);
263        assert_eq!(segs[0]["data"]["plugin"], "console");
264    }
265
266    /// scrubbers 递归命中 console args 中的邮箱。
267    #[test]
268    fn redact_scrubs_nested_strings() {
269        let mut bundle = bundle_with_events(vec![json!({
270            "type": 6, "timestamp": 1,
271            "data": { "plugin": "console", "payload": {
272                "level": "log",
273                "args": ["user email: a@b.com", { "nested": "cc@d.com" }]
274            }}
275        })]);
276        let opts = RedactConfig {
277            scrubbers: vec![r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+".to_string()],
278            strip_network_body: false,
279            strip_network_headers: false,
280            ..Default::default()
281        }
282        .to_opts()
283        .unwrap();
284        redact_bundle(&mut bundle, &opts);
285        let args = bundle["segments"]["web#1"][0]["data"]["payload"]["args"].as_array().unwrap();
286        assert_eq!(args[0], "user email: [REDACTED]");
287        assert_eq!(args[1]["nested"], "[REDACTED]");
288    }
289
290    /// 非法 scrubber regex 应报错。
291    #[test]
292    fn bad_scrubber_regex_errors() {
293        let cfg = RedactConfig {
294            scrubbers: vec!["[".to_string()],
295            ..Default::default()
296        };
297        assert!(cfg.to_opts().is_err());
298    }
299}