use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::Arc;
use thiserror::Error;
pub mod crypto;
pub mod injection;
pub mod patterns;
pub mod sync_wrapper;
pub mod unicode;
pub mod xss_scanner;
pub use crypto::CryptoScanner;
pub use injection::InjectionScanner;
pub use patterns::ThreatPatterns;
pub use unicode::UnicodeScanner;
pub use xss_scanner::{create_xss_scanner, XssScanner};
pub struct SecurityScanner {
unicode_scanner: UnicodeScanner,
injection_scanner: InjectionScanner,
xss_scanner: Arc<dyn XssScanner>,
crypto_scanner: CryptoScanner,
pub patterns: ThreatPatterns,
config: crate::config::ScannerConfig,
plugin_manager: Option<Arc<dyn crate::plugins::PluginManagerTrait>>,
#[allow(dead_code)]
#[cfg(feature = "enhanced")]
event_processor: Option<Arc<dyn crate::traits::SecurityEventProcessor>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Threat {
pub threat_type: ThreatType,
pub severity: Severity,
pub location: Location,
pub description: String,
pub remediation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ThreatType {
UnicodeInvisible,
UnicodeBiDi,
UnicodeHomograph,
UnicodeControl,
PromptInjection,
CommandInjection,
PathTraversal,
SqlInjection,
CrossSiteScripting,
LdapInjection,
XmlInjection,
NoSqlInjection,
SessionIdExposure,
ToolPoisoning,
TokenTheft,
DosPotential,
Custom(String),
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum Location {
Text { offset: usize, length: usize },
Json { path: String },
Binary { offset: usize },
}
#[derive(Error, Debug)]
pub enum ScanError {
#[error("Maximum scan depth exceeded")]
MaxDepthExceeded,
#[error("Invalid input format: {0}")]
InvalidInput(String),
#[error("Pattern compilation failed: {0}")]
PatternError(String),
#[error("Runtime error: {0}")]
RuntimeError(String),
}
pub type ScanResult = Result<Vec<Threat>, ScanError>;
impl SecurityScanner {
pub fn set_plugin_manager(
&mut self,
plugin_manager: Arc<dyn crate::plugins::PluginManagerTrait>,
) {
self.plugin_manager = Some(plugin_manager);
}
pub fn new(config: crate::config::ScannerConfig) -> Result<Self, ScanError> {
Self::with_processor(config, None)
}
pub fn with_processor(
config: crate::config::ScannerConfig,
#[allow(unused_variables)] event_processor: Option<
Arc<dyn crate::traits::SecurityEventProcessor>,
>,
) -> Result<Self, ScanError> {
let patterns = if let Some(path) = &config.custom_patterns {
ThreatPatterns::load_from_file(path)?
} else {
ThreatPatterns::default()
};
#[cfg(feature = "enhanced")]
let event_processor = if config.enable_event_buffer {
event_processor
} else {
None
};
#[cfg(not(feature = "enhanced"))]
let _event_processor: Option<Arc<dyn crate::traits::SecurityEventProcessor>> = None;
#[cfg(feature = "enhanced")]
let mut unicode_scanner = UnicodeScanner::with_config(config.allow_text_control_chars);
#[cfg(not(feature = "enhanced"))]
let unicode_scanner = UnicodeScanner::with_config(config.allow_text_control_chars);
#[cfg(feature = "enhanced")]
let mut injection_scanner = InjectionScanner::new(&patterns)?;
#[cfg(not(feature = "enhanced"))]
let injection_scanner = InjectionScanner::new(&patterns)?;
let crypto_scanner = CryptoScanner::new();
let xss_scanner = create_xss_scanner(
patterns.xss_patterns().to_vec(),
config.enhanced_mode.unwrap_or(false),
)?;
#[cfg(feature = "enhanced")]
if event_processor.is_some() {
unicode_scanner.enable_enhancement();
injection_scanner.enable_enhancement();
tracing::debug!("Scanner optimization enabled");
}
Ok(Self {
unicode_scanner,
injection_scanner,
xss_scanner,
crypto_scanner,
patterns,
config,
plugin_manager: None, #[cfg(feature = "enhanced")]
event_processor,
})
}
pub fn scan_text(&self, text: &str) -> ScanResult {
let mut threats = Vec::new();
let max_size = self
.config
.max_input_size
.unwrap_or(self.config.max_content_size);
if text.len() > max_size {
threats.push(Threat {
threat_type: ThreatType::DosPotential,
severity: Severity::High,
location: Location::Text {
offset: 0,
length: text.len(),
},
description: format!(
"Content size ({} bytes) exceeds maximum allowed size ({} bytes)",
text.len(),
max_size
),
remediation: Some(
"Reduce content size or increase max_content_size configuration".to_string(),
),
});
return Ok(threats);
}
const CHUNK_SIZE: usize = 1024 * 1024; const MAX_SCAN_TIME: std::time::Duration = std::time::Duration::from_secs(5);
if text.len() > CHUNK_SIZE {
return self.scan_text_chunked(text, CHUNK_SIZE, MAX_SCAN_TIME);
}
self.scan_text_regular(text)
}
fn scan_text_chunked(
&self,
text: &str,
chunk_size: usize,
max_scan_time: std::time::Duration,
) -> ScanResult {
let mut all_threats = Vec::new();
let scan_start = std::time::Instant::now();
for (chunk_offset, chunk) in text.as_bytes().chunks(chunk_size).enumerate() {
if scan_start.elapsed() > max_scan_time {
tracing::warn!(
"Scan timeout reached after {} seconds, processed {} bytes of {}",
max_scan_time.as_secs(),
chunk_offset * chunk_size,
text.len()
);
all_threats.push(Threat {
threat_type: ThreatType::DosPotential,
severity: Severity::Medium,
location: Location::Text {
offset: chunk_offset * chunk_size,
length: text.len() - (chunk_offset * chunk_size),
},
description: "Scan timeout - content too large to scan completely".to_string(),
remediation: Some(
"Consider reducing content size or increasing scan timeout".to_string(),
),
});
break;
}
let chunk_str = match std::str::from_utf8(chunk) {
Ok(s) => s,
Err(e) => {
let valid_up_to = e.valid_up_to();
if valid_up_to == 0 {
continue; }
match std::str::from_utf8(&chunk[..valid_up_to]) {
Ok(s) => s,
Err(_) => continue, }
},
};
let chunk_threats = self.scan_text_regular(chunk_str)?;
let byte_offset = chunk_offset * chunk_size;
for mut threat in chunk_threats {
if let Location::Text { ref mut offset, .. } = threat.location {
*offset += byte_offset;
}
all_threats.push(threat);
}
}
Ok(all_threats)
}
fn scan_text_regular(&self, text: &str) -> ScanResult {
let mut threats = Vec::new();
#[cfg(feature = "enhanced")]
if let Some(processor) = &self.event_processor {
let event = crate::traits::SecurityEvent {
event_type: "scan".to_string(),
client_id: "scanner".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
metadata: serde_json::json!({
"preview": &text[..text.len().min(100)]
}),
};
if let Ok(_handle) = tokio::runtime::Handle::try_current() {
let processor_clone = processor.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
if let Ok(rt) = rt {
let _ = rt.block_on(processor_clone.process_event(event));
}
})
.join()
.ok();
} else {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
if let Ok(rt) = rt {
let _ = rt.block_on(processor.process_event(event));
}
}
tracing::trace!("Optimized scanning active");
}
if self.config.unicode_detection {
threats.extend(self.unicode_scanner.scan_text(text)?);
}
if self.config.injection_detection || self.config.path_traversal_detection {
let injection_threats = self.injection_scanner.scan_text(text)?;
for threat in injection_threats {
match threat.threat_type {
ThreatType::PathTraversal => {
if self.config.path_traversal_detection {
threats.push(threat);
}
},
_ => {
if self.config.injection_detection {
threats.push(threat);
}
},
}
}
}
if self.config.crypto_detection {
threats.extend(self.crypto_scanner.scan_text(text)?);
}
if self.config.xss_detection.unwrap_or(true) {
let xss_threats = if let Ok(_handle) = tokio::runtime::Handle::try_current() {
let text_clone = text.to_string();
let xss_scanner = self.xss_scanner.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| {
ScanError::RuntimeError(format!("Failed to create runtime: {}", e))
})?;
rt.block_on(xss_scanner.scan_xss(&text_clone))
})
.join()
.map_err(|_| ScanError::RuntimeError("Thread panic".to_string()))??
} else {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| {
ScanError::RuntimeError(format!("Failed to create runtime: {}", e))
})?;
rt.block_on(self.xss_scanner.scan_xss(text))?
};
threats.extend(xss_threats);
}
if let Some(plugin_manager) = &self.plugin_manager {
if tokio::runtime::Handle::try_current().is_err() {
use crate::plugins::{ScanContext, ScanOptions};
use tokio::runtime::Runtime;
let context = ScanContext {
data: text.as_bytes(),
content_type: Some("text/plain"),
client_id: "scanner",
metadata: &std::collections::HashMap::new(),
options: ScanOptions::default(),
};
let rt = Runtime::new().map_err(|e| ScanError::InvalidInput(e.to_string()))?;
match rt.block_on(plugin_manager.scan_all(context)) {
Ok(plugin_results) => {
for (_plugin_id, plugin_threats) in plugin_results {
threats.extend(plugin_threats);
}
},
Err(e) => {
tracing::warn!("Plugin scan error: {}", e);
},
}
} else {
tracing::debug!("Plugin scanning skipped in async context");
}
}
#[cfg(feature = "enhanced")]
if !threats.is_empty() {
if let Some(processor) = &self.event_processor {
for threat in &threats {
let event = crate::traits::SecurityEvent {
event_type: "threat_detected".to_string(),
client_id: "scanner".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
metadata: serde_json::json!({
"threat_type": match &threat.threat_type {
ThreatType::Custom(name) => name.clone(),
_ => format!("{:?}", threat.threat_type),
},
"severity": format!("{:?}", threat.severity)
}),
};
if let Ok(_handle) = tokio::runtime::Handle::try_current() {
let processor_clone = processor.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
if let Ok(rt) = rt {
let _ = rt.block_on(processor_clone.process_event(event));
}
})
.join()
.ok();
} else {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
if let Ok(rt) = rt {
let _ = rt.block_on(processor.process_event(event));
}
}
}
}
}
Ok(threats)
}
pub fn scan_json(&self, value: &serde_json::Value) -> ScanResult {
let mut threats = self.scan_json_recursive(value, "$", 0)?;
if let Some(plugin_manager) = &self.plugin_manager {
if tokio::runtime::Handle::try_current().is_err() {
use crate::plugins::{ScanContext, ScanOptions};
use tokio::runtime::Runtime;
let json_bytes = serde_json::to_vec(value)
.map_err(|e| ScanError::InvalidInput(e.to_string()))?;
let context = ScanContext {
data: &json_bytes,
content_type: Some("application/json"),
client_id: "scanner",
metadata: &std::collections::HashMap::new(),
options: ScanOptions::default(),
};
let rt = Runtime::new().map_err(|e| ScanError::InvalidInput(e.to_string()))?;
match rt.block_on(plugin_manager.scan_all(context)) {
Ok(plugin_results) => {
for (_plugin_id, plugin_threats) in plugin_results {
for mut threat in plugin_threats {
if matches!(threat.location, Location::Text { .. }) {
threat.location = Location::Json {
path: "$".to_string(),
};
}
threats.push(threat);
}
}
},
Err(e) => {
tracing::warn!("Plugin scan error: {}", e);
},
}
} else {
tracing::debug!("Plugin scanning skipped in async context");
}
}
Ok(threats)
}
fn scan_json_recursive(
&self,
value: &serde_json::Value,
path: &str,
depth: usize,
) -> ScanResult {
if depth > self.config.max_scan_depth {
return Err(ScanError::MaxDepthExceeded);
}
let mut threats = Vec::new();
match value {
serde_json::Value::String(s) => {
let text_threats = self.scan_text(s)?;
for mut threat in text_threats {
threat.location = Location::Json {
path: path.to_string(),
};
threats.push(threat);
}
},
serde_json::Value::Object(map) => {
for (key, val) in map {
if let Ok(key_threats) = self.scan_text(key) {
for mut threat in key_threats {
threat.location = Location::Json {
path: format!("{path}.{key}"),
};
threats.push(threat);
}
}
let sub_path = format!("{path}.{key}");
threats.extend(self.scan_json_recursive(val, &sub_path, depth + 1)?);
}
},
serde_json::Value::Array(arr) => {
for (i, val) in arr.iter().enumerate() {
let sub_path = format!("{path}[{i}]");
threats.extend(self.scan_json_recursive(val, &sub_path, depth + 1)?);
}
},
_ => {}, }
Ok(threats)
}
pub fn stats(&self) -> ScannerStats {
#[cfg(feature = "enhanced")]
let mut stats = ScannerStats {
unicode_threats_detected: self.unicode_scanner.threats_detected(),
injection_threats_detected: self.injection_scanner.threats_detected(),
total_scans: self.unicode_scanner.total_scans() + self.injection_scanner.total_scans(),
};
#[cfg(not(feature = "enhanced"))]
let stats = ScannerStats {
unicode_threats_detected: self.unicode_scanner.threats_detected(),
injection_threats_detected: self.injection_scanner.threats_detected(),
total_scans: self.unicode_scanner.total_scans() + self.injection_scanner.total_scans(),
};
#[cfg(feature = "enhanced")]
if let Some(processor) = &self.event_processor {
let processor_stats = processor.get_stats();
stats.total_scans += processor_stats.events_processed / 10; tracing::trace!("Analytics enhanced");
}
stats
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScannerStats {
pub unicode_threats_detected: u64,
pub injection_threats_detected: u64,
pub total_scans: u64,
}
impl fmt::Display for ThreatType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnicodeInvisible => write!(f, "Invisible Unicode Character"),
Self::UnicodeBiDi => write!(f, "BiDi Text Spoofing"),
Self::UnicodeHomograph => write!(f, "Homograph Attack"),
Self::UnicodeControl => write!(f, "Dangerous Control Character"),
Self::PromptInjection => write!(f, "Prompt Injection"),
Self::CommandInjection => write!(f, "Command Injection"),
Self::PathTraversal => write!(f, "Path Traversal"),
Self::SqlInjection => write!(f, "SQL Injection"),
Self::CrossSiteScripting => write!(f, "Cross-Site Scripting"),
Self::LdapInjection => write!(f, "LDAP Injection"),
Self::XmlInjection => write!(f, "XML Injection/XXE"),
Self::NoSqlInjection => write!(f, "NoSQL Injection"),
Self::SessionIdExposure => write!(f, "Session ID Exposure"),
Self::ToolPoisoning => write!(f, "Tool Poisoning"),
Self::TokenTheft => write!(f, "Token Theft Risk"),
Self::DosPotential => write!(f, "Denial of Service Potential"),
Self::Custom(name) => write!(f, "{name}"),
}
}
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Low => write!(f, "Low"),
Self::Medium => write!(f, "Medium"),
Self::High => write!(f, "High"),
Self::Critical => write!(f, "Critical"),
}
}
}
impl fmt::Display for Threat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} [{}] at {}: {}",
self.threat_type, self.severity, self.location, self.description
)
}
}
impl fmt::Display for Location {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Text { offset, length } => write!(f, "offset {}, length {}", offset, length),
Self::Json { path } => write!(f, "JSON path '{}'", path),
Self::Binary { offset } => write!(f, "binary offset {}", offset),
}
}
}
pub fn create_security_scanner(
config: &crate::config::ScannerConfig,
) -> Arc<dyn crate::traits::SecurityScannerTrait> {
match SecurityScanner::new(config.clone()) {
Ok(scanner) => Arc::new(scanner),
Err(e) => {
tracing::error!("Failed to create security scanner: {}", e);
match SecurityScanner::new(Default::default()) {
Ok(scanner) => {
tracing::warn!("Created scanner with default configuration as fallback");
Arc::new(scanner)
},
Err(default_err) => {
tracing::error!(
"FATAL: Cannot create default scanner: {}. Using no-op scanner that denies all requests",
default_err
);
Arc::new(NoOpScanner::new())
},
}
},
}
}
impl crate::traits::SecurityScannerTrait for SecurityScanner {
fn scan_text(&self, text: &str) -> Vec<Threat> {
self.scan_text(text).unwrap_or_default()
}
fn scan_json(&self, value: &serde_json::Value) -> Vec<Threat> {
self.scan_json(value).unwrap_or_default()
}
fn scan_with_depth(&self, text: &str, _max_depth: usize) -> Vec<Threat> {
self.scan_text(text).unwrap_or_default()
}
fn get_stats(&self) -> crate::traits::ScannerStats {
crate::traits::ScannerStats {
texts_scanned: 0, threats_found: 0, unicode_threats: 0, injection_threats: 0, pattern_threats: 0, avg_scan_time_us: 0, }
}
fn reset_stats(&self) {
}
}
struct NoOpScanner;
impl NoOpScanner {
fn new() -> Self {
Self
}
}
impl crate::traits::SecurityScannerTrait for NoOpScanner {
fn scan_text(&self, text: &str) -> Vec<Threat> {
if !text.is_empty() {
vec![Threat {
threat_type: ThreatType::Custom("Scanner initialization failed".to_string()),
severity: Severity::Critical,
location: Location::Text {
offset: 0,
length: text.len(),
},
description:
"Security scanner failed to initialize. All requests denied for safety."
.to_string(),
remediation: Some(
"Contact system administrator to fix scanner initialization".to_string(),
),
}]
} else {
vec![]
}
}
fn scan_json(&self, value: &serde_json::Value) -> Vec<Threat> {
if !value.is_null() {
vec![Threat {
threat_type: ThreatType::Custom("Scanner initialization failed".to_string()),
severity: Severity::Critical,
location: Location::Json {
path: "$".to_string(),
},
description:
"Security scanner failed to initialize. All requests denied for safety."
.to_string(),
remediation: Some(
"Contact system administrator to fix scanner initialization".to_string(),
),
}]
} else {
vec![]
}
}
fn scan_with_depth(&self, text: &str, _max_depth: usize) -> Vec<Threat> {
self.scan_text(text)
}
fn get_stats(&self) -> crate::traits::ScannerStats {
crate::traits::ScannerStats {
texts_scanned: 0,
threats_found: 0,
unicode_threats: 0,
injection_threats: 0,
pattern_threats: 0,
avg_scan_time_us: 0,
}
}
fn reset_stats(&self) {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_threat_type_display() {
assert_eq!(
ThreatType::UnicodeInvisible.to_string(),
"Invisible Unicode Character"
);
assert_eq!(ThreatType::PromptInjection.to_string(), "Prompt Injection");
}
#[test]
fn test_severity_ordering() {
assert!(Severity::Low < Severity::Medium);
assert!(Severity::Medium < Severity::High);
assert!(Severity::High < Severity::Critical);
}
}