tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! OTel span-safety tests for provider `push_secret` / `push_ssm_parameter`.
//!
//! These tests enforce the EC-4 no-plaintext-in-spans invariant for the
//! cloud-provider write path (ADR-024 §2).  They use a recording
//! [`tracing::Subscriber`] layer identical in structure to the one in
//! `tsafe-core/tests/otel_no_plaintext.rs`, then drive each provider
//! `push_secret` function through a mock HTTP server.
//!
//! # Assertions per test
//!
//! 1. The `push_secret` / `push_ssm_parameter` function produces at least one
//!    span (i.e. the instrument attribute is actually active).
//! 2. No span field VALUE contains the plaintext secret value — proving that
//!    `#[tracing::instrument(skip(value))]` is in effect.
//! 3. At least one span field matches `name = <secret-name>` — proving that
//!    the span is observationally useful even after the value is suppressed.

use std::sync::{Arc, Mutex};

use tracing::{span, Subscriber};
use tracing_subscriber::{layer::Context, layer::SubscriberExt, Layer};

// ── Recording infrastructure ──────────────────────────────────────────────────

#[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)
}

// ── Tests ─────────────────────────────────────────────────────────────────────

/// AWS Secrets Manager `push_secret`: the plaintext secret value must not
/// appear in any span field. The key name MUST appear as the `name` field.
#[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();
    // GetSecretValue → 400 ResourceNotFoundException (secret does not exist)
    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();
    // CreateSecret → 200
    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:?}"
    );
}

/// AWS SSM Parameter Store `push_ssm_parameter`: the plaintext secret value
/// must not appear in any span field. The parameter name MUST appear as `name`.
#[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();
    // GetParameter → 400 ParameterNotFound
    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();
    // PutParameter → 200
    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:?}"
    );
}

/// GCP Secret Manager `push_secret`: the plaintext secret value must not
/// appear in any span field. The secret name MUST appear as the `name` field.
#[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();
    // check_secret_exists GET → 404
    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();
    // create_secret_resource POST → 200
    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();
    // add_secret_version POST → 200
    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:?}"
    );
}