Skip to main content

agent_first_data/
redaction.rs

1mod url;
2
3use self::url::{redact_url_field_value, redact_url_in_str, redact_urls_in_text_with_context};
4use serde_json::Value;
5use std::collections::HashSet;
6
7// ═══════════════════════════════════════════
8// Public API: Output Formatters
9// ═══════════════════════════════════════════
10
11/// Which fields a [`Redactor`] scrubs. The default is [`RedactionPolicy::All`].
12///
13/// The policy selects a *scope* inside a structured value. A command line
14/// ([`Redactor::argv`]) and a bare URL string ([`Redactor::url`]) have no
15/// `result`/`trace` split to scope to, so on those two paths `TraceOnly`
16/// redacts in full like `All`, and only [`RedactionPolicy::Off`] turns
17/// redaction off.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub enum RedactionPolicy {
20    /// Redact every secret field anywhere in the value (the default).
21    #[default]
22    All,
23    /// Redact only inside the top-level `trace` object.
24    TraceOnly,
25    /// Do not redact anything.
26    Off,
27}
28
29impl RedactionPolicy {
30    /// Whether this policy redacts an input that carries no scope of its own —
31    /// a command line or a single URL string, as opposed to a JSON value with a
32    /// `trace` object to narrow to.
33    ///
34    /// `TraceOnly` narrows *where* redaction applies within a value; it does not
35    /// weaken redaction. A standalone argv or URL has no non-`trace` half to
36    /// leave alone — and both are diagnostic material by construction, the very
37    /// thing `TraceOnly` scrubs — so `TraceOnly` redacts them in full, exactly
38    /// like `All`. Only `Off`, the caller explicitly asking for raw output,
39    /// disables redaction, and it does so on all three paths alike.
40    fn redacts_unscoped_input(self) -> bool {
41        !matches!(self, RedactionPolicy::Off)
42    }
43}
44
45/// Rendering style for plain (logfmt) output only. JSON and YAML are always
46/// structure-preserving and ignore this.
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
48pub enum PlainStyle {
49    /// Human-readable AFDATA rendering: strip suffixes and format values.
50    #[default]
51    Readable,
52    /// Schema-preserving rendering: keep keys and values unchanged after redaction.
53    Raw,
54}
55
56/// Configurable redaction builder for secrets and legacy field names.
57///
58/// `Redactor` encapsulates redaction policy, custom secret field names, and
59/// exact legacy URL field names.
60/// Build with [`Redactor::new()`], configure via builder methods, then pass to
61/// redaction functions like [`redacted_value`] or [`redact_url_secrets`].
62#[derive(Clone, Debug, Default, PartialEq, Eq)]
63pub struct Redactor {
64    policy: RedactionPolicy,
65    secret_names: Vec<String>,
66    url_names: Vec<String>,
67    reveal_names: Vec<String>,
68}
69
70impl Redactor {
71    /// Create a new default redactor (full redaction, no custom secret names).
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Set custom field names to treat as secrets in addition to `_secret` suffixes.
77    ///
78    /// Matching is exact field-name equality at any nesting level. The same
79    /// list also matches URL query-parameter names inside `_url` fields.
80    /// Builder style: returns `self`.
81    pub fn secret_names<I: IntoIterator<Item = S>, S: Into<String>>(mut self, names: I) -> Self {
82        self.secret_names = names.into_iter().map(|s| s.into()).collect();
83        self
84    }
85
86    /// Set exact legacy field names whose values should receive URL-aware
87    /// redaction in addition to `_url`/`_URL` suffixed fields.
88    ///
89    /// A matching string is handled like an `_url` value. Arrays and nested
90    /// collections recursively apply that treatment to every string leaf.
91    /// Matching is exact and does not normalize case, whitespace, or spelling.
92    #[must_use]
93    pub fn url_names<I: IntoIterator<Item = S>, S: Into<String>>(mut self, names: I) -> Self {
94        self.url_names = names.into_iter().map(Into::into).collect();
95        self
96    }
97
98    /// Reveal exactly these structured field names at any depth. Other secrets
99    /// remain masked; URL parameters and argv never use this exception.
100    #[must_use]
101    pub fn reveal_names<I: IntoIterator<Item = S>, S: Into<String>>(mut self, names: I) -> Self {
102        self.reveal_names = names.into_iter().map(Into::into).collect();
103        self
104    }
105
106    /// Set the redaction policy (default: full redaction).
107    /// Builder style: returns `self`.
108    pub fn policy(mut self, policy: RedactionPolicy) -> Self {
109        self.policy = policy;
110        self
111    }
112
113    /// Redact a JSON value copy using this redactor's policy and secret names.
114    ///
115    /// Clones `value` first; for a large payload you already own and can
116    /// mutate, prefer [`Redactor::redact_in_place`] to avoid the copy.
117    pub fn value(&self, value: &Value) -> Value {
118        let mut v = value.clone();
119        self.redact_in_place(&mut v);
120        v
121    }
122
123    /// Redact secret components of a URL string using this redactor's settings.
124    ///
125    /// A query parameter is redacted iff its (form-decoded) name ends in
126    /// `_secret`/`_SECRET` or matches an exact entry in `secret_names`. A
127    /// fragment written in the same `k=v&k=v` shape — how an OAuth implicit-flow
128    /// response carries its token — is redacted by that same rule; any other
129    /// fragment passes through byte-for-byte. The userinfo password
130    /// (`scheme://user:pass@host`) is always redacted as a structural rule.
131    /// Only the secret spans are replaced with `***`; every other byte is
132    /// preserved. A string that is not a single, whitespace-free,
133    /// scheme-prefixed URL (including a URL embedded in surrounding prose) is
134    /// returned unchanged.
135    ///
136    /// A URL is unscoped input: `RedactionPolicy::Off` returns it unchanged,
137    /// every other policy redacts it in full.
138    pub fn url(&self, url: &str) -> String {
139        if !self.policy.redacts_unscoped_input() {
140            return url.to_string();
141        }
142        let context = RedactionContext::from_redactor(self);
143        redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
144    }
145
146    /// Redact complete scheme-prefixed URLs embedded in prose.
147    ///
148    /// This is deliberately explicit and independent from structured field
149    /// redaction. It recognizes URL spans only; it never scans surrounding text
150    /// for secret-looking names or values.
151    pub fn urls_in_text(&self, text: &str) -> String {
152        if !self.policy.redacts_unscoped_input() {
153            return text.to_string();
154        }
155        let context = RedactionContext::from_redactor(self);
156        redact_urls_in_text_with_context(text, &context)
157    }
158
159    /// Redact `value` in place, using this redactor's policy and secret names.
160    ///
161    /// The zero-copy counterpart of [`Redactor::value`] — use it on a large
162    /// payload you already own to avoid cloning.
163    pub fn redact_in_place(&self, value: &mut Value) {
164        let context = RedactionContext::from_redactor(self);
165        apply_redaction_policy_with_context(value, self.policy, &context);
166    }
167
168    /// Redact secret *values* out of a command line, using this redactor's
169    /// policy and secret names.
170    ///
171    /// A long flag whose name is secret by AFDATA naming (`--api-key-secret`,
172    /// or an exact `secret_names` entry) has its value replaced by `***`, in
173    /// both `--flag=value` and `--flag value` spellings. Everything else is
174    /// preserved byte-for-byte.
175    ///
176    /// Free text is deliberately never scanned: a bare `api_key_secret=sk-live`
177    /// positional, or a secret-looking token after a non-secret flag, is left
178    /// alone. AFDATA decides sensitivity from the *field name*, and argv is no
179    /// exception — rename the flag rather than pattern-matching values. A flag
180    /// with no value (end of argv, or followed by another flag) is likewise
181    /// left inspectable.
182    ///
183    /// Only long (`--`) flags are recognized, matching the convention's
184    /// long-flags-only rule.
185    ///
186    /// A command line is unscoped input: `RedactionPolicy::Off` returns `args`
187    /// unchanged, every other policy redacts in full.
188    pub fn argv<S: AsRef<str>>(&self, args: &[S]) -> Vec<String> {
189        if !self.policy.redacts_unscoped_input() {
190            return args.iter().map(|arg| arg.as_ref().to_string()).collect();
191        }
192        let context = RedactionContext::from_redactor(self);
193        let mut out = Vec::with_capacity(args.len());
194        let mut redact_next = false;
195        for arg in args {
196            let arg = arg.as_ref();
197            if redact_next {
198                redact_next = false;
199                if !arg.starts_with('-') {
200                    out.push(REDACTED_MARKER.to_string());
201                    continue;
202                }
203            }
204            if let Some(rest) = arg.strip_prefix("--") {
205                if let Some((name, _)) = rest.split_once('=') {
206                    if is_secret_flag_name(name, &context) {
207                        out.push(format!("--{name}={REDACTED_MARKER}"));
208                        continue;
209                    }
210                } else if is_secret_flag_name(rest, &context) {
211                    redact_next = true;
212                }
213            }
214            out.push(arg.to_string());
215        }
216        out
217    }
218
219    /// True when `name` would be treated as a secret field name by this
220    /// redactor: an exact `_secret`/`_SECRET` suffix, or an exact match
221    /// against a configured `secret_names` entry.
222    ///
223    /// Exposed for callers that must gate on a single *targeted* field name
224    /// (for example a CLI dot-path leaf) rather than redact a whole value —
225    /// [`Redactor::value`] only rewrites fields it finds while walking an
226    /// object, so a bare scalar pulled out from under its field name needs
227    /// this explicit check instead.
228    pub fn is_secret_name(&self, name: &str) -> bool {
229        RedactionContext::from_redactor(self).is_secret_key(name)
230    }
231
232    /// True when `name` receives URL-aware structured-field redaction: an
233    /// exact `_url`/`_URL` suffix or an exact configured `url_names` entry.
234    ///
235    /// The counterpart to [`Redactor::is_secret_name`], for the same targeted
236    /// single-field case. Kept even with no in-tree caller: a consumer that
237    /// gates on one name has to ask both questions, and an API that answers
238    /// only half of a symmetric pair sends the caller back to reimplementing
239    /// the suffix rule — which is exactly how the rule drifts.
240    pub fn is_url_name(&self, name: &str) -> bool {
241        RedactionContext::from_redactor(self).is_url_key(name)
242    }
243}
244
245impl From<RedactionPolicy> for Redactor {
246    fn from(policy: RedactionPolicy) -> Self {
247        Self {
248            policy,
249            secret_names: Vec::new(),
250            url_names: Vec::new(),
251            reveal_names: Vec::new(),
252        }
253    }
254}
255
256/// Output options combining redaction and rendering style.
257#[derive(Clone, Debug, Default, PartialEq, Eq)]
258pub struct OutputOptions {
259    /// Redactor applied before rendering.
260    pub redaction: Redactor,
261    /// Rendering style for plain output only.
262    pub style: PlainStyle,
263}
264
265impl From<RedactionPolicy> for OutputOptions {
266    fn from(policy: RedactionPolicy) -> Self {
267        Self {
268            redaction: Redactor::from(policy),
269            style: PlainStyle::default(),
270        }
271    }
272}
273
274// ═══════════════════════════════════════════
275// Public API: Redaction & Utility
276// ═══════════════════════════════════════════
277
278/// Return a JSON value copy with default `_secret` redaction applied.
279pub fn redacted_value(value: &Value) -> Value {
280    Redactor::new().value(value)
281}
282
283/// Redact secret values out of a command line, using default options.
284///
285/// Returns `args` with the value of every `_secret`-suffixed long flag replaced
286/// by `***`, covering both `--flag=value` and `--flag value`. Use
287/// [`Redactor::argv`] for custom `secret_names` or a non-default policy.
288///
289/// Intended for CLIs that record their own invocation — startup diagnostics,
290/// audit trails, crash reports — where writing argv verbatim would put a
291/// credential in the log.
292pub fn redact_argv<S: AsRef<str>>(args: &[S]) -> Vec<String> {
293    Redactor::new().argv(args)
294}
295
296/// Redact secret components of a single URL string, using default options.
297///
298/// Returns `url` with its userinfo password and any `_secret`-suffixed query
299/// parameter values replaced by `***`.
300pub fn redact_url_secrets(url: &str) -> String {
301    Redactor::new().url(url)
302}
303
304/// Redact complete scheme-prefixed URLs embedded in prose, using default
305/// secret-name rules.
306///
307/// This helper is opt-in. Ordinary structured redaction never scans prose.
308pub fn redact_urls_in_text(text: &str) -> String {
309    Redactor::new().urls_in_text(text)
310}
311
312// ═══════════════════════════════════════════
313// Secret Redaction
314// ═══════════════════════════════════════════
315
316/// The scalar every redacted span, value, and subtree is replaced with. Also
317/// the signal plain rendering reads to tell a hidden field from a live one.
318pub(crate) const REDACTED_MARKER: &str = "***";
319
320#[derive(Default)]
321pub(crate) struct RedactionContext {
322    secret_names: HashSet<String>,
323    url_names: HashSet<String>,
324    reveal_names: HashSet<String>,
325}
326
327impl RedactionContext {
328    fn from_redactor(redactor: &Redactor) -> Self {
329        let secret_names = redactor.secret_names.iter().cloned().collect();
330        let url_names = redactor.url_names.iter().cloned().collect();
331        Self {
332            secret_names,
333            url_names,
334            reveal_names: redactor.reveal_names.iter().cloned().collect(),
335        }
336    }
337
338    fn is_secret_key(&self, key: &str) -> bool {
339        key_has_secret_suffix(key) || self.secret_names.contains(key)
340    }
341
342    fn is_url_key(&self, key: &str) -> bool {
343        key_has_url_suffix(key) || self.url_names.contains(key)
344    }
345}
346
347fn key_has_secret_suffix(key: &str) -> bool {
348    key.ends_with("_secret") || key.ends_with("_SECRET")
349}
350
351fn key_has_url_suffix(key: &str) -> bool {
352    key.ends_with("_url") || key.ends_with("_URL")
353}
354
355/// Whether a long flag's name is secret, normalizing the kebab-case flag
356/// spelling to the snake_case field spelling the convention is defined in.
357///
358/// Ungated: core argv redaction relies on it.
359pub(crate) fn is_secret_flag_name(flag_name: &str, context: &RedactionContext) -> bool {
360    let normalized = flag_name.replace('-', "_");
361    context.is_secret_key(&normalized) || context.is_secret_key(flag_name)
362}
363
364const MAX_DEPTH: usize = 256;
365const MAX_DEPTH_MARKER: &str = "<afdata:max-depth>";
366
367fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
368    redact_secrets_with_context_depth(value, context, 0);
369}
370
371fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
372    if depth >= MAX_DEPTH {
373        *value = Value::String(MAX_DEPTH_MARKER.into());
374        return;
375    }
376    match value {
377        Value::Object(map) => {
378            let keys: Vec<String> = map.keys().cloned().collect();
379            for key in keys {
380                if context.is_secret_key(&key) && !context.reveal_names.contains(&key) {
381                    // A null secret is an *absent* secret. Masking it would
382                    // manufacture the appearance of a configured credential:
383                    // readers cannot tell `"***"`-because-set from
384                    // `"***"`-because-null, so a tool showing its own config
385                    // would report every unset secret as configured. Redaction
386                    // hides a value that exists; it does not invent one.
387                    if !map.get(&key).is_some_and(Value::is_null) {
388                        map.insert(key, Value::String(REDACTED_MARKER.into()));
389                    }
390                } else if context.is_url_key(&key) {
391                    if let Some(v) = map.get_mut(&key) {
392                        redact_url_value_depth(v, context, depth + 1);
393                    }
394                } else if let Some(v) = map.get_mut(&key) {
395                    redact_secrets_with_context_depth(v, context, depth + 1);
396                }
397            }
398        }
399        Value::Array(arr) => {
400            for v in arr {
401                redact_secrets_with_context_depth(v, context, depth + 1);
402            }
403        }
404        _ => {}
405    }
406}
407
408fn redact_url_value_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
409    if depth >= MAX_DEPTH {
410        *value = Value::String(MAX_DEPTH_MARKER.into());
411        return;
412    }
413    match value {
414        Value::String(text) => {
415            *text = redact_url_field_value(text, context);
416        }
417        Value::Array(values) => {
418            for value in values {
419                redact_url_value_depth(value, context, depth + 1);
420            }
421        }
422        Value::Object(values) => {
423            let keys = values.keys().cloned().collect::<Vec<_>>();
424            for key in keys {
425                if context.is_secret_key(&key) && !context.reveal_names.contains(&key) {
426                    if !values.get(&key).is_some_and(Value::is_null) {
427                        values.insert(key, Value::String(REDACTED_MARKER.into()));
428                    }
429                } else if let Some(value) = values.get_mut(&key) {
430                    redact_url_value_depth(value, context, depth + 1);
431                }
432            }
433        }
434        _ => {}
435    }
436}
437
438/// Redact secret components of a single URL string, returning `Some(redacted)`
439/// when `s` is a processable URL, or `None` when it is not (so callers can keep
440/// the original). Only secret spans change; all other bytes are preserved.
441fn apply_redaction_policy_with_context(
442    value: &mut Value,
443    redaction_policy: RedactionPolicy,
444    context: &RedactionContext,
445) {
446    match redaction_policy {
447        RedactionPolicy::All => redact_secrets_with_context(value, context),
448        RedactionPolicy::TraceOnly => {
449            if let Value::Object(map) = value
450                && let Some(trace) = map.get_mut("trace")
451            {
452                redact_secrets_with_context(trace, context);
453            }
454        }
455        RedactionPolicy::Off => {}
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use serde_json::json;
463
464    // ── URL fragments carry secrets too ──────────
465
466    #[test]
467    fn url_fragment_params_redacted_like_query_params() {
468        assert_eq!(
469            redact_url_secrets("https://h/p?token_secret=QUERYLEAK#token_secret=FRAGLEAK"),
470            "https://h/p?token_secret=***#token_secret=***"
471        );
472    }
473
474    #[test]
475    fn url_fragment_params_redacted_without_a_query() {
476        // The OAuth implicit-flow shape: the credential exists only after '#'.
477        assert_eq!(
478            redact_url_secrets("https://h/cb#access_token_secret=abc&state=xyz"),
479            "https://h/cb#access_token_secret=***&state=xyz"
480        );
481    }
482
483    #[test]
484    fn url_fragment_without_params_is_preserved() {
485        for url in [
486            "https://h/p?a=1#section",
487            "https://h/p#",
488            "https://h/p#a/b?c",
489        ] {
490            assert_eq!(redact_url_secrets(url), url);
491        }
492    }
493
494    #[test]
495    fn url_fragment_honors_secret_names() {
496        let redactor = Redactor::new().secret_names(vec!["token".to_string()]);
497        assert_eq!(
498            redactor.url("https://h/cb#token=abc&page=2"),
499            "https://h/cb#token=***&page=2"
500        );
501    }
502
503    #[test]
504    fn url_names_are_exact_and_recurse_through_collections() {
505        let redactor = Redactor::new().secret_names(["token"]).url_names([
506            "url",
507            "relays",
508            "synapse_selector",
509        ]);
510        let input = json!({
511            "url": "https://u:pw@h/?token=one",
512            "URL": "https://u:visible@h/?token=two",
513            "relays": [
514                "wss://relay.example/?token=three",
515                {"nested": "https://u:pw@nested.example/"}
516            ],
517            "synapse_selector": "user:pass@host/db",
518            "other": "https://u:visible@h/?token=four"
519        });
520
521        assert_eq!(
522            redactor.value(&input),
523            json!({
524                "url": "https://u:***@h/?token=***",
525                "URL": "https://u:visible@h/?token=two",
526                "relays": [
527                    "wss://relay.example/?token=***",
528                    {"nested": "https://u:***@nested.example/"}
529                ],
530                "synapse_selector": "***",
531                "other": "https://u:visible@h/?token=four"
532            })
533        );
534        assert!(redactor.is_url_name("url"));
535        assert!(!redactor.is_url_name("URL"));
536    }
537
538    #[test]
539    fn prose_url_redaction_is_explicit_and_preserves_punctuation() {
540        let message = "connect (https://u:pw@h/?token_secret=one), then wss://h/?token_secret=two. bare user:pw@h stays";
541        assert_eq!(
542            redact_urls_in_text(message),
543            "connect (https://u:***@h/?token_secret=***), then wss://h/?token_secret=***. bare user:pw@h stays"
544        );
545        assert_eq!(
546            redacted_value(&json!({"message": message})),
547            json!({"message": message})
548        );
549    }
550
551    #[test]
552    fn prose_url_redaction_honors_policy_and_secret_names() {
553        let text = "see https://h/?token=abc";
554        assert_eq!(
555            Redactor::new().secret_names(["token"]).urls_in_text(text),
556            "see https://h/?token=***"
557        );
558        assert_eq!(
559            Redactor::new()
560                .policy(RedactionPolicy::Off)
561                .secret_names(["token"])
562                .urls_in_text(text),
563            text
564        );
565    }
566
567    // ── One policy meaning across value, argv, url ──────────
568
569    fn scoped_value() -> Value {
570        json!({
571            "result": {"api_key_secret": "sk-result"},
572            "trace": {"api_key_secret": "sk-trace"}
573        })
574    }
575
576    fn argv() -> Vec<String> {
577        vec!["tool".to_string(), "--api-key-secret=sk-live".to_string()]
578    }
579
580    #[test]
581    fn all_policy_redacts_every_path() {
582        let redactor = Redactor::new().policy(RedactionPolicy::All);
583        assert_eq!(
584            redactor.value(&scoped_value()),
585            json!({
586                "result": {"api_key_secret": "***"},
587                "trace": {"api_key_secret": "***"}
588            })
589        );
590        assert_eq!(redactor.argv(&argv()), vec!["tool", "--api-key-secret=***"]);
591        assert_eq!(
592            redactor.url("https://u:pw@h/cb?token_secret=abc"),
593            "https://u:***@h/cb?token_secret=***"
594        );
595    }
596
597    #[test]
598    fn trace_only_scopes_a_value_but_redacts_argv_and_url_in_full() {
599        let redactor = Redactor::new().policy(RedactionPolicy::TraceOnly);
600        // Scoped input: only the `trace` half is scrubbed.
601        assert_eq!(
602            redactor.value(&scoped_value()),
603            json!({
604                "result": {"api_key_secret": "sk-result"},
605                "trace": {"api_key_secret": "***"}
606            })
607        );
608        // Unscoped input: a command line and a bare URL have no non-`trace`
609        // half to leave alone, so they are redacted like `All`.
610        assert_eq!(redactor.argv(&argv()), vec!["tool", "--api-key-secret=***"]);
611        assert_eq!(
612            redactor.url("https://u:pw@h/cb?token_secret=abc"),
613            "https://u:***@h/cb?token_secret=***"
614        );
615    }
616
617    #[test]
618    fn off_policy_disables_every_path() {
619        let redactor = Redactor::new().policy(RedactionPolicy::Off);
620        assert_eq!(redactor.value(&scoped_value()), scoped_value());
621        assert_eq!(redactor.argv(&argv()), argv());
622        assert_eq!(
623            redactor.url("https://u:pw@h/cb?token_secret=abc"),
624            "https://u:pw@h/cb?token_secret=abc"
625        );
626    }
627}