Skip to main content

assay/lua/policy/
redact.rs

1use serde_json::Value;
2
3pub fn redact_json_text(text: &str, keys: &[String]) -> Option<String> {
4    if keys.is_empty() {
5        return None;
6    }
7    let mut value: Value = serde_json::from_str(text).ok()?;
8    let lowered: Vec<String> = keys.iter().map(|k| k.to_ascii_lowercase()).collect();
9    strip(&mut value, &lowered);
10    serde_json::to_string(&value).ok()
11}
12
13pub fn is_redacted_header(name: &str, keys: &[String]) -> bool {
14    let name = name.to_ascii_lowercase();
15    keys.iter().any(|k| k.to_ascii_lowercase() == name)
16}
17
18fn strip(value: &mut Value, keys: &[String]) {
19    match value {
20        Value::Object(map) => {
21            for (_, v) in map.iter_mut() {
22                strip(v, keys);
23            }
24            for key in map.keys().cloned().collect::<Vec<_>>() {
25                if keys.contains(&key.to_ascii_lowercase()) {
26                    map.insert(key, Value::String("[redacted]".to_string()));
27                }
28            }
29        }
30        Value::Array(items) => {
31            for item in items.iter_mut() {
32                strip(item, keys);
33            }
34        }
35        _ => {}
36    }
37}
38
39#[cfg(test)]
40mod tests {
41    use super::*;
42
43    fn keys() -> Vec<String> {
44        vec!["password".into(), "token".into()]
45    }
46
47    #[test]
48    fn nested_and_arrayed_secrets_are_replaced_not_removed() {
49        let out = redact_json_text(
50            r#"{"user":{"name":"a","password":"hunter2"},"items":[{"token":"t"}]}"#,
51            &keys(),
52        )
53        .expect("redacted");
54        let parsed: Value = serde_json::from_str(&out).expect("valid json");
55        assert_eq!(
56            parsed["user"]["password"],
57            Value::String("[redacted]".into())
58        );
59        assert_eq!(
60            parsed["items"][0]["token"],
61            Value::String("[redacted]".into())
62        );
63        assert_eq!(parsed["user"]["name"], Value::String("a".into()));
64    }
65
66    #[test]
67    fn matching_is_case_insensitive() {
68        let out = redact_json_text(r#"{"Password":"hunter2"}"#, &keys()).expect("redacted");
69        assert!(!out.contains("hunter2"));
70    }
71
72    #[test]
73    fn non_json_and_empty_keys_pass_through_untouched() {
74        assert!(redact_json_text("not json at all", &keys()).is_none());
75        assert!(redact_json_text(r#"{"password":"x"}"#, &[]).is_none());
76    }
77
78    #[test]
79    fn header_names_match_case_insensitively() {
80        assert!(is_redacted_header("X-Auth-Token", &["x-auth-token".into()]));
81        assert!(!is_redacted_header("content-type", &keys()));
82    }
83}