use tracing::field::{Field, Visit};
#[cfg(feature = "tracing-layer")]
use tracing::{Event, Metadata, Subscriber};
#[cfg(feature = "tracing-layer")]
use tracing_subscriber::layer::{Context, Filter};
pub const HTTP_STACK_TARGETS: &[&str] = &[
"reqwest",
"hyper",
"hyper_util",
"hyper_rustls",
"h2",
"rustls",
"tokio_rustls",
"tower_http",
"tower",
];
pub const SENSITIVE_HEADER_NAMES: &[&str] = &[
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-amz-security-token",
"x-vault-token",
"x-api-key",
"x-auth-token",
"x-csrf-token",
"authorization_header",
"bearer_token",
"access_token",
"refresh_token",
"id_token",
"api_key",
"session_token",
"secret",
"secret_value",
];
#[cfg(feature = "tracing-layer")]
#[must_use]
pub fn redacted_filter() -> RedactedFilter {
RedactedFilter::new()
}
#[cfg(feature = "tracing-layer")]
#[derive(Debug, Default, Clone, Copy)]
pub struct RedactedFilter {
_private: (),
}
#[cfg(feature = "tracing-layer")]
impl RedactedFilter {
#[must_use]
pub fn new() -> Self {
Self { _private: () }
}
}
#[cfg(feature = "tracing-layer")]
impl<S> Filter<S> for RedactedFilter
where
S: Subscriber,
{
fn enabled(&self, metadata: &Metadata<'_>, _: &Context<'_, S>) -> bool {
if is_http_stack_trace(metadata) {
return false;
}
true
}
fn event_enabled(&self, event: &Event<'_>, _: &Context<'_, S>) -> bool {
if is_http_stack_trace(event.metadata()) {
return false;
}
let mut visitor = SensitiveFieldVisitor::default();
event.record(&mut visitor);
!visitor.found_sensitive
}
}
#[cfg(feature = "tracing-layer")]
fn is_http_stack_trace(metadata: &Metadata<'_>) -> bool {
if *metadata.level() != tracing::Level::TRACE {
return false;
}
let target = metadata.target();
HTTP_STACK_TARGETS.iter().any(|prefix| {
target == *prefix
|| target
.strip_prefix(*prefix)
.is_some_and(|rest| rest.starts_with("::"))
})
}
#[derive(Debug, Default)]
pub struct SensitiveFieldVisitor {
pub found_sensitive: bool,
}
impl Visit for SensitiveFieldVisitor {
fn record_debug(&mut self, field: &Field, _value: &dyn std::fmt::Debug) {
self.check(field);
}
fn record_str(&mut self, field: &Field, _value: &str) {
self.check(field);
}
fn record_i64(&mut self, field: &Field, _value: i64) {
self.check(field);
}
fn record_u64(&mut self, field: &Field, _value: u64) {
self.check(field);
}
fn record_bool(&mut self, field: &Field, _value: bool) {
self.check(field);
}
fn record_f64(&mut self, field: &Field, _value: f64) {
self.check(field);
}
fn record_error(&mut self, field: &Field, _value: &(dyn std::error::Error + 'static)) {
self.check(field);
}
}
impl SensitiveFieldVisitor {
fn check(&mut self, field: &Field) {
if self.found_sensitive {
return;
}
if is_sensitive_field_name(field.name()) {
self.found_sensitive = true;
}
}
}
#[must_use]
pub fn is_sensitive_field_name(name: &str) -> bool {
SENSITIVE_HEADER_NAMES
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(name))
}
#[cfg(all(test, feature = "tracing-layer"))]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use tracing::Level;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::Layer;
#[derive(Clone, Default)]
struct CapturingWriter {
buf: Arc<Mutex<Vec<u8>>>,
}
impl CapturingWriter {
fn snapshot(&self) -> String {
let guard = self.buf.lock().unwrap();
String::from_utf8_lossy(&guard).into_owned()
}
}
impl std::io::Write for CapturingWriter {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.buf.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for CapturingWriter {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
#[test]
fn sensitive_field_visitor_flags_authorization() {
assert!(is_sensitive_field_name("authorization"));
assert!(is_sensitive_field_name("AUTHORIZATION"));
assert!(is_sensitive_field_name("Authorization"));
assert!(is_sensitive_field_name("cookie"));
assert!(is_sensitive_field_name("x-vault-token"));
assert!(is_sensitive_field_name("X-Vault-Token"));
assert!(is_sensitive_field_name("bearer_token"));
}
#[test]
fn sensitive_field_visitor_lets_normal_fields_through() {
assert!(!is_sensitive_field_name("status"));
assert!(!is_sensitive_field_name("url"));
assert!(!is_sensitive_field_name("method"));
assert!(!is_sensitive_field_name("duration_ms"));
assert!(!is_sensitive_field_name("authorize_route"));
assert!(!is_sensitive_field_name("cookies_accepted"));
}
#[test]
fn http_stack_target_match_is_prefix_aware() {
for tgt in [
"reqwest",
"reqwest::connect",
"reqwest::async_impl::client",
"hyper",
"hyper::client::conn",
"hyper_util::client::legacy::pool",
"h2::proto::streams::send",
"rustls::client::tls13",
] {
assert!(
HTTP_STACK_TARGETS.iter().any(|p| tgt == *p
|| tgt.strip_prefix(*p).is_some_and(|r| r.starts_with("::"))),
"expected target {tgt} to match HTTP_STACK_TARGETS"
);
}
for tgt in [
"reqwesting", "cellos_supervisor", "tokio", "tracing", ] {
assert!(
!HTTP_STACK_TARGETS.iter().any(|p| tgt == *p
|| tgt.strip_prefix(*p).is_some_and(|r| r.starts_with("::"))),
"did NOT expect target {tgt} to match HTTP_STACK_TARGETS"
);
}
}
#[test]
fn integration_drops_event_with_authorization_field() {
let writer = CapturingWriter::default();
let fmt_layer = tracing_subscriber::fmt::layer()
.with_writer(writer.clone())
.with_target(true)
.with_filter(RedactedFilter::new());
let subscriber = tracing_subscriber::registry().with(fmt_layer);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(
authorization = "Bearer test-bearer-xyzzy-not-real",
"outbound request"
);
tracing::info!(
target: "cellos_export_http::client",
authorization = "Bearer test-bearer-xyzzy-not-real",
"outbound request"
);
});
let captured = writer.snapshot();
assert!(
!captured.contains("xyzzy"),
"captured tracing output leaked the bearer token: {captured}"
);
assert!(
!captured.contains("Bearer"),
"captured tracing output leaked the Bearer prefix: {captured}"
);
assert!(
!captured.contains("outbound request"),
"expected event to be suppressed entirely; got: {captured}"
);
}
#[test]
fn integration_passes_non_sensitive_event_through() {
let writer = CapturingWriter::default();
let fmt_layer = tracing_subscriber::fmt::layer()
.with_writer(writer.clone())
.with_target(true)
.with_ansi(false) .with_filter(RedactedFilter::new());
let subscriber = tracing_subscriber::registry().with(fmt_layer);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(
target: "cellos_export_http::client",
status = 200_u64,
url = "https://example.invalid/health",
duration_ms = 42_u64,
"request completed"
);
});
let captured = writer.snapshot();
assert!(
captured.contains("request completed"),
"non-sensitive event was suppressed unexpectedly: {captured}"
);
assert!(
captured.contains("status=200"),
"non-sensitive structured field missing: {captured}"
);
}
#[test]
fn integration_drops_reqwest_trace_target() {
let writer = CapturingWriter::default();
let fmt_layer = tracing_subscriber::fmt::layer()
.with_writer(writer.clone())
.with_target(true)
.with_filter(RedactedFilter::new());
let env_filter = tracing_subscriber::EnvFilter::new("trace");
let subscriber = tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer);
tracing::subscriber::with_default(subscriber, || {
tracing::event!(
target: "reqwest::async_impl::client",
Level::TRACE,
"request headers: Authorization=Bearer test-bearer-xyzzy-not-real"
);
tracing::event!(
target: "hyper::proto::h1::role",
Level::TRACE,
"writing header: Authorization: Bearer test-bearer-xyzzy-not-real"
);
tracing::event!(
target: "h2::proto::streams::send",
Level::TRACE,
"sending frame: Authorization=Bearer test-bearer-xyzzy-not-real"
);
tracing::event!(
target: "reqwest::async_impl::client",
Level::DEBUG,
"request completed"
);
});
let captured = writer.snapshot();
assert!(
!captured.contains("xyzzy"),
"reqwest TRACE event leaked secret: {captured}"
);
assert!(
!captured.contains("Bearer"),
"reqwest TRACE event leaked Bearer prefix: {captured}"
);
assert!(
captured.contains("request completed"),
"expected reqwest DEBUG event to survive the filter: {captured}"
);
}
#[test]
fn every_sensitive_name_round_trips_through_the_lookup() {
for &name in SENSITIVE_HEADER_NAMES {
assert!(
is_sensitive_field_name(name),
"SENSITIVE_HEADER_NAMES entry {name:?} failed self-lookup"
);
let upper = name.to_ascii_uppercase();
assert!(
is_sensitive_field_name(&upper),
"SENSITIVE_HEADER_NAMES entry {name:?} failed uppercase lookup"
);
assert_eq!(
name,
name.to_ascii_lowercase(),
"SENSITIVE_HEADER_NAMES entry {name:?} is not lowercase"
);
}
}
}