Skip to main content

kindly_guard_server/neutralizer/
validation.rs

1// Copyright 2025 Kindly Software Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//! Neutralization validation for production safety
15//!
16//! Provides comprehensive input/output validation to ensure neutralization
17//! operations are safe, bounded, and produce valid results.
18
19use crate::neutralizer::{NeutralizeAction, NeutralizeResult};
20use crate::scanner::{Threat, ThreatType};
21use anyhow::{ensure, Result};
22use serde::{Deserialize, Serialize};
23
24/// Validation configuration
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ValidationConfig {
27    /// Maximum content size in bytes (default: 10MB)
28    pub max_content_size: usize,
29
30    /// Maximum threat location range (default: 1MB)
31    pub max_location_range: usize,
32
33    /// Maximum processing time in milliseconds (default: 5000ms)
34    pub max_processing_time_ms: u64,
35
36    /// Require output to be smaller than input
37    pub enforce_size_reduction: bool,
38
39    /// Maximum regex pattern length for injection threats
40    pub max_pattern_length: usize,
41
42    /// Validate output doesn't contain original threat
43    pub validate_threat_removed: bool,
44
45    /// Allow empty output
46    pub allow_empty_output: bool,
47
48    /// Maximum number of parameters extracted
49    pub max_extracted_params: usize,
50}
51
52impl Default for ValidationConfig {
53    fn default() -> Self {
54        Self {
55            max_content_size: 10 * 1024 * 1024, // 10MB
56            max_location_range: 1024 * 1024,    // 1MB
57            max_processing_time_ms: 5000,       // 5 seconds
58            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
67/// Input validator for neutralization
68pub struct NeutralizationValidator {
69    config: ValidationConfig,
70}
71
72impl NeutralizationValidator {
73    pub const fn new(config: ValidationConfig) -> Self {
74        Self { config }
75    }
76
77    /// Validate input before neutralization
78    pub fn validate_input(&self, threat: &Threat, content: &str) -> Result<()> {
79        // Check content size
80        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        // Validate threat location
88        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                // Validate JSON path format
120                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        // Validate threat description
137        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        // Validate content is valid UTF-8 (already guaranteed by &str)
149        // But check for specific dangerous patterns
150        Self::validate_content_safety(content)?;
151
152        Ok(())
153    }
154
155    /// Validate output after neutralization
156    pub fn validate_output(
157        &self,
158        threat: &Threat,
159        original: &str,
160        result: &NeutralizeResult,
161    ) -> Result<()> {
162        // Validate processing time
163        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        // Validate confidence score
171        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        // Validate sanitized content if present
178        if let Some(ref sanitized) = result.sanitized_content {
179            // Check size constraints
180            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            // Check empty output
190            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            // Validate threat was actually removed
199            if self.config.validate_threat_removed {
200                self.validate_threat_neutralized(threat, sanitized)?;
201            }
202
203            // Validate output is safe
204            Self::validate_content_safety(sanitized)?;
205        }
206
207        // Validate action consistency
208        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                // Other actions should produce output
225                ensure!(
226                    result.sanitized_content.is_some(),
227                    "Action {:?} should produce sanitized content",
228                    result.action_taken
229                );
230            },
231        }
232
233        // Validate extracted parameters
234        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            // Validate each parameter
243            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    /// Validate content doesn't contain dangerous patterns
256    fn validate_content_safety(content: &str) -> Result<()> {
257        // Check for null bytes
258        ensure!(!content.contains('\0'), "Content contains null bytes");
259
260        // Check for excessive control characters
261        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, // Max 1% control chars
268            "Content contains too many control characters: {}",
269            control_char_count
270        );
271
272        Ok(())
273    }
274
275    /// Validate JSON path format
276    fn is_valid_json_path(path: &str) -> bool {
277        // Simple validation - can be enhanced
278        !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    /// Validate threat was neutralized in output
287    fn validate_threat_neutralized(&self, threat: &Threat, sanitized: &str) -> Result<()> {
288        match &threat.threat_type {
289            ThreatType::UnicodeInvisible => {
290                // Check no invisible unicode remains
291                ensure!(
292                    !Self::contains_invisible_unicode(sanitized),
293                    "Sanitized content still contains invisible unicode"
294                );
295            },
296            ThreatType::UnicodeBiDi => {
297                // Check no BiDi characters remain
298                ensure!(
299                    !Self::contains_bidi_chars(sanitized),
300                    "Sanitized content still contains BiDi override characters"
301                );
302            },
303            ThreatType::SqlInjection => {
304                // Basic check - no raw SQL keywords in unsafe context
305                ensure!(
306                    !Self::contains_unsafe_sql(sanitized),
307                    "Sanitized content may still contain SQL injection"
308                );
309            },
310            ThreatType::PathTraversal => {
311                // Check no path traversal patterns
312                ensure!(
313                    !sanitized.contains("..") && !sanitized.contains('~'),
314                    "Sanitized content still contains path traversal patterns"
315                );
316            },
317            _ => {
318                // For other types, trust the neutralizer
319                // Could add more specific checks
320            },
321        }
322
323        Ok(())
324    }
325
326    /// Check for invisible unicode characters
327    fn contains_invisible_unicode(text: &str) -> bool {
328        text.chars().any(|c| {
329            matches!(c,
330                '\u{200B}'..='\u{200F}' | // Zero-width spaces
331                '\u{202A}'..='\u{202E}' | // BiDi overrides
332                '\u{2060}'..='\u{206F}'   // Other invisible
333            )
334        })
335    }
336
337    /// Check for `BiDi` override characters
338    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    /// Check for potentially unsafe SQL
344    fn contains_unsafe_sql(text: &str) -> bool {
345        // Very basic check - would need enhancement for production
346        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/// Validation errors for specific failure types
364#[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        // Valid input
392        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        // Invalid offset
408        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}