Skip to main content

netget/utils/
redact.rs

1//! Redact credentials from JSON before it is displayed or logged.
2//!
3//! Startup parameters and actions are echoed in two places an operator reads: the
4//! `open_client` / `open_server` summary pushed to the dashboard and MCP status stream, and the
5//! executor's per-action DEBUG line in `netget.log`. Both printed the RADIUS client's shared
6//! `secret`, the MQTT client's `password` and anything else of that kind verbatim. The protocol
7//! still receives the real value; only what is shown is replaced.
8//!
9//! Matching is by key name, case-insensitively, on a substring, so `secret`, `shared_secret`,
10//! `password`, `bind_password` and `api_key` are all caught. A false positive costs a line of
11//! display; a false negative prints a credential.
12
13use serde_json::Value;
14
15/// Substrings of a key name that mark its value as a credential.
16pub const SENSITIVE_KEY_PARTS: &[&str] = &[
17    "secret",
18    "password",
19    "passphrase",
20    "passwd",
21    "api_key",
22    "apikey",
23    "private_key",
24    "privatekey",
25    "credential",
26    "access_token",
27    "accesstoken",
28    "refresh_token",
29    "refreshtoken",
30    "id_token",
31    "idtoken",
32    "auth_token",
33    "authtoken",
34    "authorization",
35    "cookie",
36    // A credential that is not called one: the HTTP proxy client's `proxy_auth`
37    // (`user:password`).
38    "proxy_auth",
39];
40
41/// Substrings of a key name whose value is masked wherever it is displayed, without making
42/// the request that carries it private.
43///
44/// STOMP's `passcode` and the SNMP `community` string are credentials, but every SNMP event
45/// and every STOMP CONNECT carries one, so treating them as [`SENSITIVE_KEY_PARTS`] would turn
46/// off tracing and the access-log detail for the whole of both protocols. They are hidden
47/// from the `open_client` summary, the executor's DEBUG line and startup-parameter errors
48/// ([`redact_sensitive`], [`is_shown_redacted`]) and nothing else.
49pub const DISPLAY_ONLY_KEY_PARTS: &[&str] = &["passcode", "community"];
50
51/// What a redacted value is shown as.
52pub const REDACTED: &str = "<redacted>";
53
54/// Whether a key names a credential.
55///
56/// Bare `key` is not matched: `routing_key`, `access_key_id` and `key_type` are not secrets,
57/// and the ones that are (`api_key`, `private_key`, `secret_access_key`) match on their own
58/// part. Bare `auth` is a value-shaped case, see [`is_sensitive_entry`].
59pub fn is_sensitive_key(key: &str) -> bool {
60    let key = key.to_ascii_lowercase().replace('-', "_");
61    key == "token"
62        || key.ends_with("_token")
63        || SENSITIVE_KEY_PARTS.iter().any(|part| key.contains(part))
64}
65
66/// Whether a key/value pair holds a credential.
67///
68/// [`is_sensitive_key`] on the name, plus one case the name alone cannot decide: a key that
69/// is exactly `auth` holding a **string** is a credential (the WebDAV client's `auth` is
70/// `username:password`), while an `auth` **object** is a block to walk — Vault's answer
71/// carries `auth.client_token` beside `auth.token_type`, and only the first is hidden.
72/// `auth_type`, `auth_url`, `authenticated` and `auth_method` describe a mechanism and are
73/// shown, which a substring match would have hidden for nothing.
74pub fn is_sensitive_entry(key: &str, value: &Value) -> bool {
75    if value.is_null() {
76        return false;
77    }
78    if is_sensitive_key(key) {
79        return true;
80    }
81    let key = key.to_ascii_lowercase();
82    key == "auth" && value.is_string()
83}
84
85/// Whether a key/value pair is shown as [`REDACTED`]: every [`is_sensitive_entry`], plus the
86/// [`DISPLAY_ONLY_KEY_PARTS`].
87pub fn is_shown_redacted(key: &str, value: &Value) -> bool {
88    if value.is_null() {
89        return false;
90    }
91    if is_sensitive_entry(key, value) {
92        return true;
93    }
94    let key = key.to_ascii_lowercase();
95    DISPLAY_ONLY_KEY_PARTS.iter().any(|part| key.contains(part))
96}
97
98/// Whether this request offers an action that can carry credentials. Such model
99/// replies can be malformed or plain text, so hiding whole payloads is safer
100/// than trying to recover credential fields from an unparseable response.
101pub fn actions_have_credentials(actions: &[crate::llm::actions::ActionDefinition]) -> bool {
102    actions
103        .iter()
104        .any(|action| action.parameters.iter().any(|p| is_sensitive_key(&p.name)))
105}
106
107/// Detect credential-bearing structured input without cloning or recursive traversal.
108/// A budget-exceeding input is private because its remaining keys cannot be proven safe.
109/// Null credential fields do not contain a value. Bounds apply to node count, depth and
110/// retained string/key content; no request-wide or global privacy state is retained.
111pub fn contains_credentials(value: &Value) -> bool {
112    const MAX_NODES: usize = 4096;
113    const MAX_DEPTH: usize = 64;
114    const MAX_BYTES: usize = 256 * 1024;
115    let mut pending = vec![(value, 0usize)];
116    let mut nodes = 0usize;
117    let mut bytes = 0usize;
118    while let Some((value, depth)) = pending.pop() {
119        nodes += 1;
120        bytes = bytes.saturating_add(std::mem::size_of::<Value>());
121        if nodes > MAX_NODES || depth > MAX_DEPTH || bytes > MAX_BYTES {
122            return true;
123        }
124        match value {
125            Value::String(s) => bytes = bytes.saturating_add(s.len()),
126            Value::Array(values) => {
127                if values.len() > MAX_NODES.saturating_sub(nodes + pending.len()) {
128                    return true;
129                }
130                pending.extend(values.iter().map(|value| (value, depth + 1)));
131            }
132            Value::Object(values) => {
133                if values.len() > MAX_NODES.saturating_sub(nodes + pending.len()) {
134                    return true;
135                }
136                for (key, value) in values {
137                    bytes = bytes.saturating_add(key.len());
138                    if bytes > MAX_BYTES {
139                        return true;
140                    }
141                    if is_sensitive_entry(key, value) {
142                        return true;
143                    }
144                    pending.push((value, depth + 1));
145                }
146            }
147            _ => {}
148        }
149        if bytes > MAX_BYTES {
150            return true;
151        }
152    }
153    false
154}
155
156/// Remove untyped context from a private request's error while preserving the
157/// numeric overload category that protocols use to choose a retryable wire reply.
158pub fn hide_error_details(error: anyhow::Error) -> anyhow::Error {
159    if let Some(category) = error.downcast_ref::<crate::llm::rate_limiter::RateLimitError>() {
160        anyhow::Error::new(*category)
161    } else {
162        anyhow::anyhow!("credential-bearing request failed; diagnostics hidden")
163    }
164}
165
166/// Deepest nesting walked. `serde_json` refuses to parse past 128 levels, so a parsed value
167/// never reaches this; a value built in code might, and past it the subtree is shown as
168/// [`REDACTED`] whole rather than walked on a shrinking stack.
169pub const MAX_REDACT_DEPTH: usize = 64;
170
171/// A copy of `value` with every credential-named key's value replaced by [`REDACTED`], at any
172/// depth up to [`MAX_REDACT_DEPTH`].
173pub fn redact_sensitive(value: &Value) -> Value {
174    redact_at(value, 0)
175}
176
177fn redact_at(value: &Value, depth: usize) -> Value {
178    if depth > MAX_REDACT_DEPTH {
179        return Value::String(REDACTED.to_string());
180    }
181    match value {
182        Value::Object(map) => Value::Object(
183            map.iter()
184                .map(|(k, v)| {
185                    let shown = if is_shown_redacted(k, v) {
186                        Value::String(REDACTED.to_string())
187                    } else {
188                        redact_at(v, depth + 1)
189                    };
190                    (k.clone(), shown)
191                })
192                .collect(),
193        ),
194        Value::Array(items) => {
195            Value::Array(items.iter().map(|v| redact_at(v, depth + 1)).collect())
196        }
197        other => other.clone(),
198    }
199}