use std::sync::{Arc, Mutex};
use tracing::{span, Subscriber};
use tracing_subscriber::{layer::Context, layer::SubscriberExt, Layer};
#[derive(Debug, Clone)]
struct SpanRecord {
name: String,
fields: Vec<(String, String)>,
}
struct FieldVisitor<'a> {
fields: &'a mut Vec<(String, String)>,
}
impl tracing::field::Visit for FieldVisitor<'_> {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.fields
.push((field.name().to_string(), value.to_string()));
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.fields
.push((field.name().to_string(), format!("{value:?}")));
}
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.fields
.push((field.name().to_string(), value.to_string()));
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
self.fields
.push((field.name().to_string(), value.to_string()));
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.fields
.push((field.name().to_string(), value.to_string()));
}
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
self.fields
.push((field.name().to_string(), value.to_string()));
}
}
struct SpanRecorder {
spans: Arc<Mutex<Vec<SpanRecord>>>,
}
impl<S: Subscriber> Layer<S> for SpanRecorder {
fn on_new_span(&self, attrs: &span::Attributes<'_>, _id: &span::Id, _ctx: Context<'_, S>) {
let mut fields = Vec::new();
attrs.record(&mut FieldVisitor {
fields: &mut fields,
});
let record = SpanRecord {
name: attrs.metadata().name().to_string(),
fields,
};
self.spans.lock().unwrap().push(record);
}
}
fn make_recording_dispatch() -> (Arc<Mutex<Vec<SpanRecord>>>, tracing::Dispatch) {
let spans: Arc<Mutex<Vec<SpanRecord>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = SpanRecorder {
spans: Arc::clone(&spans),
};
let subscriber = tracing_subscriber::registry().with(recorder);
let dispatch = tracing::Dispatch::new(subscriber);
(spans, dispatch)
}
#[cfg(feature = "cloud-pull-aws")]
#[test]
fn aws_sm_push_secret_value_not_in_spans() {
use tsafe_cli::tsafe_aws::{push_secret as aws_push_secret, AwsConfig, AwsCredentials};
let plaintext_value = "ultra-secret-aws-sm-value-xY7z";
let secret_name = "prod/db-password";
let mut server = mockito::Server::new();
let _get = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
.with_status(400)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"__type":"ResourceNotFoundException","Message":"not found"}"#)
.create();
let _create = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.CreateSecret")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(
r#"{"ARN":"arn:aws:secretsmanager:us-east-1:123:secret:prod/db-password","Name":"prod/db-password"}"#,
)
.create();
let cfg = AwsConfig::with_endpoint("us-east-1", server.url());
let creds = AwsCredentials {
access_key_id: "AKID-TEST".into(),
secret_access_key: "secret-test".into(),
session_token: None,
};
let (spans, dispatch) = make_recording_dispatch();
tracing::dispatcher::with_default(&dispatch, || {
let _ = aws_push_secret(&cfg, &|| Ok(creds.clone()), secret_name, plaintext_value);
});
drop(dispatch);
let recorded = spans.lock().unwrap();
assert!(
!recorded.is_empty(),
"aws push_secret must produce at least one span"
);
for span in recorded.iter() {
for (field_name, field_value) in &span.fields {
assert!(
!field_value.contains(plaintext_value),
"Span '{}' field '{}' leaks plaintext secret value — \
#[tracing::instrument(skip(value))] must prevent this",
span.name,
field_name,
);
}
}
let has_name_field = recorded.iter().any(|s| {
s.fields
.iter()
.any(|(k, v)| k == "name" && v.contains(secret_name))
});
assert!(
has_name_field,
"aws push_secret span must expose 'name' field with the secret name for observability; \
got spans: {recorded:?}"
);
}
#[cfg(feature = "cloud-pull-aws")]
#[test]
fn aws_ssm_push_parameter_value_not_in_spans() {
use tsafe_cli::tsafe_aws::{push_ssm_parameter, AwsConfig, AwsCredentials};
let plaintext_value = "ultra-secret-ssm-value-aB3q";
let param_name = "/myapp/db-password";
let mut server = mockito::Server::new();
let _get = server
.mock("POST", "/")
.match_header("X-Amz-Target", "AmazonSSM.GetParameter")
.with_status(400)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"__type":"ParameterNotFound","message":"Parameter not found"}"#)
.create();
let _put = server
.mock("POST", "/")
.match_header("X-Amz-Target", "AmazonSSM.PutParameter")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"Version":1,"Tier":"Standard"}"#)
.create();
let cfg = AwsConfig::with_endpoint("us-east-1", server.url());
let creds = AwsCredentials {
access_key_id: "AKID-TEST".into(),
secret_access_key: "secret-test".into(),
session_token: None,
};
let (spans, dispatch) = make_recording_dispatch();
tracing::dispatcher::with_default(&dispatch, || {
let _ = push_ssm_parameter(
&cfg,
&|| Ok(creds.clone()),
param_name,
plaintext_value,
true,
);
});
drop(dispatch);
let recorded = spans.lock().unwrap();
assert!(
!recorded.is_empty(),
"aws ssm push_ssm_parameter must produce at least one span"
);
for span in recorded.iter() {
for (field_name, field_value) in &span.fields {
assert!(
!field_value.contains(plaintext_value),
"Span '{}' field '{}' leaks plaintext secret value — \
#[tracing::instrument(skip(value))] must prevent this",
span.name,
field_name,
);
}
}
let has_name_field = recorded.iter().any(|s| {
s.fields
.iter()
.any(|(k, v)| k == "name" && v.contains(param_name))
});
assert!(
has_name_field,
"aws ssm push_ssm_parameter span must expose 'name' field with the parameter name; \
got spans: {recorded:?}"
);
}
#[cfg(feature = "cloud-pull-gcp")]
#[test]
fn gcp_push_secret_value_not_in_spans() {
use tsafe_cli::tsafe_gcp::{push_secret as gcp_push_secret, GcpConfig, GcpToken};
let plaintext_value = "ultra-secret-gcp-value-pQ9w";
let secret_name = "my-db-password";
let mut server = mockito::Server::new();
let _exists = server
.mock(
"GET",
mockito::Matcher::Regex(format!(r"/v1/projects/test-project/secrets/{secret_name}$")),
)
.with_status(404)
.with_body(r#"{"error":{"code":404,"status":"NOT_FOUND"}}"#)
.create();
let _create = server
.mock(
"POST",
mockito::Matcher::Regex(r"/v1/projects/test-project/secrets\?secretId=".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"name":"projects/test-project/secrets/my-db-password"}"#)
.create();
let _version = server
.mock(
"POST",
mockito::Matcher::Regex(
r"/v1/projects/test-project/secrets/my-db-password/versions:add".to_string(),
),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"name":"projects/test-project/secrets/my-db-password/versions/1"}"#)
.create();
let cfg = GcpConfig::with_endpoint("test-project", format!("{}/v1", server.url()));
let (spans, dispatch) = make_recording_dispatch();
tracing::dispatcher::with_default(&dispatch, || {
let _ = gcp_push_secret(
&cfg,
&|| Ok(GcpToken("test-token".into())),
secret_name,
plaintext_value,
);
});
drop(dispatch);
let recorded = spans.lock().unwrap();
assert!(
!recorded.is_empty(),
"gcp push_secret must produce at least one span"
);
for span in recorded.iter() {
for (field_name, field_value) in &span.fields {
assert!(
!field_value.contains(plaintext_value),
"Span '{}' field '{}' leaks plaintext secret value — \
#[tracing::instrument(skip(value))] must prevent this",
span.name,
field_name,
);
}
}
let has_name_field = recorded.iter().any(|s| {
s.fields
.iter()
.any(|(k, v)| k == "name" && v.contains(secret_name))
});
assert!(
has_name_field,
"gcp push_secret span must expose 'name' field with the secret name; \
got spans: {recorded:?}"
);
}