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}