saluki_components/transforms/trace_obfuscation/
mod.rs

1//! Trace obfuscation transform.
2
3mod credit_cards;
4mod http;
5mod json;
6mod memcached;
7mod obfuscator;
8mod redis;
9mod sql;
10mod sql_filters;
11mod sql_tokenizer;
12
13use agent_data_plane_config::domains;
14use async_trait::async_trait;
15use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
16use saluki_core::{
17    components::{transforms::*, BuildContext},
18    data_model::event::{
19        trace::{AttributeValue, Span},
20        Event,
21    },
22    topology::EventsBuffer,
23};
24use saluki_error::GenericError;
25use stringtheory::MetaString;
26
27pub use self::obfuscator::{tags, ObfuscationConfig, Obfuscator};
28
29const TEXT_NON_PARSABLE_SQL: &str = "Non-parsable SQL query";
30
31/// Trace obfuscation configuration.
32pub struct TraceObfuscationConfiguration {
33    /// Obfuscator configuration.
34    pub config: ObfuscationConfig,
35}
36
37impl TraceObfuscationConfiguration {
38    /// Creates a new `TraceObfuscationConfiguration` from the resolved trace configuration.
39    pub fn from_configuration(config: &domains::traces::Obfuscation) -> Self {
40        Self { config: config.into() }
41    }
42}
43
44#[async_trait]
45impl SynchronousTransformBuilder for TraceObfuscationConfiguration {
46    async fn build(&self, _context: BuildContext) -> Result<Box<dyn SynchronousTransform + Send>, GenericError> {
47        Ok(Box::new(TraceObfuscation {
48            obfuscator: Obfuscator::new(self.config.clone()),
49        }))
50    }
51}
52
53impl MemoryBounds for TraceObfuscationConfiguration {
54    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
55        builder
56            .minimum()
57            .with_single_value::<TraceObfuscation>("component struct");
58    }
59}
60
61/// The obfuscation transform that processes traces.
62pub struct TraceObfuscation {
63    obfuscator: Obfuscator,
64}
65
66impl TraceObfuscation {
67    fn obfuscate_span(&mut self, span: &mut Span) {
68        if self.obfuscator.config.credit_cards.enabled {
69            self.obfuscate_credit_cards_in_span(span);
70        }
71
72        match span.span_type() {
73            "http" | "web" => self.obfuscate_http_span(span),
74            "sql" | "cassandra" => self.obfuscate_sql_span(span),
75            "redis" | "valkey" => self.obfuscate_redis_span(span),
76            "memcached" => self.obfuscate_memcached_span(span),
77            "mongodb" => self.obfuscate_mongodb_span(span),
78            "elasticsearch" | "opensearch" => self.obfuscate_elasticsearch_span(span),
79            _ => {}
80        }
81    }
82
83    fn obfuscate_credit_cards_in_span(&mut self, span: &mut Span) {
84        for (key, value) in span.attributes.iter_mut() {
85            if let AttributeValue::String(str_val) = value {
86                if let Some(replacement) = self
87                    .obfuscator
88                    .obfuscate_credit_card_number(key.as_ref(), str_val.as_ref())
89                {
90                    *str_val = replacement;
91                }
92            }
93        }
94    }
95
96    fn obfuscate_http_span(&mut self, span: &mut Span) {
97        // Every URL goes through the algorithm. Screening with a cheap byte scan first would save the work on URLs
98        // with nothing to redact, but no scan we have says whether the algorithm changes a URL, and the one upstream
99        // offers misses the URLs it redacts wholesale. See `http::obfuscate_url`.
100        let obfuscated = match span.attributes.get(tags::HTTP_URL).and_then(AttributeValue::as_string) {
101            Some(url) if !url.is_empty() => self.obfuscator.obfuscate_url(url),
102            _ => return,
103        };
104
105        if let Some(obfuscated) = obfuscated {
106            span.attributes
107                .insert(tags::HTTP_URL.into(), AttributeValue::String(obfuscated));
108        }
109    }
110
111    fn obfuscate_sql_span(&mut self, span: &mut Span) {
112        let sql_query_owned: Option<String> = span
113            .attributes
114            .get(tags::DB_STATEMENT)
115            .and_then(AttributeValue::as_string)
116            .filter(|s| !s.is_empty())
117            .map(|s| s.as_ref().to_owned());
118        let sql_query: &str = match &sql_query_owned {
119            Some(s) => s.as_str(),
120            None => span.resource(),
121        };
122
123        if sql_query.is_empty() {
124            return;
125        }
126
127        let dbms_owned: Option<String> = span
128            .attributes
129            .get(tags::DBMS)
130            .and_then(AttributeValue::as_string)
131            .filter(|s| !s.is_empty())
132            .map(|s| s.as_ref().to_owned());
133
134        let config = match &dbms_owned {
135            Some(d) => self.obfuscator.config.sql.with_dbms(d.clone()),
136            None => self.obfuscator.config.sql.clone(),
137        };
138
139        match sql::obfuscate_sql_string(sql_query, &config) {
140            Ok(obfuscated) => {
141                let query: MetaString = obfuscated.query.into();
142
143                span.set_resource(query.clone());
144                span.attributes
145                    .insert(tags::SQL_QUERY.into(), AttributeValue::String(query.clone()));
146
147                if span.attributes.contains_key(tags::DB_STATEMENT) {
148                    span.attributes
149                        .insert(tags::DB_STATEMENT.into(), AttributeValue::String(query));
150                }
151
152                if !obfuscated.table_names.is_empty() {
153                    span.attributes.insert(
154                        "sql.tables".into(),
155                        AttributeValue::String(obfuscated.table_names.into()),
156                    );
157                }
158            }
159            Err(_) => {
160                let non_parsable: MetaString = TEXT_NON_PARSABLE_SQL.into();
161                span.set_resource(non_parsable.clone());
162                span.attributes
163                    .insert(tags::SQL_QUERY.into(), AttributeValue::String(non_parsable));
164            }
165        }
166    }
167
168    fn obfuscate_redis_span(&mut self, span: &mut Span) {
169        let resource = span.resource();
170        if resource.is_empty() {
171            return;
172        }
173
174        if let Some(quantized) = self.obfuscator.quantize_redis_string(resource) {
175            span.set_resource(quantized.to_string());
176        }
177
178        if span.span_type() == "redis" && self.obfuscator.config.redis.enabled {
179            if let Some(cmd_value) = span
180                .attributes
181                .get(tags::REDIS_RAW_COMMAND)
182                .and_then(AttributeValue::as_string)
183                .map(|s| s.as_ref().to_owned())
184            {
185                if let Some(obfuscated) = self.obfuscator.obfuscate_redis_string(&cmd_value) {
186                    span.attributes
187                        .insert(tags::REDIS_RAW_COMMAND.into(), AttributeValue::String(obfuscated));
188                }
189            }
190        }
191
192        if span.span_type() == "valkey" && self.obfuscator.config.valkey.enabled {
193            if let Some(cmd_value) = span
194                .attributes
195                .get(tags::VALKEY_RAW_COMMAND)
196                .and_then(AttributeValue::as_string)
197                .map(|s| s.as_ref().to_owned())
198            {
199                if let Some(obfuscated) = self.obfuscator.obfuscate_valkey_string(&cmd_value) {
200                    span.attributes
201                        .insert(tags::VALKEY_RAW_COMMAND.into(), AttributeValue::String(obfuscated));
202                }
203            }
204        }
205    }
206
207    fn obfuscate_memcached_span(&mut self, span: &mut Span) {
208        if !self.obfuscator.config.memcached.enabled {
209            return;
210        }
211
212        let cmd_value = match span
213            .attributes
214            .get(tags::MEMCACHED_COMMAND)
215            .and_then(AttributeValue::as_string)
216        {
217            Some(v) if !v.is_empty() => v.as_ref().to_owned(),
218            _ => return,
219        };
220
221        if let Some(obfuscated) = self.obfuscator.obfuscate_memcached_command(&cmd_value) {
222            if obfuscated.is_empty() {
223                span.attributes.remove(tags::MEMCACHED_COMMAND);
224            } else {
225                span.attributes
226                    .insert(tags::MEMCACHED_COMMAND.into(), AttributeValue::String(obfuscated));
227            }
228        }
229    }
230
231    fn obfuscate_mongodb_span(&mut self, span: &mut Span) {
232        let query_value = match span
233            .attributes
234            .get(tags::MONGODB_QUERY)
235            .and_then(AttributeValue::as_string)
236        {
237            Some(v) => v.as_ref().to_owned(),
238            None => return,
239        };
240
241        if let Some(obfuscated) = self.obfuscator.obfuscate_mongodb_string(&query_value) {
242            span.attributes
243                .insert(tags::MONGODB_QUERY.into(), AttributeValue::String(obfuscated));
244        }
245    }
246
247    fn obfuscate_elasticsearch_span(&mut self, span: &mut Span) {
248        if let Some(body_value) = span
249            .attributes
250            .get(tags::ELASTIC_BODY)
251            .and_then(AttributeValue::as_string)
252            .map(|s| s.as_ref().to_owned())
253        {
254            if let Some(obfuscated) = self.obfuscator.obfuscate_elasticsearch_string(&body_value) {
255                span.attributes
256                    .insert(tags::ELASTIC_BODY.into(), AttributeValue::String(obfuscated));
257            }
258        }
259
260        if let Some(body_value) = span
261            .attributes
262            .get(tags::OPENSEARCH_BODY)
263            .and_then(AttributeValue::as_string)
264            .map(|s| s.as_ref().to_owned())
265        {
266            if let Some(obfuscated) = self.obfuscator.obfuscate_opensearch_string(&body_value) {
267                span.attributes
268                    .insert(tags::OPENSEARCH_BODY.into(), AttributeValue::String(obfuscated));
269            }
270        }
271    }
272}
273
274impl SynchronousTransform for TraceObfuscation {
275    fn transform_buffer(&mut self, buffer: &mut EventsBuffer) {
276        for event in buffer {
277            if let Event::Trace(ref mut trace) = event {
278                for span in trace.spans_mut() {
279                    self.obfuscate_span(span);
280                }
281            }
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn http_span(url: &str) -> Span {
291        let mut span = Span::new("svc", "http.request", "GET /x", "http", 1, 0, 0, 0, 0);
292        span.attributes
293            .insert(tags::HTTP_URL.into(), AttributeValue::String(url.into()));
294        span
295    }
296
297    fn transform(remove_query_string: bool, remove_paths_with_digits: bool) -> TraceObfuscation {
298        let mut config = ObfuscationConfig::default();
299        config.http.remove_query_string = remove_query_string;
300        config.http.remove_paths_with_digits = remove_paths_with_digits;
301
302        TraceObfuscation {
303            obfuscator: Obfuscator::new(config),
304        }
305    }
306
307    fn obfuscated_url(url: &str, remove_query_string: bool, remove_paths_with_digits: bool) -> String {
308        let mut span = http_span(url);
309        transform(remove_query_string, remove_paths_with_digits).obfuscate_span(&mut span);
310
311        span.attributes
312            .get(tags::HTTP_URL)
313            .and_then(AttributeValue::as_string)
314            .map(|s| s.as_ref().to_owned())
315            .expect("http.url was removed from the span")
316    }
317
318    // The reference implementation redacts a URL it cannot parse wholesale as soon as either option is on, so the span
319    // path has to reach the algorithm for these URLs rather than screening them out first.
320    #[test]
321    fn unparseable_url_is_redacted_on_the_span() {
322        for url in ["https://example.com:port/x", "http://foo:bar.com/x", ":"] {
323            for (remove_query_string, remove_paths_with_digits) in [(true, false), (false, true), (true, true)] {
324                assert_eq!(
325                    obfuscated_url(url, remove_query_string, remove_paths_with_digits),
326                    "?",
327                    "expected wholesale redaction for {url:?}"
328                );
329            }
330        }
331    }
332
333    // With both options off the algorithm only strips userinfo, and an unparseable URL keeps everything else.
334    #[test]
335    fn unparseable_url_is_kept_when_both_options_are_off() {
336        assert_eq!(
337            obfuscated_url("https://example.com:port/x", false, false),
338            "https://example.com:port/x"
339        );
340    }
341}