use anyhow::Result;
use async_trait::async_trait;
use std::sync::Arc;
use std::time::Instant;
use crate::{
neutralizer::{
BatchNeutralizeResult, NeutralizeResult, NeutralizerCapabilities, ThreatNeutralizer,
},
scanner::{Threat, ThreatType},
telemetry::{
DistributedTracingProvider, SpanBuilder, SpanKind, SpanStatus, StatusCode, TelemetryContext,
},
};
pub struct TracedNeutralizer {
inner: Arc<dyn ThreatNeutralizer>,
tracing_provider: Arc<DistributedTracingProvider>,
service_name: String,
}
impl TracedNeutralizer {
pub fn new(
neutralizer: Arc<dyn ThreatNeutralizer>,
tracing_provider: Arc<DistributedTracingProvider>,
) -> Arc<Self> {
Arc::new(Self {
inner: neutralizer,
tracing_provider,
service_name: "kindly-guard.neutralizer".to_string(),
})
}
async fn get_parent_context(&self) -> Option<TelemetryContext> {
None
}
}
#[async_trait]
impl ThreatNeutralizer for TracedNeutralizer {
async fn neutralize(&self, threat: &Threat, content: &str) -> Result<NeutralizeResult> {
let _parent_context = self.get_parent_context().await;
let span = SpanBuilder::new(&self.tracing_provider, "neutralize")
.with_kind(SpanKind::Internal)
.with_attribute("service.name", &self.service_name)
.with_attribute("threat.type", &format!("{:?}", threat.threat_type))
.with_attribute("threat.severity", &format!("{:?}", threat.severity))
.with_attribute("content.length", &content.len().to_string())
.with_attribute(
"threat.location.offset",
&match &threat.location {
crate::scanner::Location::Text { offset, .. } => offset.to_string(),
crate::scanner::Location::Json { path, .. } => format!("json:{path}"),
crate::scanner::Location::Binary { offset } => format!("binary:{offset}"),
},
)
.start()
.await;
let span_id = span.span_id.clone();
let start_time = Instant::now();
self.tracing_provider
.add_span_event(
&span_id,
"neutralization.start",
vec![("threat.description", &threat.description)],
)
.await;
let result = self.inner.neutralize(threat, content).await;
let duration_ms = start_time.elapsed().as_secs_f64() * 1000.0;
let (status_code, status_description) = match &result {
Ok(neutralize_result) => {
self.tracing_provider
.add_span_event(
&span_id,
"neutralization.complete",
vec![
("action", &format!("{}", neutralize_result.action_taken)),
(
"confidence",
&neutralize_result.confidence_score.to_string(),
),
(
"processing_time_us",
&neutralize_result.processing_time_us.to_string(),
),
(
"modified",
if neutralize_result.sanitized_content.is_some() {
"true"
} else {
"false"
},
),
],
)
.await;
if let Some(ref correlation) = neutralize_result.correlation_data {
self.tracing_provider
.add_span_event(
&span_id,
"neutralization.correlation",
vec![
(
"related_threats",
&correlation.related_threats.len().to_string(),
),
(
"attack_pattern",
&format!("{:?}", correlation.attack_pattern),
),
(
"prediction_score",
&correlation.prediction_score.to_string(),
),
],
)
.await;
for related_id in &correlation.related_threats {
self.tracing_provider
.add_span_link(
&span_id,
&span.trace_id, related_id,
vec![("link.type", "related_threat")],
)
.await;
}
}
(StatusCode::Ok, None)
},
Err(e) => {
self.tracing_provider
.add_span_event(
&span_id,
"neutralization.error",
vec![
("error.type", &format!("{e:?}")),
("error.message", &e.to_string()),
],
)
.await;
(StatusCode::Error, Some(e.to_string()))
},
};
if let Ok(ref neutralize_result) = result {
self.tracing_provider
.add_span_event(
&span_id,
"metrics.recorded",
vec![
("duration_ms", &duration_ms.to_string()),
("threat_type", &format!("{:?}", threat.threat_type)),
(
"action_taken",
&format!("{}", neutralize_result.action_taken),
),
],
)
.await;
}
self.tracing_provider
.end_distributed_span(
&span_id,
SpanStatus {
code: status_code,
description: status_description,
},
)
.await;
result
}
async fn batch_neutralize(
&self,
threats: &[Threat],
content: &str,
) -> Result<BatchNeutralizeResult> {
let parent_context = self.get_parent_context().await;
let batch_span = SpanBuilder::new(&self.tracing_provider, "batch_neutralize")
.with_kind(SpanKind::Internal)
.with_attribute("service.name", &self.service_name)
.with_attribute("batch.size", &threats.len().to_string())
.with_attribute("content.length", &content.len().to_string())
.start()
.await;
let batch_span_id = batch_span.span_id.clone();
let batch_context = TelemetryContext {
trace_id: batch_span.trace_id.clone(),
span_id: batch_span.span_id.clone(),
parent_span_id: parent_context.as_ref().map(|p| p.span_id.clone()),
baggage: vec![],
};
let mut individual_results = Vec::new();
let mut current_content = content.to_string();
let start_time = Instant::now();
for (i, threat) in threats.iter().enumerate() {
let child_span = SpanBuilder::new(&self.tracing_provider, "batch_neutralize.item")
.with_kind(SpanKind::Internal)
.with_parent(batch_context.child())
.with_attribute("batch.index", &i.to_string())
.with_attribute("threat.type", &format!("{:?}", threat.threat_type))
.start()
.await;
let child_span_id = child_span.span_id.clone();
let result = self.inner.neutralize(threat, ¤t_content).await;
if let Ok(ref neutralize_result) = result {
if let Some(ref sanitized) = neutralize_result.sanitized_content {
current_content = sanitized.clone();
}
individual_results.push(neutralize_result.clone());
}
let status = if result.is_ok() {
SpanStatus {
code: StatusCode::Ok,
description: None,
}
} else {
SpanStatus {
code: StatusCode::Error,
description: result.as_ref().err().map(std::string::ToString::to_string),
}
};
self.tracing_provider
.end_distributed_span(&child_span_id, status)
.await;
self.tracing_provider
.add_span_link(
&batch_span_id,
&child_span.trace_id,
&child_span_id,
vec![("link.type", "batch_item")],
)
.await;
}
let duration_ms = start_time.elapsed().as_secs_f64() * 1000.0;
let successful_count = individual_results.len();
self.tracing_provider
.add_span_event(
&batch_span_id,
"batch.summary",
vec![
("total_threats", &threats.len().to_string()),
("successful", &successful_count.to_string()),
("failed", &(threats.len() - successful_count).to_string()),
("duration_ms", &duration_ms.to_string()),
],
)
.await;
self.tracing_provider
.end_distributed_span(
&batch_span_id,
SpanStatus {
code: if successful_count == threats.len() {
StatusCode::Ok
} else {
StatusCode::Error
},
description: if successful_count < threats.len() {
Some(format!(
"{} of {} threats failed",
threats.len() - successful_count,
threats.len()
))
} else {
None
},
},
)
.await;
Ok(BatchNeutralizeResult {
final_content: current_content,
individual_results,
})
}
fn can_neutralize(&self, threat_type: &ThreatType) -> bool {
self.inner.can_neutralize(threat_type)
}
fn get_capabilities(&self) -> NeutralizerCapabilities {
self.inner.get_capabilities()
}
}
pub trait NeutralizerTracingExt {
fn with_tracing(
self: Arc<Self>,
tracing_provider: Arc<DistributedTracingProvider>,
) -> Arc<TracedNeutralizer>;
}
impl NeutralizerTracingExt for dyn ThreatNeutralizer {
fn with_tracing(
self: Arc<Self>,
tracing_provider: Arc<DistributedTracingProvider>,
) -> Arc<TracedNeutralizer> {
TracedNeutralizer::new(self, tracing_provider)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
neutralizer::{standard::StandardNeutralizer, NeutralizationConfig},
scanner::{Location, Severity},
telemetry::{
ProbabilitySampler, StandardTelemetryProvider, TelemetryConfig,
W3CTraceContextPropagator,
},
};
#[tokio::test]
async fn test_traced_neutralization() {
let config = NeutralizationConfig::default();
let neutralizer = Arc::new(StandardNeutralizer::new(config));
let telemetry_config = TelemetryConfig::default();
let base_provider = Arc::new(StandardTelemetryProvider::new(telemetry_config));
let sampler = Arc::new(ProbabilitySampler::new(1.0));
let propagator = Arc::new(W3CTraceContextPropagator);
let tracing_provider = Arc::new(DistributedTracingProvider::new(
base_provider,
sampler,
propagator,
));
let traced =
(neutralizer as Arc<dyn ThreatNeutralizer>).with_tracing(tracing_provider.clone());
let threat = Threat {
threat_type: ThreatType::SqlInjection,
severity: Severity::High,
location: Location::Text {
offset: 0,
length: 10,
},
description: "SQL injection test".to_string(),
remediation: None,
};
let result = traced.neutralize(&threat, "SELECT * FROM users").await;
assert!(result.is_ok());
let spans = tracing_provider.export_spans().await;
assert!(!spans.is_empty());
let span = &spans[0];
assert_eq!(span.operation_name, "neutralize");
eprintln!("Span attributes: {:?}", span.attributes);
assert!(span.attributes.contains_key("threat.type"));
assert!(span.events.iter().any(|e| e.name == "neutralization.start"));
}
#[tokio::test]
async fn test_batch_traced_neutralization() {
let config = NeutralizationConfig::default();
let neutralizer = Arc::new(StandardNeutralizer::new(config));
let telemetry_config = TelemetryConfig::default();
let base_provider = Arc::new(StandardTelemetryProvider::new(telemetry_config));
let sampler = Arc::new(ProbabilitySampler::new(1.0));
let propagator = Arc::new(W3CTraceContextPropagator);
let tracing_provider = Arc::new(DistributedTracingProvider::new(
base_provider,
sampler,
propagator,
));
let traced =
(neutralizer as Arc<dyn ThreatNeutralizer>).with_tracing(tracing_provider.clone());
let threats = vec![
Threat {
threat_type: ThreatType::SqlInjection,
severity: Severity::High,
location: Location::Text {
offset: 0,
length: 10,
},
description: "SQL injection".to_string(),
remediation: None,
},
Threat {
threat_type: ThreatType::CommandInjection,
severity: Severity::High,
location: Location::Text {
offset: 20,
length: 10,
},
description: "Command injection".to_string(),
remediation: None,
},
];
let result = traced
.batch_neutralize(&threats, "SELECT * FROM users; echo test")
.await;
assert!(result.is_ok());
let spans = tracing_provider.export_spans().await;
assert!(spans.len() >= 3);
let batch_span = spans
.iter()
.find(|s| s.operation_name == "batch_neutralize")
.unwrap();
eprintln!("Batch span attributes: {:?}", batch_span.attributes);
assert!(batch_span.attributes.contains_key("batch.size"));
assert!(batch_span.events.iter().any(|e| e.name == "batch.summary"));
}
}