use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RedactConfig {
#[serde(default = "default_true")]
pub strip_network_body: bool,
#[serde(default = "default_true")]
pub strip_network_headers: bool,
#[serde(default)]
pub drop_network: bool,
#[serde(default)]
pub drop_console: bool,
#[serde(default)]
pub scrubbers: Vec<String>,
}
impl Default for RedactConfig {
fn default() -> Self {
Self {
strip_network_body: true,
strip_network_headers: true,
drop_network: false,
drop_console: false,
scrubbers: vec![],
}
}
}
fn default_true() -> bool {
true
}
pub struct RedactOpts {
pub strip_network_body: bool,
pub strip_network_headers: bool,
pub drop_network: bool,
pub drop_console: bool,
pub scrubbers: Vec<Regex>,
}
impl Default for RedactOpts {
fn default() -> Self {
Self {
strip_network_body: true,
strip_network_headers: true,
drop_network: false,
drop_console: false,
scrubbers: vec![],
}
}
}
impl RedactConfig {
pub fn to_opts(&self) -> Result<RedactOpts, String> {
let scrubbers = self
.scrubbers
.iter()
.map(|s| {
Regex::new(s).map_err(|e| format!("非法 scrubber regex {s:?}: {e}"))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(RedactOpts {
strip_network_body: self.strip_network_body,
strip_network_headers: self.strip_network_headers,
drop_network: self.drop_network,
drop_console: self.drop_console,
scrubbers,
})
}
}
pub fn redact_bundle(bundle: &mut Value, opts: &RedactOpts) {
if let Some(segs) = bundle.get_mut("segments").and_then(|v| v.as_object_mut()) {
for events_val in segs.values_mut() {
let Some(arr) = events_val.as_array_mut() else {
continue;
};
let mut out: Vec<Value> = Vec::with_capacity(arr.len());
for e in arr.drain(..) {
if let Some(kept) = redact_event(e, opts) {
out.push(kept);
}
}
*events_val = Value::Array(out);
}
}
if !opts.scrubbers.is_empty() {
if let Some(url) = bundle
.get_mut("session")
.and_then(|s| s.get_mut("url"))
.and_then(|u| u.as_str().map(|s| s.to_string()))
{
let scrubbed = scrub_str(&url, &opts.scrubbers);
if let Some(url_val) = bundle.get_mut("session").and_then(|s| s.get_mut("url")) {
*url_val = Value::String(scrubbed);
}
}
}
}
fn redact_event(e: Value, opts: &RedactOpts) -> Option<Value> {
if e.get("type").and_then(|t| t.as_i64()) != Some(6) {
return Some(e);
}
let mut e = e;
let data = e.get_mut("data")?;
let plugin = data
.get("plugin")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string();
if plugin == "network" && opts.drop_network {
return None;
}
if plugin == "console" && opts.drop_console {
return None;
}
if let Some(payload) = data.get_mut("payload") {
if plugin == "network" {
if let Some(obj) = payload.as_object_mut() {
if opts.strip_network_body {
obj.remove("reqBody");
obj.remove("resBody");
}
if opts.strip_network_headers {
obj.remove("reqHeaders");
obj.remove("resHeaders");
}
}
}
if !opts.scrubbers.is_empty() {
scrub_value(payload, &opts.scrubbers);
}
}
Some(e)
}
fn scrub_str(s: &str, scrubbers: &[Regex]) -> String {
let mut out = s.to_string();
for re in scrubbers {
out = re.replace_all(&out, "[REDACTED]").to_string();
}
out
}
fn scrub_value(v: &mut Value, scrubbers: &[Regex]) {
match v {
Value::String(s) => {
*s = scrub_str(s, scrubbers);
}
Value::Array(arr) => {
for x in arr {
scrub_value(x, scrubbers);
}
}
Value::Object(obj) => {
for (_, val) in obj.iter_mut() {
scrub_value(val, scrubbers);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn bundle_with_events(events: Vec<Value>) -> Value {
json!({
"format": "prism-session",
"version": 1,
"session": { "id": "s1", "url": "https://app.example.com/?token=secret123" },
"windows": [],
"segments": { "web#1": events },
"annotations": [],
})
}
#[test]
fn redact_strips_network_and_scrubs() {
let mut bundle = bundle_with_events(vec![
json!({ "type": 2, "timestamp": 1 }),
json!({
"type": 6, "timestamp": 2,
"data": { "plugin": "network", "payload": {
"url": "https://api.example.com/me",
"reqBody": "{\"password\":\"hunter2\"}",
"resBody": "{\"email\":\"a@b.com\"}",
"reqHeaders": { "Authorization": "Bearer xyz" },
"method": "GET"
}}
}),
json!({
"type": 6, "timestamp": 3,
"data": { "plugin": "console", "payload": { "level": "error", "args": ["boom"] } }
}),
]);
let opts = RedactConfig {
scrubbers: vec![r"token=\w+".to_string(), r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+".to_string()],
..Default::default()
}
.to_opts()
.unwrap();
redact_bundle(&mut bundle, &opts);
let segs = bundle["segments"]["web#1"].as_array().unwrap();
assert_eq!(segs.len(), 3); assert_eq!(segs[0]["type"], 2);
let net_payload = &segs[1]["data"]["payload"];
assert!(net_payload.get("reqBody").is_none(), "reqBody 应被剥离");
assert!(net_payload.get("resBody").is_none(), "resBody 应被剥离");
assert!(net_payload.get("reqHeaders").is_none(), "reqHeaders 应被剥离");
assert_eq!(net_payload["method"], "GET"); assert_eq!(net_payload["url"], "https://api.example.com/me");
assert_eq!(
bundle["session"]["url"],
"https://app.example.com/?[REDACTED]"
);
}
#[test]
fn redact_drops_network_events() {
let mut bundle = bundle_with_events(vec![
json!({ "type": 6, "timestamp": 1, "data": { "plugin": "network", "payload": {} } }),
json!({ "type": 6, "timestamp": 2, "data": { "plugin": "console", "payload": {} } }),
]);
let opts = RedactOpts {
drop_network: true,
..Default::default()
};
redact_bundle(&mut bundle, &opts);
let segs = bundle["segments"]["web#1"].as_array().unwrap();
assert_eq!(segs.len(), 1);
assert_eq!(segs[0]["data"]["plugin"], "console");
}
#[test]
fn redact_scrubs_nested_strings() {
let mut bundle = bundle_with_events(vec![json!({
"type": 6, "timestamp": 1,
"data": { "plugin": "console", "payload": {
"level": "log",
"args": ["user email: a@b.com", { "nested": "cc@d.com" }]
}}
})]);
let opts = RedactConfig {
scrubbers: vec![r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+".to_string()],
strip_network_body: false,
strip_network_headers: false,
..Default::default()
}
.to_opts()
.unwrap();
redact_bundle(&mut bundle, &opts);
let args = bundle["segments"]["web#1"][0]["data"]["payload"]["args"].as_array().unwrap();
assert_eq!(args[0], "user email: [REDACTED]");
assert_eq!(args[1]["nested"], "[REDACTED]");
}
#[test]
fn bad_scrubber_regex_errors() {
let cfg = RedactConfig {
scrubbers: vec!["[".to_string()],
..Default::default()
};
assert!(cfg.to_opts().is_err());
}
}