Skip to main content

mermaid_runtime/
redact.rs

1//! Mandatory secret redaction at durable runtime boundaries.
2//!
3//! This module is the single implementation used by both the CLI and the
4//! SQLite repositories. Values remain unmodified while executing; cloned
5//! arguments, outcomes, labels, and URLs are scrubbed immediately before they
6//! cross a persistence or display boundary.
7
8use std::sync::LazyLock;
9
10use regex::Regex;
11
12static SECRET_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
13    let pattern = |source: &str| Regex::new(source).expect("static redaction regex must compile");
14    vec![
15        (
16            pattern(
17                r#"(?i)\b([A-Z0-9_]*(?:API[_-]?KEY|APIKEY|SECRET|TOKEN|PASSWORD|PASSWD|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?))\b\s*[:=]\s*(?:"(?:\\.|[^"\\\r\n])*"?|'(?:\\.|[^'\\\r\n])*'?|[^\s"']+)"#,
18            ),
19            "${1}=[REDACTED]",
20        ),
21        (
22            pattern(r#"(?i)\b(Bearer)\s+[A-Za-z0-9._~+/\-]+={0,}"#),
23            "${1} [REDACTED]",
24        ),
25        (
26            pattern(
27                r#"(?i)\b((?:Proxy-)?Authorization)\b\s*[:=]\s*((?:[A-Za-z][A-Za-z0-9._~+/-]*\s+)?)[^\r\n]+"#,
28            ),
29            "${1}: ${2}[REDACTED]",
30        ),
31        (
32            pattern(r#"\bsk-(?:ant-)?[A-Za-z0-9._\-]{12,}"#),
33            "[REDACTED]",
34        ),
35        (pattern(r#"\bAKIA[0-9A-Z]{16}\b"#), "[REDACTED]"),
36        (pattern(r#"\bgh[pousr]_[A-Za-z0-9]{20,}\b"#), "[REDACTED]"),
37        (pattern(r#"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"#), "[REDACTED]"),
38        (pattern(r#"\bAIza[0-9A-Za-z._\-]{20,}\b"#), "[REDACTED]"),
39        (pattern(r#"\bgsk_[A-Za-z0-9]{20,}\b"#), "[REDACTED]"),
40        (
41            pattern(r#"\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b"#),
42            "[REDACTED]",
43        ),
44        (
45            pattern(r#"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"#),
46            "[REDACTED]",
47        ),
48        (
49            pattern(
50                r#"(?is)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\z)"#,
51            ),
52            "[REDACTED PRIVATE KEY]",
53        ),
54        (pattern(r#"(://[^/:@\s]+:)[^/@\s]{3,}@"#), "${1}[REDACTED]@"),
55        (
56            pattern(
57                r#"(?i)([?&](?:access[_-]?key|access[_-]?token|api[_-]?key|apikey|auth|authorization|client[_-]?secret|code|credential|googleaccessid|id[_-]?token|jwt|key|key-pair-id|password|policy|refresh[_-]?token|secret|session[_-]?token|sig|signature|token|x-amz-credential|x-amz-signature)=)[^&#\s]+"#,
58            ),
59            "${1}[REDACTED]",
60        ),
61    ]
62});
63
64static HTTP_URL_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
65    Regex::new(r#"(?i)https?://[^\s<>"']+"#).expect("static URL redaction regex must compile")
66});
67
68const BARE_CREDENTIAL_KEYS: &[&str] = &[
69    "authorization",
70    "auth",
71    "password",
72    "token",
73    "secret",
74    "apikey",
75];
76
77fn key_names_credential(key: &str) -> bool {
78    let normalized = normalize_credential_key(key);
79    if BARE_CREDENTIAL_KEYS.iter().any(|bare| normalized == *bare) {
80        return true;
81    }
82
83    let segments: Vec<_> = normalized
84        .split('_')
85        .filter(|segment| !segment.is_empty())
86        .collect();
87    if segments.iter().any(|segment| {
88        matches!(
89            *segment,
90            "password" | "passwd" | "secret" | "credential" | "credentials" | "apikey"
91        )
92    }) {
93        return true;
94    }
95    if segments
96        .windows(2)
97        .any(|pair| matches!(pair, ["api" | "access" | "private", "key"]))
98    {
99        return true;
100    }
101
102    // A singular terminal `token` conventionally names the credential itself
103    // (`access_token`, `idToken`, `nullable_token`). Plural or qualified metric
104    // fields such as `max_tokens`, `prompt_tokens`, and `token_count` describe
105    // accounting, not secrets; replacing their numbers with a string would
106    // also corrupt typed persisted payloads such as replay headers.
107    segments.last() == Some(&"token")
108}
109
110fn normalize_credential_key(key: &str) -> String {
111    let mut normalized = String::with_capacity(key.len());
112    let mut previous_was_lower_or_digit = false;
113    for ch in key.chars() {
114        if matches!(ch, '-' | ' ' | '.') {
115            if !normalized.ends_with('_') {
116                normalized.push('_');
117            }
118            previous_was_lower_or_digit = false;
119        } else {
120            if ch.is_ascii_uppercase() && previous_was_lower_or_digit {
121                normalized.push('_');
122            }
123            normalized.extend(ch.to_lowercase());
124            previous_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
125        }
126    }
127    normalized
128}
129
130/// Replace credential-shaped substrings while preserving surrounding context.
131pub fn redact_secrets(input: &str) -> String {
132    let mut output = redact_secret_shapes(input);
133    if HTTP_URL_PATTERN.is_match(&output) {
134        output = std::borrow::Cow::Owned(
135            HTTP_URL_PATTERN
136                .replace_all(&output, |captures: &regex::Captures<'_>| {
137                    sanitize_url_match(&captures[0])
138                })
139                .into_owned(),
140        );
141    }
142    output.into_owned()
143}
144
145fn redact_secret_shapes(input: &str) -> std::borrow::Cow<'_, str> {
146    let mut output = std::borrow::Cow::Borrowed(input);
147    for (regex, replacement) in SECRET_PATTERNS.iter() {
148        if regex.is_match(&output) {
149            output = std::borrow::Cow::Owned(regex.replace_all(&output, *replacement).into_owned());
150        }
151    }
152    output
153}
154
155fn sanitize_url_match(raw: &str) -> String {
156    let core = raw.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '}']);
157    let trailing = &raw[core.len()..];
158    let Ok(url) = url::Url::parse(core) else {
159        return raw.to_string();
160    };
161    format!("{}{trailing}", sanitize_parsed_url(url))
162}
163
164/// Redact every string leaf of a cloned structured payload in place.
165pub fn redact_json(value: &mut serde_json::Value) {
166    match value {
167        serde_json::Value::String(string) => {
168            let redacted = if url::Url::parse(string)
169                .is_ok_and(|url| matches!(url.scheme(), "http" | "https"))
170            {
171                sanitize_url_for_display(string)
172            } else {
173                redact_secrets(string)
174            };
175            if &redacted != string {
176                *string = redacted;
177            }
178        },
179        serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
180        serde_json::Value::Object(map) => {
181            for (key, value) in map {
182                if key_names_credential(key) && !value.is_null() {
183                    *value = serde_json::Value::String("[REDACTED]".to_string());
184                } else {
185                    redact_json(value);
186                }
187            }
188        },
189        _ => {},
190    }
191}
192
193/// Redact a serialized JSON payload. Malformed input is treated as ordinary
194/// text and still receives shape-based scrubbing; persistence never fails open.
195pub fn redact_json_text(input: &str) -> String {
196    match serde_json::from_str::<serde_json::Value>(input) {
197        Ok(mut value) => {
198            redact_json(&mut value);
199            serde_json::to_string(&value).unwrap_or_else(|_| redact_secrets(input))
200        },
201        Err(_) => redact_secrets(input),
202    }
203}
204
205/// Sanitize a URL for display or storage without changing the transport URL.
206pub fn sanitize_url_for_display(input: &str) -> String {
207    let Ok(url) = url::Url::parse(input) else {
208        return redact_secrets(input);
209    };
210    sanitize_parsed_url(url)
211}
212
213fn sanitize_parsed_url(mut url: url::Url) -> String {
214    let _ = url.set_username("");
215    let _ = url.set_password(None);
216    url.set_fragment(None);
217
218    let path = url.path().to_string();
219    let redacted_path = redact_secret_shapes(&path);
220    if redacted_path != path {
221        url.set_path(&redacted_path);
222    }
223
224    if url.query().is_some() {
225        let pairs: Vec<(String, String)> = url
226            .query_pairs()
227            .map(|(key, value)| {
228                let value = if sensitive_query_key(&key) || redact_secrets(&value) != value {
229                    "[REDACTED]".to_string()
230                } else {
231                    value.into_owned()
232                };
233                (key.into_owned(), value)
234            })
235            .collect();
236        url.set_query(None);
237        if !pairs.is_empty() {
238            url.query_pairs_mut().extend_pairs(pairs);
239        }
240    }
241
242    url.to_string()
243}
244
245fn sensitive_query_key(key: &str) -> bool {
246    let normalized = normalize_credential_key(key);
247    key_names_credential(key)
248        || matches!(
249            normalized.as_str(),
250            "auth"
251                | "authorization"
252                | "code"
253                | "googleaccessid"
254                | "google_access_id"
255                | "jwt"
256                | "key"
257                | "key_pair_id"
258                | "policy"
259                | "sig"
260                | "signature"
261        )
262        || normalized.ends_with("_signature")
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn serialized_payload_redaction_covers_signed_urls_and_fetched_secrets() {
271        let input = serde_json::json!({
272            "url": "https://user:password@example.test/a?GoogleAccessId=opaque-id&Signature=opaque-signature#fragment",
273            "model_content": "OPENAI_API_KEY=sk-abcdefghijklmnop1234; see (https://alice:hunter2@example.test/page?q=1#private-state).",
274            "authorization": "opaque-bearer-value"
275        })
276        .to_string();
277        let redacted = redact_json_text(&input);
278        for secret in [
279            "user",
280            "password",
281            "opaque-id",
282            "opaque-signature",
283            "fragment",
284            "sk-abcdefghijklmnop1234",
285            "opaque-bearer-value",
286            "alice",
287            "hunter2",
288            "private-state",
289        ] {
290            assert!(!redacted.contains(secret), "leaked {secret}: {redacted}");
291        }
292        assert!(redacted.contains("REDACTED"));
293    }
294
295    #[test]
296    fn structured_credentials_are_redacted_regardless_of_value_type_or_length() {
297        let mut value = serde_json::json!({
298            "password": "abc",
299            "token": 12345,
300            "nested": {
301                "client_secret": true,
302                "accessToken": 67890,
303                "apiKey": false
304            },
305            "nullable_token": null,
306            "ordinary": 12345,
307            "max_tokens": 4096,
308            "promptTokens": 2048,
309            "token_count": 3,
310            "key": "Enter"
311        });
312        redact_json(&mut value);
313        assert_eq!(value["password"], "[REDACTED]");
314        assert_eq!(value["token"], "[REDACTED]");
315        assert_eq!(value["nested"]["client_secret"], "[REDACTED]");
316        assert_eq!(value["nested"]["accessToken"], "[REDACTED]");
317        assert_eq!(value["nested"]["apiKey"], "[REDACTED]");
318        assert!(value["nullable_token"].is_null());
319        assert_eq!(value["ordinary"], 12345);
320        assert_eq!(value["max_tokens"], 4096);
321        assert_eq!(value["promptTokens"], 2048);
322        assert_eq!(value["token_count"], 3);
323        assert_eq!(value["key"], "Enter");
324    }
325
326    #[test]
327    fn embedded_short_basic_and_private_key_secrets_are_fully_redacted() {
328        let input = "password=abc\nAuthorization: Basic dXNlcjphYmM=\n\
329                     -----BEGIN PRIVATE KEY-----\n\
330                     cHJpdmF0ZS1tYXRlcmlhbA==\n\
331                     -----END PRIVATE KEY-----";
332        let redacted = redact_secrets(input);
333        for secret in ["password=abc", "dXNlcjphYmM=", "cHJpdmF0ZS1tYXRlcmlhbA=="] {
334            assert!(!redacted.contains(secret), "leaked {secret}: {redacted}");
335        }
336        assert!(redacted.contains("password=[REDACTED]"));
337        assert!(redacted.contains("Authorization: Basic [REDACTED]"));
338        assert!(redacted.contains("[REDACTED PRIVATE KEY]"));
339        assert!(!redacted.contains("-----END PRIVATE KEY-----"));
340
341        let truncated =
342            redact_secrets("before\n-----BEGIN EC PRIVATE KEY-----\ncHJpdmF0ZS1tYXRlcmlhbA==");
343        assert_eq!(truncated, "before\n[REDACTED PRIVATE KEY]");
344
345        let uppercase_url =
346            redact_secrets("see HTTPS://alice:pw@example.test/path?token=abc#private-state");
347        for secret in ["alice", "pw", "abc", "private-state"] {
348            assert!(
349                !uppercase_url.contains(secret),
350                "uppercase URL leaked {secret}: {uppercase_url}"
351            );
352        }
353    }
354
355    #[test]
356    fn quoted_assignments_and_authorization_schemes_are_fully_redacted() {
357        let input = "password=\"alpha beta\"\n\
358                     client_secret='gamma delta'\n\
359                     Authorization: Digest username=\"alice\", response=\"digest-secret\"\n\
360                     Proxy-Authorization: Custom opaque proxy credential\n\
361                     Authorization is required for this operation.";
362        let redacted = redact_secrets(input);
363
364        for secret in [
365            "alpha beta",
366            "gamma delta",
367            "alice",
368            "digest-secret",
369            "opaque proxy credential",
370        ] {
371            assert!(!redacted.contains(secret), "leaked {secret}: {redacted}");
372        }
373        assert!(redacted.contains("password=[REDACTED]"));
374        assert!(redacted.contains("client_secret=[REDACTED]"));
375        assert!(redacted.contains("Authorization: Digest [REDACTED]"));
376        assert!(redacted.contains("Proxy-Authorization: Custom [REDACTED]"));
377        assert!(redacted.contains("Authorization is required for this operation."));
378    }
379
380    #[test]
381    fn provider_signed_url_credentials_are_sanitized() {
382        for url in [
383            "https://storage.example/object?X-Goog-Credential=acct%2Fscope&X-Goog-Signature=abcdef&X-Goog-Date=20260721T000000Z",
384            "https://s3.example/object?X-Amz-Credential=acct%2Fscope&X-Amz-Signature=abcdef&X-Amz-Security-Token=session-token&response-content-type=text%2Fplain",
385        ] {
386            let sanitized = sanitize_url_for_display(url);
387            assert!(!sanitized.contains("acct"), "{sanitized}");
388            assert!(!sanitized.contains("abcdef"), "{sanitized}");
389            assert!(!sanitized.contains("session-token"), "{sanitized}");
390            assert!(sanitized.contains("REDACTED"), "{sanitized}");
391        }
392    }
393
394    #[test]
395    fn camel_case_query_credentials_are_sanitized_without_hiding_ordinary_values() {
396        let sanitized = sanitize_url_for_display(
397            "https://example.test/callback?accessToken=opaque-access&clientSecret=opaque-client&ordinary=visible",
398        );
399
400        assert!(!sanitized.contains("opaque-access"), "{sanitized}");
401        assert!(!sanitized.contains("opaque-client"), "{sanitized}");
402        assert!(sanitized.contains("ordinary=visible"), "{sanitized}");
403    }
404
405    #[test]
406    fn exact_url_paths_and_short_bearer_values_are_shape_redacted() {
407        let token = "sk-abcdefghijklmnop1234";
408        let sanitized = sanitize_url_for_display(&format!("https://example.test/download/{token}"));
409        assert!(!sanitized.contains(token), "URL path leaked: {sanitized}");
410        assert!(sanitized.contains("REDACTED"));
411        assert_eq!(
412            redact_secrets("Authorization: Bearer abc"),
413            "Authorization: Bearer [REDACTED]"
414        );
415    }
416}