Skip to main content

soma_mcp_client/security/
redact.rs

1use 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;