Skip to main content

context_harness/
redact.rs

1//! Secret redaction for connector and config values surfaced to operators
2//! (the `ctx sources` / `GET /tools/sources` listing, the `workspaces`
3//! discovery tool, and any future config-introspection surface).
4//!
5//! Policy is **deny-by-default**: when emitting an arbitrary connector config
6//! map — for example a script connector's `extra` table, whose `${VAR}` values
7//! are env-expanded and may be credentials — only an allowlist of known-safe
8//! structural keys is shown verbatim; URL-bearing keys have their embedded
9//! credentials stripped; every other value is replaced with [`REDACTED`].
10//!
11//! This satisfies SPEC-0014 R48/R49: redact credentials, tokens, and
12//! env-expanded values, not only fields whose names literally contain
13//! `secret`/`token`.
14
15use toml::Value;
16
17/// Marker substituted for any redacted value.
18pub const REDACTED: &str = "[REDACTED]";
19
20/// Structural, non-secret connector keys that are safe to show verbatim.
21const SAFE_KEYS: &[&str] = &[
22    "path",
23    "root",
24    "branch",
25    "region",
26    "bucket",
27    "prefix",
28    "include_globs",
29    "exclude_globs",
30    "shallow",
31    "follow_symlinks",
32    "max_extract_bytes",
33    "timeout",
34    "timeout_secs",
35    "enabled",
36    "name",
37    "dims",
38    "batch_size",
39    "max_retries",
40    "provider",
41    "model",
42    "metric",
43    "backend",
44    "index",
45    "fallback",
46];
47
48/// Keys whose values are URLs: shown with embedded credentials stripped.
49const URL_KEYS: &[&str] = &["url", "endpoint_url", "endpoint", "host"];
50
51/// Strip credentials (userinfo) from a URL.
52///
53/// `https://user:token@host/path` becomes `https://[REDACTED]@host/path`. The
54/// scheme, host, port, and path are preserved. Values without a `scheme://`
55/// authority (scp-like `git@host:path`, bare local paths) are returned
56/// unchanged — they carry no `user:secret@` userinfo to leak.
57pub fn redact_url(value: &str) -> String {
58    let Some(scheme_end) = value.find("://") else {
59        return value.to_string();
60    };
61    let auth_start = scheme_end + 3;
62    let rest = &value[auth_start..];
63    let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
64    let authority = &rest[..auth_end];
65    match authority.rfind('@') {
66        Some(at) => {
67            let host = &authority[at + 1..];
68            format!(
69                "{}{}@{}{}",
70                &value[..auth_start],
71                REDACTED,
72                host,
73                &rest[auth_end..]
74            )
75        }
76        None => value.to_string(),
77    }
78}
79
80/// Redact an arbitrary connector-config table, deny-by-default.
81///
82/// Returns a new table safe to serialize into operator-facing output. Nested
83/// tables and arrays are redacted recursively. Unknown keys (anything not in
84/// the structural allowlist or a recognized URL key) have their values
85/// replaced with [`REDACTED`].
86pub fn redact_connector_map(table: &toml::value::Table) -> toml::value::Table {
87    table
88        .iter()
89        .map(|(k, v)| (k.clone(), redact_value(k, v)))
90        .collect()
91}
92
93fn redact_value(key: &str, value: &Value) -> Value {
94    let lk = key.to_ascii_lowercase();
95    match value {
96        Value::Table(t) => Value::Table(redact_connector_map(t)),
97        Value::Array(a) => Value::Array(a.iter().map(|v| redact_value(key, v)).collect()),
98        Value::String(s) if URL_KEYS.contains(&lk.as_str()) => Value::String(redact_url(s)),
99        _ if SAFE_KEYS.contains(&lk.as_str()) => value.clone(),
100        _ => Value::String(REDACTED.to_string()),
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn redacts_userinfo_from_https_url() {
110        assert_eq!(
111            redact_url("https://user:ghp_secrettoken@github.com/acme/platform.git"),
112            "https://[REDACTED]@github.com/acme/platform.git"
113        );
114    }
115
116    #[test]
117    fn redacts_token_only_userinfo() {
118        assert_eq!(
119            redact_url("https://x-access-token:abc123@github.com/o/r.git"),
120            "https://[REDACTED]@github.com/o/r.git"
121        );
122    }
123
124    #[test]
125    fn leaves_clean_url_unchanged() {
126        let url = "https://github.com/acme/platform.git";
127        assert_eq!(redact_url(url), url);
128    }
129
130    #[test]
131    fn leaves_scp_like_and_bare_paths_unchanged() {
132        // scp-like git remote: `git@` is a username, not a secret; no `://`.
133        assert_eq!(
134            redact_url("git@github.com:acme/platform.git"),
135            "git@github.com:acme/platform.git"
136        );
137        assert_eq!(
138            redact_url("/srv/repos/platform.git"),
139            "/srv/repos/platform.git"
140        );
141    }
142
143    #[test]
144    fn preserves_query_and_port() {
145        assert_eq!(
146            redact_url("http://user:pw@localhost:9000/bucket?x=1"),
147            "http://[REDACTED]@localhost:9000/bucket?x=1"
148        );
149    }
150
151    #[test]
152    fn connector_map_is_deny_by_default() {
153        let mut t = toml::value::Table::new();
154        t.insert(
155            "base_url".into(),
156            Value::String("https://svc.example.com".into()),
157        );
158        t.insert(
159            "url".into(),
160            Value::String("https://u:p@git.example.com/x".into()),
161        );
162        t.insert(
163            "api_token".into(),
164            Value::String("${JIRA_API_TOKEN}".into()),
165        );
166        t.insert("project".into(), Value::String("ENG".into()));
167        t.insert("path".into(), Value::String("/scripts/jira.lua".into()));
168
169        let r = redact_connector_map(&t);
170        // URL key: creds stripped, host kept.
171        assert_eq!(
172            r["url"].as_str().unwrap(),
173            "https://[REDACTED]@git.example.com/x"
174        );
175        // Safe structural key: kept verbatim.
176        assert_eq!(r["path"].as_str().unwrap(), "/scripts/jira.lua");
177        // Unknown keys (incl. non-token-named secrets): redacted.
178        assert_eq!(r["api_token"].as_str().unwrap(), REDACTED);
179        assert_eq!(r["project"].as_str().unwrap(), REDACTED);
180        // base_url is not in URL_KEYS or SAFE_KEYS -> redacted (deny-by-default).
181        assert_eq!(r["base_url"].as_str().unwrap(), REDACTED);
182    }
183}