use crate::neutralizer::{NeutralizeAction, NeutralizeResult};
use crate::scanner::{Threat, ThreatType};
use anyhow::{ensure, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationConfig {
pub max_content_size: usize,
pub max_location_range: usize,
pub max_processing_time_ms: u64,
pub enforce_size_reduction: bool,
pub max_pattern_length: usize,
pub validate_threat_removed: bool,
pub allow_empty_output: bool,
pub max_extracted_params: usize,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
max_content_size: 10 * 1024 * 1024, max_location_range: 1024 * 1024, max_processing_time_ms: 5000, enforce_size_reduction: false,
max_pattern_length: 1000,
validate_threat_removed: true,
allow_empty_output: true,
max_extracted_params: 100,
}
}
}
pub struct NeutralizationValidator {
config: ValidationConfig,
}
impl NeutralizationValidator {
pub const fn new(config: ValidationConfig) -> Self {
Self { config }
}
pub fn validate_input(&self, threat: &Threat, content: &str) -> Result<()> {
ensure!(
content.len() <= self.config.max_content_size,
"Content size {} exceeds maximum allowed size of {} bytes",
content.len(),
self.config.max_content_size
);
match &threat.location {
crate::scanner::Location::Text { offset, length } => {
ensure!(
*offset < content.len(),
"Threat offset {} exceeds content length {}",
offset,
content.len()
);
ensure!(
offset + length <= content.len(),
"Threat range [{}, {}] exceeds content bounds",
offset,
offset + length
);
ensure!(
*length <= self.config.max_location_range,
"Threat range {} exceeds maximum allowed range {}",
length,
self.config.max_location_range
);
},
crate::scanner::Location::Json { path } => {
ensure!(
path.len() <= self.config.max_pattern_length,
"JSON path length {} exceeds maximum {}",
path.len(),
self.config.max_pattern_length
);
ensure!(
Self::is_valid_json_path(path),
"Invalid JSON path format: {}",
path
);
},
crate::scanner::Location::Binary { offset } => {
ensure!(
*offset < content.len(),
"Binary offset {} exceeds content length {}",
offset,
content.len()
);
},
}
ensure!(
!threat.description.is_empty(),
"Threat description cannot be empty"
);
ensure!(
threat.description.len() <= 1000,
"Threat description too long: {} chars",
threat.description.len()
);
Self::validate_content_safety(content)?;
Ok(())
}
pub fn validate_output(
&self,
threat: &Threat,
original: &str,
result: &NeutralizeResult,
) -> Result<()> {
ensure!(
result.processing_time_us <= self.config.max_processing_time_ms * 1000,
"Processing time {}μs exceeds maximum {}ms",
result.processing_time_us,
self.config.max_processing_time_ms
);
ensure!(
(0.0..=1.0).contains(&result.confidence_score),
"Confidence score {} out of valid range [0.0, 1.0]",
result.confidence_score
);
if let Some(ref sanitized) = result.sanitized_content {
if self.config.enforce_size_reduction {
ensure!(
sanitized.len() <= original.len(),
"Sanitized content ({} bytes) larger than original ({} bytes)",
sanitized.len(),
original.len()
);
}
if !self.config.allow_empty_output {
ensure!(
!sanitized.is_empty(),
"Empty output not allowed for threat type {:?}",
threat.threat_type
);
}
if self.config.validate_threat_removed {
self.validate_threat_neutralized(threat, sanitized)?;
}
Self::validate_content_safety(sanitized)?;
}
match result.action_taken {
NeutralizeAction::NoAction => {
ensure!(
result.sanitized_content.is_none(),
"NoAction should not produce sanitized content"
);
},
NeutralizeAction::Removed => {
if let Some(ref content) = result.sanitized_content {
ensure!(
content.is_empty() || content.len() < original.len(),
"Removed action should reduce content size"
);
}
},
_ => {
ensure!(
result.sanitized_content.is_some(),
"Action {:?} should produce sanitized content",
result.action_taken
);
},
}
if let Some(ref params) = result.extracted_params {
ensure!(
params.len() <= self.config.max_extracted_params,
"Too many extracted parameters: {} (max: {})",
params.len(),
self.config.max_extracted_params
);
for param in params {
ensure!(
param.len() <= 1000,
"Extracted parameter too long: {} chars",
param.len()
);
}
}
Ok(())
}
fn validate_content_safety(content: &str) -> Result<()> {
ensure!(!content.contains('\0'), "Content contains null bytes");
let control_char_count = content
.chars()
.filter(|c| c.is_control() && !c.is_whitespace())
.count();
ensure!(
control_char_count <= content.len() / 100, "Content contains too many control characters: {}",
control_char_count
);
Ok(())
}
fn is_valid_json_path(path: &str) -> bool {
!path.is_empty()
&& !path.contains('\0')
&& !path.contains("..")
&& path
.chars()
.all(|c| c.is_ascii() || c.is_alphanumeric() || "$.[]._-".contains(c))
}
fn validate_threat_neutralized(&self, threat: &Threat, sanitized: &str) -> Result<()> {
match &threat.threat_type {
ThreatType::UnicodeInvisible => {
ensure!(
!Self::contains_invisible_unicode(sanitized),
"Sanitized content still contains invisible unicode"
);
},
ThreatType::UnicodeBiDi => {
ensure!(
!Self::contains_bidi_chars(sanitized),
"Sanitized content still contains BiDi override characters"
);
},
ThreatType::SqlInjection => {
ensure!(
!Self::contains_unsafe_sql(sanitized),
"Sanitized content may still contain SQL injection"
);
},
ThreatType::PathTraversal => {
ensure!(
!sanitized.contains("..") && !sanitized.contains('~'),
"Sanitized content still contains path traversal patterns"
);
},
_ => {
},
}
Ok(())
}
fn contains_invisible_unicode(text: &str) -> bool {
text.chars().any(|c| {
matches!(c,
'\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' )
})
}
fn contains_bidi_chars(text: &str) -> bool {
text.chars()
.any(|c| matches!(c, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'))
}
fn contains_unsafe_sql(text: &str) -> bool {
let dangerous_patterns = [
"' OR '",
"'; DROP",
"'; DELETE",
"UNION SELECT",
"/*",
"*/",
"--",
];
let text_upper = text.to_uppercase();
dangerous_patterns
.iter()
.any(|pattern| text_upper.contains(pattern))
}
}
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("Content too large: {size} bytes (max: {max})")]
ContentTooLarge { size: usize, max: usize },
#[error("Invalid threat location: {0}")]
InvalidLocation(String),
#[error("Processing timeout: {duration_ms}ms (max: {max_ms}ms)")]
ProcessingTimeout { duration_ms: u64, max_ms: u64 },
#[error("Invalid output: {0}")]
InvalidOutput(String),
#[error("Threat not neutralized: {0}")]
ThreatNotNeutralized(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::Location;
#[test]
fn test_input_validation() {
let validator = NeutralizationValidator::new(ValidationConfig::default());
let threat = Threat {
threat_type: ThreatType::SqlInjection,
severity: crate::scanner::Severity::High,
location: Location::Text {
offset: 0,
length: 10,
},
description: "SQL injection detected".to_string(),
remediation: None,
};
assert!(validator
.validate_input(&threat, "SELECT * FROM users")
.is_ok());
let bad_threat = Threat {
location: Location::Text {
offset: 100,
length: 10,
},
..threat.clone()
};
assert!(validator.validate_input(&bad_threat, "short").is_err());
}
#[test]
fn test_output_validation() {
let validator = NeutralizationValidator::new(ValidationConfig::default());
let threat = Threat {
threat_type: ThreatType::UnicodeInvisible,
severity: crate::scanner::Severity::High,
location: Location::Text {
offset: 5,
length: 1,
},
description: "Invisible unicode detected".to_string(),
remediation: None,
};
let result = NeutralizeResult {
action_taken: NeutralizeAction::Removed,
sanitized_content: Some("Hello World".to_string()),
confidence_score: 0.95,
processing_time_us: 1000,
correlation_data: None,
extracted_params: None,
};
assert!(validator
.validate_output(&threat, "Hello\u{200B}World", &result)
.is_ok());
}
}