openlatch-client 0.1.12

The open-source security layer for AI agents — client forwarder
//! `before_send` scrubber — reuses `src/core/privacy/` to redact credentials
//! from panic messages and backtrace frames before events leave the process.
//!
//! Fields scrubbed (per brainstorm Decision 5):
//! - `event.message`
//! - `event.exception.values[*].value` (panic message)
//! - `event.exception.values[*].stacktrace.frames[*].filename`
//! - `event.exception.values[*].stacktrace.frames[*].function`
//!
//! `frames[*].vars` is **not** scrubbed — sentry-rust's backtrace capture
//! produces symbol names only (no runtime variable values), so the map is
//! always empty. Scrubbing empty data would be dead code with a misleading
//! test name.

use sentry::protocol::Event;

use crate::privacy::{filter_value, PrivacyFilter};

/// Scrub an outgoing Sentry event in place using the given privacy filter.
///
/// Always returns `Some(event)` — we never drop events (an opaque redacted
/// stack trace still dedupes by fingerprint and remains useful for triage).
pub fn scrub_event(mut event: Event<'static>, filter: &PrivacyFilter) -> Option<Event<'static>> {
    if let Some(ref mut msg) = event.message {
        *msg = scrub_string(msg, filter);
    }

    for exception in event.exception.iter_mut() {
        if let Some(ref mut v) = exception.value {
            *v = scrub_string(v, filter);
        }
        if let Some(ref mut stacktrace) = exception.stacktrace {
            for frame in stacktrace.frames.iter_mut() {
                if let Some(ref mut f) = frame.filename {
                    *f = scrub_string(f, filter);
                }
                if let Some(ref mut f) = frame.function {
                    *f = scrub_string(f, filter);
                }
            }
        }
    }

    Some(event)
}

/// Run the JSON-oriented privacy filter against a bare `String`.
///
/// The privacy module operates on `serde_json::Value::String`, so we wrap the
/// input, filter it, and unwrap. Cheap — at most one allocation for the
/// wrapping Value, zero if no pattern matches.
fn scrub_string(s: &str, filter: &PrivacyFilter) -> String {
    let mut v = serde_json::Value::String(s.to_string());
    filter_value(&mut v, filter);
    match v {
        serde_json::Value::String(out) => out,
        _ => s.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use sentry::protocol::{Exception, Frame, Stacktrace, Values};

    fn filter() -> PrivacyFilter {
        PrivacyFilter::new(&[])
    }

    fn panic_event(message: &str) -> Event<'static> {
        Event {
            exception: Values::from(vec![Exception {
                ty: "panic".into(),
                value: Some(message.to_string()),
                ..Default::default()
            }]),
            ..Default::default()
        }
    }

    #[test]
    fn test_scrub_redacts_aws_key_in_panic_message() {
        let filter = filter();
        // Synthetic pattern-matching fixture, not a real credential.
        let fake_aws = format!("{}{}", "AKIA", "1234567890ABCDEF"); // gitleaks:allow
        let ev = panic_event(&format!("boom: {fake_aws} leaked"));
        let scrubbed = scrub_event(ev, &filter).unwrap();
        let value = scrubbed.exception[0].value.as_ref().unwrap();
        assert!(value.contains("[AWS_KEY:AKIA***]"), "got: {value}");
        assert!(!value.contains(&fake_aws));
    }

    #[test]
    fn test_scrub_redacts_github_pat_in_frame_filename() {
        let filter = filter();
        let mut ev = panic_event("boom");
        ev.exception[0].stacktrace = Some(Stacktrace {
            frames: vec![Frame {
                filename: Some("/tmp/ghp_abcdefghijklmnopqrstuvwxyz0123456789/main.rs".into()),
                function: Some("my_fn".into()),
                ..Default::default()
            }],
            ..Default::default()
        });
        let scrubbed = scrub_event(ev, &filter).unwrap();
        let fname = scrubbed.exception[0].stacktrace.as_ref().unwrap().frames[0]
            .filename
            .as_ref()
            .unwrap();
        assert!(fname.contains("[GITHUB_TOKEN:ghp_***]"), "got: {fname}");
    }

    #[test]
    fn test_scrub_redacts_bearer_token_in_message() {
        let filter = filter();
        let ev = panic_event("auth failed for Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig");
        let scrubbed = scrub_event(ev, &filter).unwrap();
        let value = scrubbed.exception[0].value.as_ref().unwrap();
        // The privacy filter has a Bearer pattern; we assert the raw token is gone.
        assert!(
            !value.contains("eyJhbGciOiJIUzI1NiJ9.payload.sig"),
            "got: {value}"
        );
    }

    #[test]
    fn test_scrub_preserves_event_when_nothing_matches() {
        let filter = filter();
        let ev = panic_event("ordinary panic with no secrets");
        let scrubbed = scrub_event(ev, &filter).unwrap();
        assert_eq!(
            scrubbed.exception[0].value.as_ref().unwrap(),
            "ordinary panic with no secrets"
        );
    }
}