use serde_json::Value;
use std::collections::HashSet;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RedactionPolicy {
#[default]
All,
TraceOnly,
Off,
}
impl RedactionPolicy {
fn redacts_unscoped_input(self) -> bool {
!matches!(self, RedactionPolicy::Off)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PlainStyle {
#[default]
Readable,
Raw,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Redactor {
policy: RedactionPolicy,
secret_names: Vec<String>,
}
impl Redactor {
pub fn new() -> Self {
Self::default()
}
pub fn secret_names<I: IntoIterator<Item = S>, S: Into<String>>(mut self, names: I) -> Self {
self.secret_names = names.into_iter().map(|s| s.into()).collect();
self
}
pub fn policy(mut self, policy: RedactionPolicy) -> Self {
self.policy = policy;
self
}
pub fn value(&self, value: &Value) -> Value {
let mut v = value.clone();
self.redact_in_place(&mut v);
v
}
pub fn url(&self, url: &str) -> String {
if !self.policy.redacts_unscoped_input() {
return url.to_string();
}
let context = RedactionContext::from_redactor(self);
redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
}
pub fn redact_in_place(&self, value: &mut Value) {
let context = RedactionContext::from_redactor(self);
apply_redaction_policy_with_context(value, self.policy, &context);
}
pub fn argv<S: AsRef<str>>(&self, args: &[S]) -> Vec<String> {
if !self.policy.redacts_unscoped_input() {
return args.iter().map(|arg| arg.as_ref().to_string()).collect();
}
let context = RedactionContext::from_redactor(self);
let mut out = Vec::with_capacity(args.len());
let mut redact_next = false;
for arg in args {
let arg = arg.as_ref();
if redact_next {
redact_next = false;
if !arg.starts_with('-') {
out.push(REDACTED_MARKER.to_string());
continue;
}
}
if let Some(rest) = arg.strip_prefix("--") {
if let Some((name, _)) = rest.split_once('=') {
if is_secret_flag_name(name, &context) {
out.push(format!("--{name}={REDACTED_MARKER}"));
continue;
}
} else if is_secret_flag_name(rest, &context) {
redact_next = true;
}
}
out.push(arg.to_string());
}
out
}
pub fn is_secret_name(&self, name: &str) -> bool {
RedactionContext::from_redactor(self).is_secret_key(name)
}
}
impl From<RedactionPolicy> for Redactor {
fn from(policy: RedactionPolicy) -> Self {
Self {
policy,
secret_names: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OutputOptions {
pub redaction: Redactor,
pub style: PlainStyle,
}
impl From<RedactionPolicy> for OutputOptions {
fn from(policy: RedactionPolicy) -> Self {
Self {
redaction: Redactor::from(policy),
style: PlainStyle::default(),
}
}
}
pub fn redacted_value(value: &Value) -> Value {
Redactor::new().value(value)
}
pub fn redact_argv<S: AsRef<str>>(args: &[S]) -> Vec<String> {
Redactor::new().argv(args)
}
pub fn redact_url_secrets(url: &str) -> String {
Redactor::new().url(url)
}
pub(crate) const REDACTED_MARKER: &str = "***";
#[derive(Default)]
pub(crate) struct RedactionContext {
secret_names: HashSet<String>,
}
impl RedactionContext {
fn from_redactor(redactor: &Redactor) -> Self {
let secret_names = redactor.secret_names.iter().cloned().collect();
Self { secret_names }
}
fn is_secret_key(&self, key: &str) -> bool {
key_has_secret_suffix(key) || self.secret_names.contains(key)
}
}
fn key_has_secret_suffix(key: &str) -> bool {
key.ends_with("_secret") || key.ends_with("_SECRET")
}
fn key_has_url_suffix(key: &str) -> bool {
key.ends_with("_url") || key.ends_with("_URL")
}
pub(crate) fn is_secret_flag_name(flag_name: &str, context: &RedactionContext) -> bool {
let normalized = flag_name.replace('-', "_");
context.is_secret_key(&normalized) || context.is_secret_key(flag_name)
}
const MAX_DEPTH: usize = 256;
const MAX_DEPTH_MARKER: &str = "<afdata:max-depth>";
fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
redact_secrets_with_context_depth(value, context, 0);
}
fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
if depth >= MAX_DEPTH {
*value = Value::String(MAX_DEPTH_MARKER.into());
return;
}
match value {
Value::Object(map) => {
let keys: Vec<String> = map.keys().cloned().collect();
for key in keys {
if context.is_secret_key(&key) {
map.insert(key, Value::String(REDACTED_MARKER.into()));
} else if key_has_url_suffix(&key) {
if let Some(Value::String(s)) = map.get_mut(&key) {
*s = redact_url_field_value(s, context);
} else if let Some(v) = map.get_mut(&key) {
redact_secrets_with_context_depth(v, context, depth + 1);
}
} else if let Some(v) = map.get_mut(&key) {
redact_secrets_with_context_depth(v, context, depth + 1);
}
}
}
Value::Array(arr) => {
for v in arr {
redact_secrets_with_context_depth(v, context, depth + 1);
}
}
_ => {}
}
}
fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
if !s.contains("://") || !is_single_url(s) {
return None;
}
let scheme_sep = s.find("://")?;
let scheme = &s[..scheme_sep];
let rest = &s[scheme_sep + 3..];
let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..auth_end];
let remainder = &rest[auth_end..];
let new_authority = redact_userinfo_password(authority);
let (before_fragment, fragment) = match remainder.split_once('#') {
Some((before, fragment)) => (before, Some(fragment)),
None => (remainder, None),
};
let new_before_fragment = match before_fragment.split_once('?') {
Some((path, query)) => format!("{path}?{}", redact_query(query, context)),
None => before_fragment.to_string(),
};
let new_fragment = match fragment {
Some(fragment) => format!("#{}", redact_query(fragment, context)),
None => String::new(),
};
Some(format!(
"{scheme}://{new_authority}{new_before_fragment}{new_fragment}"
))
}
fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
if let Some(redacted) = redact_url_in_str(s, context) {
return redacted;
}
let trimmed = s.trim();
if trimmed != s
&& let Some(redacted) = redact_url_in_str(trimmed, context)
{
return redacted;
}
if s.chars().any(char::is_whitespace) || s.contains('@') {
return REDACTED_MARKER.to_string();
}
s.to_string()
}
fn redact_userinfo_password(authority: &str) -> String {
let Some(at) = authority.rfind('@') else {
return authority.to_string();
};
let userinfo = &authority[..at];
match userinfo.find(':') {
Some(colon) => format!(
"{}:{REDACTED_MARKER}{}",
&authority[..colon],
&authority[at..]
),
None => authority.to_string(),
}
}
fn redact_query(query: &str, context: &RedactionContext) -> String {
query
.split('&')
.map(|segment| {
let Some(eq) = segment.find('=') else {
return segment.to_string();
};
let raw_key = &segment[..eq];
let name = url::form_urlencoded::parse(segment.as_bytes())
.next()
.map(|(k, _)| k.into_owned())
.unwrap_or_default();
if context.is_secret_key(&name) {
format!("{raw_key}={REDACTED_MARKER}")
} else {
segment.to_string()
}
})
.collect::<Vec<_>>()
.join("&")
}
fn is_single_url(s: &str) -> bool {
if s.bytes().any(|b| b.is_ascii_whitespace()) {
return false;
}
let bytes = s.as_bytes();
if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
return false;
}
let mut i = 1;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
i += 1;
} else {
break;
}
}
s[i..].starts_with("://")
}
fn apply_redaction_policy_with_context(
value: &mut Value,
redaction_policy: RedactionPolicy,
context: &RedactionContext,
) {
match redaction_policy {
RedactionPolicy::All => redact_secrets_with_context(value, context),
RedactionPolicy::TraceOnly => {
if let Value::Object(map) = value
&& let Some(trace) = map.get_mut("trace")
{
redact_secrets_with_context(trace, context);
}
}
RedactionPolicy::Off => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn url_fragment_params_redacted_like_query_params() {
assert_eq!(
redact_url_secrets("https://h/p?token_secret=QUERYLEAK#token_secret=FRAGLEAK"),
"https://h/p?token_secret=***#token_secret=***"
);
}
#[test]
fn url_fragment_params_redacted_without_a_query() {
assert_eq!(
redact_url_secrets("https://h/cb#access_token_secret=abc&state=xyz"),
"https://h/cb#access_token_secret=***&state=xyz"
);
}
#[test]
fn url_fragment_without_params_is_preserved() {
for url in [
"https://h/p?a=1#section",
"https://h/p#",
"https://h/p#a/b?c",
] {
assert_eq!(redact_url_secrets(url), url);
}
}
#[test]
fn url_fragment_honors_secret_names() {
let redactor = Redactor::new().secret_names(vec!["token".to_string()]);
assert_eq!(
redactor.url("https://h/cb#token=abc&page=2"),
"https://h/cb#token=***&page=2"
);
}
fn scoped_value() -> Value {
json!({
"result": {"api_key_secret": "sk-result"},
"trace": {"api_key_secret": "sk-trace"}
})
}
fn argv() -> Vec<String> {
vec!["tool".to_string(), "--api-key-secret=sk-live".to_string()]
}
#[test]
fn all_policy_redacts_every_path() {
let redactor = Redactor::new().policy(RedactionPolicy::All);
assert_eq!(
redactor.value(&scoped_value()),
json!({
"result": {"api_key_secret": "***"},
"trace": {"api_key_secret": "***"}
})
);
assert_eq!(redactor.argv(&argv()), vec!["tool", "--api-key-secret=***"]);
assert_eq!(
redactor.url("https://u:pw@h/cb?token_secret=abc"),
"https://u:***@h/cb?token_secret=***"
);
}
#[test]
fn trace_only_scopes_a_value_but_redacts_argv_and_url_in_full() {
let redactor = Redactor::new().policy(RedactionPolicy::TraceOnly);
assert_eq!(
redactor.value(&scoped_value()),
json!({
"result": {"api_key_secret": "sk-result"},
"trace": {"api_key_secret": "***"}
})
);
assert_eq!(redactor.argv(&argv()), vec!["tool", "--api-key-secret=***"]);
assert_eq!(
redactor.url("https://u:pw@h/cb?token_secret=abc"),
"https://u:***@h/cb?token_secret=***"
);
}
#[test]
fn off_policy_disables_every_path() {
let redactor = Redactor::new().policy(RedactionPolicy::Off);
assert_eq!(redactor.value(&scoped_value()), scoped_value());
assert_eq!(redactor.argv(&argv()), argv());
assert_eq!(
redactor.url("https://u:pw@h/cb?token_secret=abc"),
"https://u:pw@h/cb?token_secret=abc"
);
}
}