Skip to main content

dre_core/
secrets.rs

1//! Secret masking. The value of every environment variable named `DRE_SECRET_*` is replaced by
2//! `*****` in everything DRE writes for people to read: the console, `logs/dre.log`, JSON events,
3//! `run_results.json` and the compiled SQL in `target/compiled/`. The SQL actually sent to the
4//! database is unchanged. `mask_secrets: false` in `dre_project.yml` turns masking off.
5//!
6//! JSON is redacted structurally, before it is serialized: serializing escapes `"`, `\` and
7//! control characters, so masking the finished text would miss a secret that contains them. Use
8//! [`to_json_pretty`] / [`to_json_line`] for every JSON surface, never `mask` on JSON text.
9//!
10//! Values shorter than 4 characters aren't masked: replacing every `a` or `12` in a log would
11//! make it unreadable, and a value that short isn't a secret anyway.
12
13use std::borrow::Cow;
14use std::sync::LazyLock;
15use std::sync::atomic::{AtomicBool, Ordering};
16
17pub const PREFIX: &str = "DRE_SECRET_";
18pub const MASK: &str = "*****";
19pub const MASKED_SOURCE_ERROR: &str =
20    "source error details omitted because the request contains a masked secret";
21const MIN_LEN: usize = 4;
22
23static ENABLED: AtomicBool = AtomicBool::new(true);
24
25fn eligible(value: &str) -> bool {
26    value.chars().count() >= MIN_LEN
27}
28
29/// Secret values, longest first so a secret containing another is masked whole.
30static SECRETS: LazyLock<Vec<String>> = LazyLock::new(|| {
31    let mut v: Vec<String> = std::env::vars()
32        .filter(|(k, v)| k.starts_with(PREFIX) && eligible(v))
33        .map(|(_, v)| v)
34        .collect();
35    v.sort_by_key(|s| std::cmp::Reverse(s.len()));
36    v.dedup();
37    v
38});
39
40pub fn set_enabled(on: bool) {
41    ENABLED.store(on, Ordering::Relaxed);
42}
43
44pub(crate) fn enabled() -> bool {
45    ENABLED.load(Ordering::Relaxed)
46}
47
48fn mask_with<'a>(s: &'a str, secrets: &[String]) -> Cow<'a, str> {
49    if !secrets.iter().any(|x| s.contains(x.as_str())) {
50        return Cow::Borrowed(s);
51    }
52    let mut out = s.to_string();
53    for x in secrets {
54        out = out.replace(x.as_str(), MASK);
55    }
56    Cow::Owned(out)
57}
58
59/// `s` with every secret value replaced by `*****`.
60pub fn mask(s: &str) -> Cow<'_, str> {
61    if !ENABLED.load(Ordering::Relaxed) {
62        return Cow::Borrowed(s);
63    }
64    mask_with(s, &SECRETS)
65}
66
67/// Mask a rendered message without changing the report payload. Markdown interpolation has
68/// already escaped punctuation, so both literal and escaped secret values must be matched.
69pub(crate) fn mask_markdown(s: &str) -> Cow<'_, str> {
70    static VALUES: LazyLock<Vec<String>> = LazyLock::new(|| {
71        let mut values: Vec<String> = SECRETS
72            .iter()
73            .flat_map(|s| [s.clone(), dre_protocol::markdown::escape(s)])
74            .collect();
75        values.sort_by_key(|s| std::cmp::Reverse(s.len()));
76        values.dedup();
77        values
78    });
79    if !ENABLED.load(Ordering::Relaxed) {
80        return Cow::Borrowed(s);
81    }
82    mask_with(s, &VALUES)
83}
84
85/// Message titles collapse whitespace before delivery. Match that representation as well as
86/// the literal secret when protecting a title in logs and run metadata.
87pub(crate) fn mask_message_title(s: &str) -> Cow<'_, str> {
88    static VALUES: LazyLock<Vec<String>> = LazyLock::new(|| {
89        let mut values: Vec<String> = SECRETS
90            .iter()
91            .flat_map(|s| [s.clone(), s.split_whitespace().collect::<Vec<_>>().join(" ")])
92            .filter(|s| !s.is_empty())
93            .collect();
94        values.sort_by_key(|s| std::cmp::Reverse(s.len()));
95        values.dedup();
96        values
97    });
98    if !ENABLED.load(Ordering::Relaxed) {
99        return Cow::Borrowed(s);
100    }
101    mask_with(s, &VALUES)
102}
103
104/// Whether masking is enabled and `s` contains a complete registered secret.
105pub fn contains_secret(s: &str) -> bool {
106    ENABLED.load(Ordering::Relaxed) && SECRETS.iter().any(|secret| s.contains(secret))
107}
108
109fn mask_source_error_with<'a>(error: &'a str, sensitive: bool, secrets: &[String]) -> Cow<'a, str> {
110    if sensitive {
111        Cow::Borrowed(MASKED_SOURCE_ERROR)
112    } else {
113        mask_with(error, secrets)
114    }
115}
116
117/// Mask a source error before it reaches logs or output. Source plugins can transform or truncate
118/// a request in their diagnostics, so exact replacement is not sufficient for a secret-bearing
119/// request. In that case the opaque detail is omitted instead.
120pub fn mask_source_error(error: &str, sensitive: bool) -> Cow<'_, str> {
121    if !ENABLED.load(Ordering::Relaxed) {
122        return Cow::Borrowed(error);
123    }
124    mask_source_error_with(error, sensitive, &SECRETS)
125}
126
127fn unique_masked_names_with(names: &[String], secrets: &[String]) -> Vec<String> {
128    use std::collections::BTreeSet;
129
130    let masked: Vec<String> = names
131        .iter()
132        .map(|name| mask_with(name, secrets).into_owned())
133        .collect();
134    if names == masked {
135        return names.to_vec();
136    }
137    let mut used = BTreeSet::new();
138    let mut output = vec![String::new(); names.len()];
139    let mut pending = Vec::new();
140
141    // Literal names keep their public identity and reserve every suffix they already occupy.
142    for (index, (original, protected)) in names.iter().zip(&masked).enumerate() {
143        if original == protected {
144            used.insert(original.clone());
145            output[index].clone_from(original);
146        } else {
147            pending.push((protected.clone(), original.clone(), index));
148        }
149    }
150
151    // Original names make the assignment stable when source ordering changes.
152    pending.sort();
153    for (base, _, index) in pending {
154        let mut candidate = base.clone();
155        let mut suffix = 2;
156        while !used.insert(candidate.clone()) {
157            candidate = format!("{base}#{suffix}");
158            suffix += 1;
159        }
160        output[index] = candidate;
161    }
162    output
163}
164
165fn redact_json_with(v: &mut serde_json::Value, secrets: &[String]) {
166    use serde_json::Value;
167    match v {
168        Value::String(s) => {
169            if let Cow::Owned(m) = mask_with(s, secrets) {
170                *s = m;
171            }
172        }
173        Value::Array(a) => a.iter_mut().for_each(|v| redact_json_with(v, secrets)),
174        Value::Object(o) => {
175            let entries = std::mem::take(o);
176            let keys: Vec<String> = entries.keys().cloned().collect();
177            let protected_keys = unique_masked_names_with(&keys, secrets);
178            for ((_, mut val), protected_key) in entries.into_iter().zip(protected_keys) {
179                redact_json_with(&mut val, secrets);
180                o.insert(protected_key, val);
181            }
182        }
183        _ => {}
184    }
185}
186
187/// `v` with every secret value replaced by `*****` in all strings and object keys. Colliding
188/// masked keys receive `#2`, `#3`, and so on rather than silently replacing another entry.
189pub fn redact_json(v: &mut serde_json::Value) {
190    if ENABLED.load(Ordering::Relaxed) {
191        redact_json_with(v, &SECRETS);
192    }
193}
194
195fn redacted<T: serde::Serialize>(v: &T) -> serde_json::Result<serde_json::Value> {
196    let mut v = serde_json::to_value(v)?;
197    redact_json(&mut v);
198    Ok(v)
199}
200
201/// Pretty JSON of `v` with secrets redacted first.
202pub fn to_json_pretty<T: serde::Serialize>(v: &T) -> serde_json::Result<String> {
203    serde_json::to_string_pretty(&redacted(v)?)
204}
205
206/// One-line JSON of `v` with secrets redacted first.
207pub fn to_json_line<T: serde::Serialize>(v: &T) -> serde_json::Result<String> {
208    serde_json::to_string(&redacted(v)?)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use serde_json::json;
215
216    fn secrets(values: &[&str]) -> Vec<String> {
217        let mut values: Vec<String> = values.iter().map(|v| (*v).to_string()).collect();
218        values.sort_by_key(|s| std::cmp::Reverse(s.len()));
219        values
220    }
221
222    #[test]
223    fn redacts_nested_values_longest_first() {
224        let secrets = secrets(&["nested-secret", "nested-secret-and-more"]);
225        let mut value = json!({"items": ["nested-secret-and-more", {"value": "x nested-secret y"}]});
226        redact_json_with(&mut value, &secrets);
227        assert_eq!(value, json!({"items": ["*****", {"value": "x ***** y"}]}));
228    }
229
230    #[test]
231    fn preserves_entries_when_masked_keys_collide() {
232        let secrets = secrets(&["alpha-secret-key", "bravo-secret-key"]);
233        let mut value = json!({
234            "alpha-secret-key": 1,
235            "bravo-secret-key": 2,
236            "*****": 3,
237            "*****#2": 4,
238        });
239        redact_json_with(&mut value, &secrets);
240        assert_eq!(
241            value,
242            json!({
243                "*****": 3,
244                "*****#2": 4,
245                "*****#3": 1,
246                "*****#4": 2,
247            })
248        );
249    }
250
251    #[test]
252    fn masking_one_name_does_not_rename_duplicate_literal_names() {
253        let secrets = secrets(&["secret-column"]);
254        let names = ["a", "a", "secret-column"].map(str::to_string);
255        assert_eq!(unique_masked_names_with(&names, &secrets), ["a", "a", "*****"]);
256    }
257
258    #[test]
259    fn suppresses_transformed_source_errors_only_for_secret_requests() {
260        let secrets = secrets(&["long-secret-value-that-a-source-truncated"]);
261        let error = "LINE 1: select * from long-secret-value-that-a-source...";
262        assert_eq!(mask_source_error_with(error, true, &secrets), MASKED_SOURCE_ERROR);
263        assert_eq!(
264            mask_source_error_with("ordinary syntax error", false, &secrets),
265            "ordinary syntax error"
266        );
267    }
268
269    #[test]
270    fn minimum_length_counts_unicode_characters() {
271        assert!(!eligible("ééé"));
272        assert!(eligible("éééé"));
273    }
274}