kindly_guard_server/neutralizer/
validation.rs1use crate::neutralizer::{NeutralizeAction, NeutralizeResult};
20use crate::scanner::{Threat, ThreatType};
21use anyhow::{ensure, Result};
22use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ValidationConfig {
27 pub max_content_size: usize,
29
30 pub max_location_range: usize,
32
33 pub max_processing_time_ms: u64,
35
36 pub enforce_size_reduction: bool,
38
39 pub max_pattern_length: usize,
41
42 pub validate_threat_removed: bool,
44
45 pub allow_empty_output: bool,
47
48 pub max_extracted_params: usize,
50}
51
52impl Default for ValidationConfig {
53 fn default() -> Self {
54 Self {
55 max_content_size: 10 * 1024 * 1024, max_location_range: 1024 * 1024, max_processing_time_ms: 5000, enforce_size_reduction: false,
59 max_pattern_length: 1000,
60 validate_threat_removed: true,
61 allow_empty_output: true,
62 max_extracted_params: 100,
63 }
64 }
65}
66
67pub struct NeutralizationValidator {
69 config: ValidationConfig,
70}
71
72impl NeutralizationValidator {
73 pub const fn new(config: ValidationConfig) -> Self {
74 Self { config }
75 }
76
77 pub fn validate_input(&self, threat: &Threat, content: &str) -> Result<()> {
79 ensure!(
81 content.len() <= self.config.max_content_size,
82 "Content size {} exceeds maximum allowed size of {} bytes",
83 content.len(),
84 self.config.max_content_size
85 );
86
87 match &threat.location {
89 crate::scanner::Location::Text { offset, length } => {
90 ensure!(
91 *offset < content.len(),
92 "Threat offset {} exceeds content length {}",
93 offset,
94 content.len()
95 );
96
97 ensure!(
98 offset + length <= content.len(),
99 "Threat range [{}, {}] exceeds content bounds",
100 offset,
101 offset + length
102 );
103
104 ensure!(
105 *length <= self.config.max_location_range,
106 "Threat range {} exceeds maximum allowed range {}",
107 length,
108 self.config.max_location_range
109 );
110 },
111 crate::scanner::Location::Json { path } => {
112 ensure!(
113 path.len() <= self.config.max_pattern_length,
114 "JSON path length {} exceeds maximum {}",
115 path.len(),
116 self.config.max_pattern_length
117 );
118
119 ensure!(
121 Self::is_valid_json_path(path),
122 "Invalid JSON path format: {}",
123 path
124 );
125 },
126 crate::scanner::Location::Binary { offset } => {
127 ensure!(
128 *offset < content.len(),
129 "Binary offset {} exceeds content length {}",
130 offset,
131 content.len()
132 );
133 },
134 }
135
136 ensure!(
138 !threat.description.is_empty(),
139 "Threat description cannot be empty"
140 );
141
142 ensure!(
143 threat.description.len() <= 1000,
144 "Threat description too long: {} chars",
145 threat.description.len()
146 );
147
148 Self::validate_content_safety(content)?;
151
152 Ok(())
153 }
154
155 pub fn validate_output(
157 &self,
158 threat: &Threat,
159 original: &str,
160 result: &NeutralizeResult,
161 ) -> Result<()> {
162 ensure!(
164 result.processing_time_us <= self.config.max_processing_time_ms * 1000,
165 "Processing time {}μs exceeds maximum {}ms",
166 result.processing_time_us,
167 self.config.max_processing_time_ms
168 );
169
170 ensure!(
172 (0.0..=1.0).contains(&result.confidence_score),
173 "Confidence score {} out of valid range [0.0, 1.0]",
174 result.confidence_score
175 );
176
177 if let Some(ref sanitized) = result.sanitized_content {
179 if self.config.enforce_size_reduction {
181 ensure!(
182 sanitized.len() <= original.len(),
183 "Sanitized content ({} bytes) larger than original ({} bytes)",
184 sanitized.len(),
185 original.len()
186 );
187 }
188
189 if !self.config.allow_empty_output {
191 ensure!(
192 !sanitized.is_empty(),
193 "Empty output not allowed for threat type {:?}",
194 threat.threat_type
195 );
196 }
197
198 if self.config.validate_threat_removed {
200 self.validate_threat_neutralized(threat, sanitized)?;
201 }
202
203 Self::validate_content_safety(sanitized)?;
205 }
206
207 match result.action_taken {
209 NeutralizeAction::NoAction => {
210 ensure!(
211 result.sanitized_content.is_none(),
212 "NoAction should not produce sanitized content"
213 );
214 },
215 NeutralizeAction::Removed => {
216 if let Some(ref content) = result.sanitized_content {
217 ensure!(
218 content.is_empty() || content.len() < original.len(),
219 "Removed action should reduce content size"
220 );
221 }
222 },
223 _ => {
224 ensure!(
226 result.sanitized_content.is_some(),
227 "Action {:?} should produce sanitized content",
228 result.action_taken
229 );
230 },
231 }
232
233 if let Some(ref params) = result.extracted_params {
235 ensure!(
236 params.len() <= self.config.max_extracted_params,
237 "Too many extracted parameters: {} (max: {})",
238 params.len(),
239 self.config.max_extracted_params
240 );
241
242 for param in params {
244 ensure!(
245 param.len() <= 1000,
246 "Extracted parameter too long: {} chars",
247 param.len()
248 );
249 }
250 }
251
252 Ok(())
253 }
254
255 fn validate_content_safety(content: &str) -> Result<()> {
257 ensure!(!content.contains('\0'), "Content contains null bytes");
259
260 let control_char_count = content
262 .chars()
263 .filter(|c| c.is_control() && !c.is_whitespace())
264 .count();
265
266 ensure!(
267 control_char_count <= content.len() / 100, "Content contains too many control characters: {}",
269 control_char_count
270 );
271
272 Ok(())
273 }
274
275 fn is_valid_json_path(path: &str) -> bool {
277 !path.is_empty()
279 && !path.contains('\0')
280 && !path.contains("..")
281 && path
282 .chars()
283 .all(|c| c.is_ascii() || c.is_alphanumeric() || "$.[]._-".contains(c))
284 }
285
286 fn validate_threat_neutralized(&self, threat: &Threat, sanitized: &str) -> Result<()> {
288 match &threat.threat_type {
289 ThreatType::UnicodeInvisible => {
290 ensure!(
292 !Self::contains_invisible_unicode(sanitized),
293 "Sanitized content still contains invisible unicode"
294 );
295 },
296 ThreatType::UnicodeBiDi => {
297 ensure!(
299 !Self::contains_bidi_chars(sanitized),
300 "Sanitized content still contains BiDi override characters"
301 );
302 },
303 ThreatType::SqlInjection => {
304 ensure!(
306 !Self::contains_unsafe_sql(sanitized),
307 "Sanitized content may still contain SQL injection"
308 );
309 },
310 ThreatType::PathTraversal => {
311 ensure!(
313 !sanitized.contains("..") && !sanitized.contains('~'),
314 "Sanitized content still contains path traversal patterns"
315 );
316 },
317 _ => {
318 },
321 }
322
323 Ok(())
324 }
325
326 fn contains_invisible_unicode(text: &str) -> bool {
328 text.chars().any(|c| {
329 matches!(c,
330 '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' )
334 })
335 }
336
337 fn contains_bidi_chars(text: &str) -> bool {
339 text.chars()
340 .any(|c| matches!(c, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'))
341 }
342
343 fn contains_unsafe_sql(text: &str) -> bool {
345 let dangerous_patterns = [
347 "' OR '",
348 "'; DROP",
349 "'; DELETE",
350 "UNION SELECT",
351 "/*",
352 "*/",
353 "--",
354 ];
355
356 let text_upper = text.to_uppercase();
357 dangerous_patterns
358 .iter()
359 .any(|pattern| text_upper.contains(pattern))
360 }
361}
362
363#[derive(Debug, thiserror::Error)]
365pub enum ValidationError {
366 #[error("Content too large: {size} bytes (max: {max})")]
367 ContentTooLarge { size: usize, max: usize },
368
369 #[error("Invalid threat location: {0}")]
370 InvalidLocation(String),
371
372 #[error("Processing timeout: {duration_ms}ms (max: {max_ms}ms)")]
373 ProcessingTimeout { duration_ms: u64, max_ms: u64 },
374
375 #[error("Invalid output: {0}")]
376 InvalidOutput(String),
377
378 #[error("Threat not neutralized: {0}")]
379 ThreatNotNeutralized(String),
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use crate::scanner::Location;
386
387 #[test]
388 fn test_input_validation() {
389 let validator = NeutralizationValidator::new(ValidationConfig::default());
390
391 let threat = Threat {
393 threat_type: ThreatType::SqlInjection,
394 severity: crate::scanner::Severity::High,
395 location: Location::Text {
396 offset: 0,
397 length: 10,
398 },
399 description: "SQL injection detected".to_string(),
400 remediation: None,
401 };
402
403 assert!(validator
404 .validate_input(&threat, "SELECT * FROM users")
405 .is_ok());
406
407 let bad_threat = Threat {
409 location: Location::Text {
410 offset: 100,
411 length: 10,
412 },
413 ..threat.clone()
414 };
415
416 assert!(validator.validate_input(&bad_threat, "short").is_err());
417 }
418
419 #[test]
420 fn test_output_validation() {
421 let validator = NeutralizationValidator::new(ValidationConfig::default());
422
423 let threat = Threat {
424 threat_type: ThreatType::UnicodeInvisible,
425 severity: crate::scanner::Severity::High,
426 location: Location::Text {
427 offset: 5,
428 length: 1,
429 },
430 description: "Invisible unicode detected".to_string(),
431 remediation: None,
432 };
433
434 let result = NeutralizeResult {
435 action_taken: NeutralizeAction::Removed,
436 sanitized_content: Some("Hello World".to_string()),
437 confidence_score: 0.95,
438 processing_time_us: 1000,
439 correlation_data: None,
440 extracted_params: None,
441 };
442
443 assert!(validator
444 .validate_output(&threat, "Hello\u{200B}World", &result)
445 .is_ok());
446 }
447}