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