1use secrecy::{ExposeSecret, SecretString};
2use serde_json::Value;
3
4const SENSITIVE_KEY_PARTS: &[&str] = &[
5 "secret",
6 "password",
7 "token",
8 "apikey",
9 "privatekey",
10 "sharedsecret",
11];
12
13pub const REDACTED_MARKER: &str = "[redacted]";
15
16pub fn redact_sensitive_fields(mut value: Value) -> Value {
18 redact_sensitive_fields_in_place(&mut value);
19 value
20}
21
22pub fn redact_sensitive_fields_in_place(value: &mut Value) {
24 match value {
25 Value::Object(map) => {
26 for (key, value) in map {
27 if is_sensitive_key(key) {
28 *value = redacted_value();
29 } else {
30 redact_sensitive_fields_in_place(value);
31 }
32 }
33 }
34 Value::Array(values) => {
35 for value in values {
36 redact_sensitive_fields_in_place(value);
37 }
38 }
39 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
40 }
41}
42
43fn is_sensitive_key(key: &str) -> bool {
44 let normalized = normalize_key(key);
45 SENSITIVE_KEY_PARTS
46 .iter()
47 .any(|part| normalized.contains(part))
48}
49
50fn normalize_key(key: &str) -> String {
51 key.chars()
52 .filter(|c| c.is_ascii_alphanumeric())
53 .flat_map(char::to_lowercase)
54 .collect()
55}
56
57fn redacted_value() -> Value {
58 let marker = SecretString::from(REDACTED_MARKER.to_owned());
59 Value::String(marker.expose_secret().to_owned())
60}
61
62#[cfg(test)]
63mod tests {
64 use serde_json::json;
65
66 use super::*;
67
68 #[test]
69 fn redacts_nested_object_fields() {
70 let settings = json!({
71 "response": {
72 "tsigKeys": [
73 {
74 "name": "zone-transfer",
75 "sharedSecret": "actual-secret"
76 }
77 ]
78 }
79 });
80
81 let redacted = redact_sensitive_fields(settings);
82
83 assert_eq!(
84 redacted["response"]["tsigKeys"][0]["sharedSecret"],
85 REDACTED_MARKER
86 );
87 assert_eq!(redacted["response"]["tsigKeys"][0]["name"], "zone-transfer");
88 }
89
90 #[test]
91 fn redacts_password_fields_even_when_already_masked() {
92 let settings = json!({
93 "dnsTlsCertificatePassword": "********",
94 "webServiceTlsCertificatePassword": ""
95 });
96
97 let redacted = redact_sensitive_fields(settings);
98
99 assert_eq!(redacted["dnsTlsCertificatePassword"], REDACTED_MARKER);
100 assert_eq!(
101 redacted["webServiceTlsCertificatePassword"],
102 REDACTED_MARKER
103 );
104 }
105
106 #[test]
107 fn leaves_unrelated_fields_unchanged() {
108 let settings = json!({
109 "version": "13.4.1",
110 "clusterDomain": "cluster.example.test",
111 "dnsServerDomain": "dns.example.test"
112 });
113
114 let redacted = redact_sensitive_fields(settings.clone());
115
116 assert_eq!(redacted, settings);
117 }
118
119 #[test]
120 fn redacts_arrays_of_objects() {
121 let settings = json!({
122 "providers": [
123 { "apiKey": "one", "name": "primary" },
124 { "api_token": "two", "name": "secondary" },
125 { "private-key": "three", "name": "tertiary" }
126 ]
127 });
128
129 let redacted = redact_sensitive_fields(settings);
130
131 assert_eq!(redacted["providers"][0]["apiKey"], REDACTED_MARKER);
132 assert_eq!(redacted["providers"][1]["api_token"], REDACTED_MARKER);
133 assert_eq!(redacted["providers"][2]["private-key"], REDACTED_MARKER);
134 assert_eq!(redacted["providers"][2]["name"], "tertiary");
135 }
136}