saluki_components/transforms/trace_obfuscation/
mod.rs1mod 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
31pub struct TraceObfuscationConfiguration {
33 pub config: ObfuscationConfig,
35}
36
37impl TraceObfuscationConfiguration {
38 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
61pub 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 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 #[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 #[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}