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}