use std::collections::HashSet;
use serde_json::Value;
pub fn sanitize(value: Value) -> Value {
Redactor::snapshot().sanitize(value)
}
#[derive(Debug, Clone)]
pub struct Redactor {
enabled: bool,
secrets: HashSet<String>,
}
impl Redactor {
pub fn snapshot() -> Self {
if redaction_disabled() {
Self {
enabled: false,
secrets: HashSet::new(),
}
} else {
Self {
enabled: true,
secrets: known_env_secrets(),
}
}
}
pub fn sanitize(&self, value: Value) -> Value {
if !self.enabled {
return value;
}
sanitize_with(&self.secrets, value)
}
}
fn redaction_disabled() -> bool {
match std::env::var("DEVBOY_TRACE_REDACTION") {
Ok(v) => matches!(v.to_lowercase().as_str(), "off" | "0" | "false" | "no"),
Err(_) => false,
}
}
fn sanitize_with(secrets: &HashSet<String>, value: Value) -> Value {
match value {
Value::String(s) => Value::String(redact_string(secrets, &s)),
Value::Array(xs) => {
Value::Array(xs.into_iter().map(|x| sanitize_with(secrets, x)).collect())
}
Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
let new_val = if key_looks_secret(&k) {
Value::String("<redacted:secret-field>".to_string())
} else {
sanitize_with(secrets, v)
};
out.insert(k, new_val);
}
Value::Object(out)
}
other => other,
}
}
fn redact_string(secrets: &HashSet<String>, s: &str) -> String {
if !s.is_empty() && secrets.contains(s) {
return "<redacted:credential>".to_string();
}
if has_known_prefix(s) {
return "<redacted:token-pattern>".to_string();
}
if let Some(rewritten) = mask_auth_header_segment(s) {
return rewritten;
}
s.to_string()
}
fn has_known_prefix(s: &str) -> bool {
const CASE_SENSITIVE: &[&str] = &[
"ghp_",
"github_pat_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
"glpat-",
"pk_",
"sk_",
"sk-",
"xoxb-",
"xoxa-",
"xoxp-",
"xapp-",
];
if CASE_SENSITIVE
.iter()
.any(|p| s.starts_with(p) && s.len() > p.len() + 8)
{
return true;
}
const SCHEME_CI: &[&str] = &["bearer ", "basic "];
let lower = s.to_ascii_lowercase();
SCHEME_CI
.iter()
.any(|p| lower.starts_with(p) && s.len() > p.len() + 8)
}
fn mask_auth_header_segment(s: &str) -> Option<String> {
let lower = s.to_ascii_lowercase();
let needles = ["bearer ", "basic "];
for needle in needles {
if let Some(idx) = lower.find(needle) {
let head = &s[..idx];
let scheme = &s[idx..idx + needle.len()]; let rest = &s[idx + needle.len()..];
let end = rest
.find(|c: char| c.is_whitespace() || c == ',' || c == ';')
.unwrap_or(rest.len());
if end >= 8 {
let tail = &rest[end..];
return Some(format!("{head}{scheme}<redacted:auth>{tail}"));
}
}
}
None
}
fn key_looks_secret(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
const SUFFIXES: &[&str] = &[
"_TOKEN",
"_SECRET",
"_KEY",
"_PASSWORD",
"_PASSPHRASE",
"_AUTH",
];
const EXACT: &[&str] = &["AUTHORIZATION", "COOKIE", "TOKEN", "SECRET", "PASSWORD"];
if EXACT.contains(&upper.as_str()) {
return true;
}
if SUFFIXES.iter().any(|suf| upper.ends_with(suf)) {
return true;
}
if upper.contains("PASSWORD") || upper.contains("SECRET") || upper.contains("TOKEN") {
return true;
}
false
}
fn known_env_secrets() -> HashSet<String> {
let mut out = HashSet::new();
for (name, value) in std::env::vars() {
if value.is_empty() {
continue;
}
if key_looks_secret(&name) {
out.insert(value);
}
}
out
}
#[cfg(test)]
pub(crate) mod test_support {
use std::sync::Mutex;
pub(crate) static ENV_TEST_MUTEX: Mutex<()> = Mutex::new(());
pub(crate) fn with_clean_env<R>(f: impl FnOnce() -> R) -> R {
let _guard = ENV_TEST_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
temp_env::with_var("DEVBOY_TRACE_REDACTION", None::<&str>, f)
}
}
#[cfg(test)]
mod tests {
use super::test_support::{ENV_TEST_MUTEX, with_clean_env};
use super::*;
use serde_json::json;
#[test]
fn masks_github_pat() {
with_clean_env(|| {
let v = json!({ "args": { "token": "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } });
let out = sanitize(v);
let s = serde_json::to_string(&out).unwrap();
assert!(!s.contains("ghp_aaaaaaaa"));
assert!(s.contains("<redacted"));
});
}
#[test]
fn masks_bearer_scheme_in_header_string() {
with_clean_env(|| {
let v = json!("Authorization: Bearer xxxxxxxxxxxxyyyyyyyyyyyy");
let out = sanitize(v);
let s = out.as_str().unwrap();
assert!(!s.contains("xxxxxxxxxxxxyyyyyyyyyyyy"), "got: {s}");
assert!(s.contains("<redacted"), "got: {s}");
});
}
#[test]
fn masks_by_key_name_even_when_value_looks_harmless() {
with_clean_env(|| {
let v = json!({ "password": "not-a-prefix" });
let out = sanitize(v);
assert_eq!(
out.get("password").and_then(|v| v.as_str()),
Some("<redacted:secret-field>")
);
});
}
#[test]
fn env_var_values_are_redacted_when_they_match_exactly() {
let _guard = ENV_TEST_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
temp_env::with_vars(
[
("DEVBOY_TRACE_REDACTION", None::<&str>),
(
"DEVBOY_TEST_TOKEN",
Some("super-secret-value-nothing-matches"),
),
],
|| {
let v = json!({ "note": "leaked: super-secret-value-nothing-matches" });
let out = sanitize(v);
let note = out.get("note").and_then(|v| v.as_str()).unwrap();
assert_eq!(note, "leaked: super-secret-value-nothing-matches");
let v = json!({ "raw": "super-secret-value-nothing-matches" });
let out = sanitize(v);
assert_eq!(
out.get("raw").and_then(|v| v.as_str()),
Some("<redacted:credential>")
);
},
);
}
#[test]
fn short_strings_are_not_redacted_by_prefix_check() {
with_clean_env(|| {
let v = json!("ghp_");
assert_eq!(sanitize(v).as_str(), Some("ghp_"));
});
}
#[test]
fn redaction_can_be_disabled_via_env() {
let _guard = ENV_TEST_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
temp_env::with_var("DEVBOY_TRACE_REDACTION", Some("off"), || {
let v = json!({ "token": "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" });
let out = sanitize(v.clone());
assert_eq!(out, v);
});
}
#[test]
fn masks_bearer_scheme_case_insensitive() {
with_clean_env(|| {
for header in [
"Authorization: Bearer xxxxxxxxxxxxyyyyyyyyyyyy",
"authorization: bearer xxxxxxxxxxxxyyyyyyyyyyyy",
"AUTHORIZATION: BEARER xxxxxxxxxxxxyyyyyyyyyyyy",
"authorization: BeArEr xxxxxxxxxxxxyyyyyyyyyyyy",
] {
let out = sanitize(json!(header));
let s = out.as_str().unwrap();
assert!(
!s.contains("xxxxxxxxxxxxyyyyyyyyyyyy"),
"token leaked for header `{header}` → `{s}`"
);
assert!(
s.contains("<redacted"),
"no redaction marker for header `{header}` → `{s}`"
);
}
});
}
#[test]
fn masks_bare_bearer_value_case_insensitive() {
with_clean_env(|| {
for raw in [
"Bearer abcdefghijklmnopqrstuvwx",
"bearer abcdefghijklmnopqrstuvwx",
"BEARER abcdefghijklmnopqrstuvwx",
"Basic YWxpY2U6aHVudGVyMjpkcmFnb24=",
] {
let out = sanitize(json!(raw));
let s = out.as_str().unwrap();
assert!(s.contains("<redacted"), "not redacted: `{raw}` → `{s}`");
}
});
}
#[test]
fn masks_generic_pk_prefix() {
with_clean_env(|| {
let v = json!({ "clickup_pk": "pk_abcdefghijklmnop" });
let out = sanitize(v);
assert_eq!(
out.get("clickup_pk").and_then(|v| v.as_str()),
Some("<redacted:token-pattern>"),
"generic pk_ prefix should redact"
);
let doc = json!("pk_");
assert_eq!(sanitize(doc).as_str(), Some("pk_"));
});
}
#[test]
fn redactor_snapshot_amortizes_env_scan() {
let _guard = ENV_TEST_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
let redactor = temp_env::with_vars(
[
("DEVBOY_TRACE_REDACTION", None::<&str>),
("DEVBOY_REDACTOR_CACHE_TOKEN", Some("cached-token-zzzzzzzz")),
],
Redactor::snapshot,
);
let out = redactor.sanitize(json!({ "raw": "cached-token-zzzzzzzz" }));
assert_eq!(
out.get("raw").and_then(|v| v.as_str()),
Some("<redacted:credential>")
);
}
#[test]
fn redactor_snapshot_respects_disable_env() {
let _guard = ENV_TEST_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
temp_env::with_var("DEVBOY_TRACE_REDACTION", Some("off"), || {
let redactor = Redactor::snapshot();
let v = json!({ "token": "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" });
assert_eq!(redactor.sanitize(v.clone()), v);
});
}
}