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}