agent_first_data/redaction.rs
1use serde_json::Value;
2use std::collections::HashSet;
3
4// ═══════════════════════════════════════════
5// Public API: Output Formatters
6// ═══════════════════════════════════════════
7
8/// Which fields a [`Redactor`] scrubs. The default is [`RedactionPolicy::All`].
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum RedactionPolicy {
11 /// Redact every secret field anywhere in the value (the default).
12 #[default]
13 All,
14 /// Redact only inside the top-level `trace` object.
15 TraceOnly,
16 /// Do not redact anything.
17 Off,
18}
19
20/// Rendering style for plain (logfmt) output only. JSON and YAML are always
21/// structure-preserving and ignore this.
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub enum PlainStyle {
24 /// Human-readable AFDATA rendering: strip suffixes and format values.
25 #[default]
26 Readable,
27 /// Schema-preserving rendering: keep keys and values unchanged after redaction.
28 Raw,
29}
30
31/// Configurable redaction builder for secrets and legacy field names.
32///
33/// `Redactor` encapsulates redaction policy and custom secret field names.
34/// Build with [`Redactor::new()`], configure via builder methods, then pass to
35/// redaction functions like [`redacted_value`] or [`redact_url_secrets`].
36#[derive(Clone, Debug, Default, PartialEq, Eq)]
37pub struct Redactor {
38 policy: RedactionPolicy,
39 secret_names: Vec<String>,
40}
41
42impl Redactor {
43 /// Create a new default redactor (full redaction, no custom secret names).
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 /// Set custom field names to treat as secrets in addition to `_secret` suffixes.
49 ///
50 /// Matching is exact field-name equality at any nesting level. The same
51 /// list also matches URL query-parameter names inside `_url` fields.
52 /// Builder style: returns `self`.
53 pub fn secret_names<I: IntoIterator<Item = S>, S: Into<String>>(mut self, names: I) -> Self {
54 self.secret_names = names.into_iter().map(|s| s.into()).collect();
55 self
56 }
57
58 /// Set the redaction policy (default: full redaction).
59 /// Builder style: returns `self`.
60 pub fn policy(mut self, policy: RedactionPolicy) -> Self {
61 self.policy = policy;
62 self
63 }
64
65 /// Redact a JSON value copy using this redactor's policy and secret names.
66 ///
67 /// Clones `value` first; for a large payload you already own and can
68 /// mutate, prefer [`Redactor::redact_in_place`] to avoid the copy.
69 pub fn value(&self, value: &Value) -> Value {
70 let mut v = value.clone();
71 self.redact_in_place(&mut v);
72 v
73 }
74
75 /// Redact secret components of a URL string using this redactor's settings.
76 ///
77 /// A query parameter is redacted iff its (form-decoded) name ends in
78 /// `_secret`/`_SECRET` or matches an exact entry in `secret_names`. The
79 /// userinfo password (`scheme://user:pass@host`) is always redacted as a
80 /// structural rule. Only the secret spans are replaced with `***`; every
81 /// other byte is preserved. A string that is not a single, whitespace-free,
82 /// scheme-prefixed URL (including a URL embedded in surrounding prose) is
83 /// returned unchanged.
84 pub fn url(&self, url: &str) -> String {
85 let context = RedactionContext::from_redactor(self);
86 redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
87 }
88
89 /// Redact `value` in place, using this redactor's policy and secret names.
90 ///
91 /// The zero-copy counterpart of [`Redactor::value`] — use it on a large
92 /// payload you already own to avoid cloning.
93 pub fn redact_in_place(&self, value: &mut Value) {
94 let context = RedactionContext::from_redactor(self);
95 apply_redaction_policy_with_context(value, self.policy, &context);
96 }
97
98 /// Redact secret *values* out of a command line, using this redactor's
99 /// policy and secret names.
100 ///
101 /// A long flag whose name is secret by AFDATA naming (`--api-key-secret`,
102 /// or an exact `secret_names` entry) has its value replaced by `***`, in
103 /// both `--flag=value` and `--flag value` spellings. Everything else is
104 /// preserved byte-for-byte.
105 ///
106 /// Free text is deliberately never scanned: a bare `api_key_secret=sk-live`
107 /// positional, or a secret-looking token after a non-secret flag, is left
108 /// alone. AFDATA decides sensitivity from the *field name*, and argv is no
109 /// exception — rename the flag rather than pattern-matching values. A flag
110 /// with no value (end of argv, or followed by another flag) is likewise
111 /// left inspectable.
112 ///
113 /// Only long (`--`) flags are recognized, matching the convention's
114 /// long-flags-only rule.
115 pub fn argv<S: AsRef<str>>(&self, args: &[S]) -> Vec<String> {
116 if matches!(self.policy, RedactionPolicy::Off) {
117 return args.iter().map(|arg| arg.as_ref().to_string()).collect();
118 }
119 let context = RedactionContext::from_redactor(self);
120 let mut out = Vec::with_capacity(args.len());
121 let mut redact_next = false;
122 for arg in args {
123 let arg = arg.as_ref();
124 if redact_next {
125 redact_next = false;
126 if !arg.starts_with('-') {
127 out.push("***".to_string());
128 continue;
129 }
130 }
131 if let Some(rest) = arg.strip_prefix("--") {
132 if let Some((name, _)) = rest.split_once('=') {
133 if is_secret_flag_name(name, &context) {
134 out.push(format!("--{name}=***"));
135 continue;
136 }
137 } else if is_secret_flag_name(rest, &context) {
138 redact_next = true;
139 }
140 }
141 out.push(arg.to_string());
142 }
143 out
144 }
145
146 /// True when `name` would be treated as a secret field name by this
147 /// redactor: an exact `_secret`/`_SECRET` suffix, or an exact match
148 /// against a configured `secret_names` entry.
149 ///
150 /// Exposed for callers that must gate on a single *targeted* field name
151 /// (for example a CLI dot-path leaf) rather than redact a whole value —
152 /// [`Redactor::value`] only rewrites fields it finds while walking an
153 /// object, so a bare scalar pulled out from under its field name needs
154 /// this explicit check instead.
155 pub fn is_secret_name(&self, name: &str) -> bool {
156 RedactionContext::from_redactor(self).is_secret_key(name)
157 }
158}
159
160impl From<RedactionPolicy> for Redactor {
161 fn from(policy: RedactionPolicy) -> Self {
162 Self {
163 policy,
164 secret_names: Vec::new(),
165 }
166 }
167}
168
169/// Output options combining redaction and rendering style.
170#[derive(Clone, Debug, Default, PartialEq, Eq)]
171pub struct OutputOptions {
172 /// Redactor applied before rendering.
173 pub redaction: Redactor,
174 /// Rendering style for plain output only.
175 pub style: PlainStyle,
176}
177
178impl From<RedactionPolicy> for OutputOptions {
179 fn from(policy: RedactionPolicy) -> Self {
180 Self {
181 redaction: Redactor::from(policy),
182 style: PlainStyle::default(),
183 }
184 }
185}
186
187// ═══════════════════════════════════════════
188// Public API: Redaction & Utility
189// ═══════════════════════════════════════════
190
191/// Return a JSON value copy with default `_secret` redaction applied.
192pub fn redacted_value(value: &Value) -> Value {
193 Redactor::new().value(value)
194}
195
196/// Redact secret values out of a command line, using default options.
197///
198/// Returns `args` with the value of every `_secret`-suffixed long flag replaced
199/// by `***`, covering both `--flag=value` and `--flag value`. Use
200/// [`Redactor::argv`] for custom `secret_names` or a non-default policy.
201///
202/// Intended for CLIs that record their own invocation — startup diagnostics,
203/// audit trails, crash reports — where writing argv verbatim would put a
204/// credential in the log.
205pub fn redact_argv<S: AsRef<str>>(args: &[S]) -> Vec<String> {
206 Redactor::new().argv(args)
207}
208
209/// Redact secret components of a single URL string, using default options.
210///
211/// Returns `url` with its userinfo password and any `_secret`-suffixed query
212/// parameter values replaced by `***`.
213pub fn redact_url_secrets(url: &str) -> String {
214 Redactor::new().url(url)
215}
216
217// ═══════════════════════════════════════════
218// Secret Redaction
219// ═══════════════════════════════════════════
220
221#[derive(Default)]
222pub(crate) struct RedactionContext {
223 secret_names: HashSet<String>,
224}
225
226impl RedactionContext {
227 fn from_redactor(redactor: &Redactor) -> Self {
228 let secret_names = redactor.secret_names.iter().cloned().collect();
229 Self { secret_names }
230 }
231
232 fn is_secret_key(&self, key: &str) -> bool {
233 key_has_secret_suffix(key) || self.secret_names.contains(key)
234 }
235}
236
237fn key_has_secret_suffix(key: &str) -> bool {
238 key.ends_with("_secret") || key.ends_with("_SECRET")
239}
240
241fn key_has_url_suffix(key: &str) -> bool {
242 key.ends_with("_url") || key.ends_with("_URL")
243}
244
245/// Whether a long flag's name is secret, normalizing the kebab-case flag
246/// spelling to the snake_case field spelling the convention is defined in.
247///
248/// Ungated: core argv redaction relies on it, not just the `cli-help` renderer.
249pub(crate) fn is_secret_flag_name(flag_name: &str, context: &RedactionContext) -> bool {
250 let normalized = flag_name.replace('-', "_");
251 context.is_secret_key(&normalized) || context.is_secret_key(flag_name)
252}
253
254const MAX_DEPTH: usize = 256;
255const MAX_DEPTH_MARKER: &str = "<afdata:max-depth>";
256
257fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
258 redact_secrets_with_context_depth(value, context, 0);
259}
260
261fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
262 if depth >= MAX_DEPTH {
263 *value = Value::String(MAX_DEPTH_MARKER.into());
264 return;
265 }
266 match value {
267 Value::Object(map) => {
268 let keys: Vec<String> = map.keys().cloned().collect();
269 for key in keys {
270 if context.is_secret_key(&key) {
271 map.insert(key, Value::String("***".into()));
272 } else if key_has_url_suffix(&key) {
273 if let Some(Value::String(s)) = map.get_mut(&key) {
274 *s = redact_url_field_value(s, context);
275 } else if let Some(v) = map.get_mut(&key) {
276 redact_secrets_with_context_depth(v, context, depth + 1);
277 }
278 } else if let Some(v) = map.get_mut(&key) {
279 redact_secrets_with_context_depth(v, context, depth + 1);
280 }
281 }
282 }
283 Value::Array(arr) => {
284 for v in arr {
285 redact_secrets_with_context_depth(v, context, depth + 1);
286 }
287 }
288 _ => {}
289 }
290}
291
292/// Redact secret components of a single URL string, returning `Some(redacted)`
293/// when `s` is a processable URL, or `None` when it is not (so callers can keep
294/// the original). Only secret spans change; all other bytes are preserved.
295fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
296 // Precondition (spec): a single, whitespace-free, scheme-prefixed URL.
297 // The gate is scheme + no-whitespace only — NOT "parses as a URL library
298 // object". Span location below is purely byte-wise, so we never re-serialize
299 // the URL; adding a `url::Url::parse` gate here would diverge across
300 // languages (e.g. ports > 65535 or empty hosts that one library rejects and
301 // another accepts) and silently leak secrets in the values it rejects.
302 if !s.contains("://") || !is_single_url(s) {
303 return None;
304 }
305 let scheme_sep = s.find("://")?;
306 let scheme = &s[..scheme_sep];
307 let rest = &s[scheme_sep + 3..];
308
309 // Authority runs from after "://" to the first '/', '?', or '#'.
310 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
311 let authority = &rest[..auth_end];
312 let remainder = &rest[auth_end..];
313
314 let new_authority = redact_userinfo_password(authority);
315
316 // Query runs from the first '?' to the first '#' (or end).
317 let new_remainder = match remainder.find('?') {
318 Some(q) => {
319 let (path, q_onwards) = remainder.split_at(q);
320 let query_body = &q_onwards[1..];
321 let (query, fragment) = match query_body.find('#') {
322 Some(h) => (&query_body[..h], &query_body[h..]),
323 None => (query_body, ""),
324 };
325 format!("{path}?{}{fragment}", redact_query(query, context))
326 }
327 None => remainder.to_string(),
328 };
329
330 Some(format!("{scheme}://{new_authority}{new_remainder}"))
331}
332
333fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
334 if let Some(redacted) = redact_url_in_str(s, context) {
335 return redacted;
336 }
337 let trimmed = s.trim();
338 if trimmed != s
339 && let Some(redacted) = redact_url_in_str(trimmed, context)
340 {
341 return redacted;
342 }
343 // Fail closed: a `_url` value we could not parse as a clean scheme-prefixed
344 // URL, yet which carries a credential sigil (`@` userinfo) or internal
345 // whitespace, is redacted wholesale rather than passed through. A schemeless
346 // connection string like `user:pass@host/db` has no scheme anchor for the
347 // surgical span logic above, so blanket redaction is the safe default.
348 if s.chars().any(char::is_whitespace) || s.contains('@') {
349 return "***".to_string();
350 }
351 s.to_string()
352}
353
354/// Replace the userinfo password (`user:pass@`) with `***`, preserving the
355/// username. Authority without `@`, or userinfo without `:`, is unchanged.
356fn redact_userinfo_password(authority: &str) -> String {
357 let Some(at) = authority.rfind('@') else {
358 return authority.to_string();
359 };
360 let userinfo = &authority[..at];
361 match userinfo.find(':') {
362 Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
363 None => authority.to_string(),
364 }
365}
366
367/// Redact the values of secret-named query parameters, preserving raw bytes of
368/// every other segment (keys, benign values, encoding, ordering, separators).
369fn redact_query(query: &str, context: &RedactionContext) -> String {
370 query
371 .split('&')
372 .map(|segment| {
373 let Some(eq) = segment.find('=') else {
374 return segment.to_string();
375 };
376 let raw_key = &segment[..eq];
377 // Form-decode the name (`+` → space, percent-decode) for the check.
378 let name = url::form_urlencoded::parse(segment.as_bytes())
379 .next()
380 .map(|(k, _)| k.into_owned())
381 .unwrap_or_default();
382 if context.is_secret_key(&name) {
383 format!("{raw_key}=***")
384 } else {
385 segment.to_string()
386 }
387 })
388 .collect::<Vec<_>>()
389 .join("&")
390}
391
392/// True when `s` begins with a URL scheme (`ALPHA *(ALPHA / DIGIT / "+" / "-" /
393/// ".") "://"`) and contains no ASCII whitespace — i.e. a single bare URL, not
394/// a URL embedded in prose.
395fn is_single_url(s: &str) -> bool {
396 if s.bytes().any(|b| b.is_ascii_whitespace()) {
397 return false;
398 }
399 let bytes = s.as_bytes();
400 if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
401 return false;
402 }
403 let mut i = 1;
404 while i < bytes.len() {
405 let c = bytes[i];
406 if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
407 i += 1;
408 } else {
409 break;
410 }
411 }
412 s[i..].starts_with("://")
413}
414
415fn apply_redaction_policy_with_context(
416 value: &mut Value,
417 redaction_policy: RedactionPolicy,
418 context: &RedactionContext,
419) {
420 match redaction_policy {
421 RedactionPolicy::All => redact_secrets_with_context(value, context),
422 RedactionPolicy::TraceOnly => {
423 if let Value::Object(map) = value
424 && let Some(trace) = map.get_mut("trace")
425 {
426 redact_secrets_with_context(trace, context);
427 }
428 }
429 RedactionPolicy::Off => {}
430 }
431}