soma_mcp_client/security/
redact.rs1use serde_json::Value;
2use url::Url;
3
4const REDACTED: &str = "[redacted]";
5
6#[must_use]
7pub fn is_sensitive_key(key: &str) -> bool {
8 let normalized = key.to_ascii_lowercase().replace('-', "_");
9 if matches!(
10 normalized.as_str(),
11 "sort_key" | "cache_key" | "idempotency_key" | "partition_key" | "primary_key"
12 ) {
13 return false;
14 }
15 matches!(
16 normalized.as_str(),
17 "token"
18 | "access_token"
19 | "id_token"
20 | "refresh_token"
21 | "apikey"
22 | "api_key"
23 | "password"
24 | "passwd"
25 | "secret"
26 | "client_secret"
27 | "authorization"
28 | "bearer"
29 | "cookie"
30 | "session"
31 | "session_id"
32 | "code"
33 ) || normalized.ends_with("_token")
34 || normalized.ends_with("_secret")
35 || normalized.ends_with("_password")
36 || normalized.ends_with("_key")
37}
38
39#[must_use]
40pub fn redact_url(raw: &str) -> String {
41 match Url::parse(raw) {
42 Ok(parsed) => redact_parsed_url(parsed),
43 Err(_) => redact_secret_like_segments(raw),
44 }
45}
46
47#[must_use]
48pub fn redact_stdio_value(value: &str) -> String {
49 if let Some((key, _)) = value.split_once('=') {
50 if is_sensitive_key(key) {
51 return format!("{key}={REDACTED}");
52 }
53 }
54
55 if let Some(flag) = value.strip_prefix("--") {
56 let key = flag.split_once('=').map_or(flag, |(key, _)| key);
57 if is_sensitive_key(key) {
58 return format!("--{key}={REDACTED}");
59 }
60 }
61
62 redact_secret_like_segments(value)
63}
64
65#[must_use]
66pub fn redact_stdio_args(args: &[String]) -> Vec<String> {
67 let mut redacted = Vec::with_capacity(args.len());
68 let mut redact_next = false;
69 for arg in args {
70 if redact_next {
71 redacted.push(REDACTED.to_owned());
72 redact_next = false;
73 continue;
74 }
75 let split_sensitive_flag = arg
76 .strip_prefix("--")
77 .map(|value| value.split_once('=').map_or(value, |(key, _)| key))
78 .is_some_and(is_sensitive_key);
79 if split_sensitive_flag && !arg.contains('=') {
80 redacted.push(arg.clone());
81 redact_next = true;
82 continue;
83 }
84 redacted.push(redact_stdio_value(arg));
85 }
86 redacted
87}
88
89#[must_use]
90pub fn redact_json_value(value: &Value) -> Value {
91 match value {
92 Value::Object(map) => Value::Object(
93 map.iter()
94 .map(|(key, value)| {
95 if is_sensitive_key(key) {
96 (key.clone(), Value::String(REDACTED.to_owned()))
97 } else {
98 (key.clone(), redact_json_value(value))
99 }
100 })
101 .collect(),
102 ),
103 Value::Array(values) => Value::Array(values.iter().map(redact_json_value).collect()),
104 Value::String(value) => Value::String(redact_secret_like_segments(value)),
105 other => other.clone(),
106 }
107}
108
109#[must_use]
110pub fn redact_log_line(line: &str) -> String {
111 let mut out = Vec::new();
112 let mut auth_tokens_to_redact = 0usize;
113 for token in line.split_whitespace() {
114 if auth_tokens_to_redact > 0 {
115 out.push(REDACTED.to_owned());
116 auth_tokens_to_redact -= 1;
117 continue;
118 }
119 if token
120 .trim_end_matches(':')
121 .eq_ignore_ascii_case("authorization")
122 {
123 out.push("Authorization:".to_owned());
124 auth_tokens_to_redact = 2;
125 continue;
126 }
127 out.push(redact_stdio_value(token));
128 }
129 out.join(" ")
130}
131
132fn redact_parsed_url(mut parsed: Url) -> String {
133 let _ = parsed.set_username("");
134 let _ = parsed.set_password(None);
135 let query = parsed.query().map(redact_query_pairs);
136 parsed.set_query(query.as_deref());
137 parsed.set_fragment(None);
138 parsed.to_string()
139}
140
141fn redact_query_pairs(query: &str) -> String {
142 query
143 .split('&')
144 .filter(|pair| !pair.is_empty())
145 .map(|pair| {
146 let (key, value) = pair
147 .split_once('=')
148 .map_or((pair, ""), |(key, value)| (key, value));
149 if is_sensitive_key(key) {
150 format!("{key}={REDACTED}")
151 } else if value.is_empty() {
152 key.to_owned()
153 } else {
154 format!("{key}={value}")
155 }
156 })
157 .collect::<Vec<_>>()
158 .join("&")
159}
160
161fn redact_secret_like_segments(value: &str) -> String {
162 if value
163 .trim_start()
164 .to_ascii_lowercase()
165 .starts_with("bearer ")
166 {
167 return REDACTED.to_owned();
168 }
169 value
170 .split_whitespace()
171 .map(|segment| {
172 if looks_secret_like(segment) {
173 REDACTED.to_owned()
174 } else {
175 segment.to_owned()
176 }
177 })
178 .collect::<Vec<_>>()
179 .join(" ")
180}
181
182fn looks_secret_like(value: &str) -> bool {
183 let trimmed = value.trim_matches(|ch: char| matches!(ch, '"' | '\'' | ',' | ';'));
184 trimmed.starts_with("Bearer ")
185 || trimmed.starts_with("sk-")
186 || trimmed.starts_with("ghp_")
187 || trimmed.starts_with("github_pat_")
188 || looks_like_jwt(trimmed)
189}
190
191fn looks_like_jwt(value: &str) -> bool {
192 let parts: Vec<&str> = value.split('.').collect();
193 parts.len() == 3 && parts.iter().all(|part| part.len() >= 8)
194}
195
196#[cfg(test)]
197#[path = "redact_tests.rs"]
198mod tests;