1use regex::Regex;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13#[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
45pub 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 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
86pub 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 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
118fn redact_event(e: Value, opts: &RedactOpts) -> Option<Value> {
120 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
165fn 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 #[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); 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"); assert_eq!(net_payload["url"], "https://api.example.com/me"); assert_eq!(
244 bundle["session"]["url"],
245 "https://app.example.com/?[REDACTED]"
246 );
247 }
248
249 #[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 #[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 #[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}