1use super::categories::{OwaspCategory, Severity};
7use super::payloads::ExpectedBehavior;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ValidationResult {
14 pub vulnerable: bool,
16 pub category: OwaspCategory,
18 pub description: String,
20 pub confidence: Confidence,
22 #[serde(default)]
24 pub details: HashMap<String, String>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Confidence {
31 High,
33 Medium,
35 Low,
37}
38
39#[derive(Debug, Clone)]
41pub struct ResponseData {
42 pub status: u16,
44 pub headers: HashMap<String, String>,
46 pub body: String,
48 pub response_time_ms: u64,
50}
51
52#[derive(Debug, Clone)]
54pub struct BaselineResponse {
55 pub status: u16,
57 pub body: String,
59 pub response_time_ms: u64,
61}
62
63pub struct OwaspValidator {
65 required_headers: Vec<(&'static str, Option<&'static str>)>,
67 error_patterns: Vec<&'static str>,
69 auth_bypass_patterns: Vec<&'static str>,
71}
72
73impl Default for OwaspValidator {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl OwaspValidator {
80 pub fn new() -> Self {
82 Self {
83 required_headers: vec![
84 ("x-content-type-options", Some("nosniff")),
85 ("x-frame-options", None), ("strict-transport-security", None),
87 ("content-security-policy", None),
88 ("x-xss-protection", None),
89 ],
90 error_patterns: vec![
91 "stack trace",
92 "stacktrace",
93 "at line",
94 "syntax error",
95 "undefined variable",
96 "undefined method",
97 "null pointer",
98 "nullpointerexception",
99 "segmentation fault",
100 "internal server error",
101 "debug mode",
102 "DEBUG=",
103 "password=",
104 "secret=",
105 "api_key=",
106 "connection string",
107 "jdbc:",
108 "mysql:",
109 "postgres:",
110 "mongodb:",
111 "redis:",
112 "Exception in thread",
113 "Traceback (most recent call last)",
114 "File \"",
115 ".java:",
116 ".py:",
117 ".js:",
118 ".rb:",
119 ".go:",
120 "at Object.",
121 "at Module.",
122 ],
123 auth_bypass_patterns: vec![
124 "\"authenticated\":true",
125 "\"authenticated\": true",
126 "\"logged_in\":true",
127 "\"logged_in\": true",
128 "\"success\":true",
129 "\"success\": true",
130 "\"authorized\":true",
131 "\"authorized\": true",
132 "welcome",
133 "dashboard",
134 "profile",
135 ],
136 }
137 }
138
139 pub fn validate(
141 &self,
142 category: OwaspCategory,
143 response: &ResponseData,
144 expected: &ExpectedBehavior,
145 baseline: Option<&BaselineResponse>,
146 ) -> ValidationResult {
147 match expected {
148 ExpectedBehavior::SuccessWhenShouldFail => {
149 self.check_success_when_should_fail(category, response)
150 }
151 ExpectedBehavior::UnauthorizedDataAccess => {
152 self.check_unauthorized_access(category, response, baseline)
153 }
154 ExpectedBehavior::FieldAccepted => {
155 self.check_field_accepted(category, response, baseline)
156 }
157 ExpectedBehavior::NoRateLimiting => self.check_no_rate_limiting(category, response),
158 ExpectedBehavior::InternalDataExposure => {
159 self.check_internal_exposure(category, response)
160 }
161 ExpectedBehavior::EndpointExists => self.check_endpoint_exists(category, response),
162 ExpectedBehavior::MissingSecurityHeaders => {
163 self.check_missing_headers(category, response)
164 }
165 ExpectedBehavior::VerboseErrors => self.check_verbose_errors(category, response),
166 ExpectedBehavior::Custom(desc) => self.check_custom(category, response, desc, baseline),
167 }
168 }
169
170 fn check_success_when_should_fail(
172 &self,
173 category: OwaspCategory,
174 response: &ResponseData,
175 ) -> ValidationResult {
176 let is_success = (200..300).contains(&response.status);
177
178 if is_success {
179 let body_lower = response.body.to_lowercase();
181 let has_bypass_indicator =
182 self.auth_bypass_patterns.iter().any(|p| body_lower.contains(&p.to_lowercase()));
183
184 ValidationResult {
185 vulnerable: true,
186 category,
187 description: if has_bypass_indicator {
188 format!(
189 "Request succeeded (HTTP {}) with authentication bypass indicators",
190 response.status
191 )
192 } else {
193 format!(
194 "Request succeeded (HTTP {}) when it should have been rejected",
195 response.status
196 )
197 },
198 confidence: if has_bypass_indicator {
199 Confidence::High
200 } else {
201 Confidence::Medium
202 },
203 details: HashMap::new(),
204 }
205 } else {
206 ValidationResult {
207 vulnerable: false,
208 category,
209 description: format!("Request properly rejected (HTTP {})", response.status),
210 confidence: Confidence::High,
211 details: HashMap::new(),
212 }
213 }
214 }
215
216 fn check_unauthorized_access(
218 &self,
219 category: OwaspCategory,
220 response: &ResponseData,
221 baseline: Option<&BaselineResponse>,
222 ) -> ValidationResult {
223 let is_success = (200..300).contains(&response.status);
224
225 if !is_success {
226 return ValidationResult {
227 vulnerable: false,
228 category,
229 description: format!("Access denied (HTTP {})", response.status),
230 confidence: Confidence::High,
231 details: HashMap::new(),
232 };
233 }
234
235 if let Some(baseline) = baseline {
237 if response.body != baseline.body && !response.body.is_empty() {
238 return ValidationResult {
240 vulnerable: true,
241 category,
242 description: "Accessed different user's data by manipulating resource ID"
243 .to_string(),
244 confidence: Confidence::High,
245 details: {
246 let mut d = HashMap::new();
247 d.insert("baseline_length".to_string(), baseline.body.len().to_string());
248 d.insert("response_length".to_string(), response.body.len().to_string());
249 d
250 },
251 };
252 }
253 }
254
255 if !response.body.is_empty() {
257 ValidationResult {
258 vulnerable: true,
259 category,
260 description: format!(
261 "Resource accessed with manipulated ID (HTTP {})",
262 response.status
263 ),
264 confidence: Confidence::Medium,
265 details: HashMap::new(),
266 }
267 } else {
268 ValidationResult {
269 vulnerable: false,
270 category,
271 description: "No data returned".to_string(),
272 confidence: Confidence::Medium,
273 details: HashMap::new(),
274 }
275 }
276 }
277
278 fn check_field_accepted(
280 &self,
281 category: OwaspCategory,
282 response: &ResponseData,
283 baseline: Option<&BaselineResponse>,
284 ) -> ValidationResult {
285 let is_success = (200..300).contains(&response.status);
286
287 if !is_success {
288 return ValidationResult {
289 vulnerable: false,
290 category,
291 description: format!("Field rejected (HTTP {})", response.status),
292 confidence: Confidence::High,
293 details: HashMap::new(),
294 };
295 }
296
297 let body_lower = response.body.to_lowercase();
299
300 let privilege_indicators = [
302 "\"role\":\"admin\"",
303 "\"role\": \"admin\"",
304 "\"is_admin\":true",
305 "\"is_admin\": true",
306 "\"isadmin\":true",
307 "\"isadmin\": true",
308 "\"verified\":true",
309 "\"verified\": true",
310 "\"permissions\":",
311 "\"balance\":",
312 "\"credits\":",
313 ];
314
315 let has_indicator =
316 privilege_indicators.iter().any(|p| body_lower.contains(&p.to_lowercase()));
317
318 if has_indicator {
319 ValidationResult {
320 vulnerable: true,
321 category,
322 description: "Unauthorized field was accepted and reflected in response"
323 .to_string(),
324 confidence: Confidence::High,
325 details: HashMap::new(),
326 }
327 } else if let Some(baseline) = baseline {
328 if response.body != baseline.body {
330 ValidationResult {
331 vulnerable: true,
332 category,
333 description: "Response differs after injecting unauthorized fields".to_string(),
334 confidence: Confidence::Medium,
335 details: HashMap::new(),
336 }
337 } else {
338 ValidationResult {
339 vulnerable: false,
340 category,
341 description: "Field appears to have been ignored".to_string(),
342 confidence: Confidence::Medium,
343 details: HashMap::new(),
344 }
345 }
346 } else {
347 ValidationResult {
348 vulnerable: true,
349 category,
350 description: "Request accepted, field may have been processed".to_string(),
351 confidence: Confidence::Low,
352 details: HashMap::new(),
353 }
354 }
355 }
356
357 fn check_no_rate_limiting(
359 &self,
360 category: OwaspCategory,
361 response: &ResponseData,
362 ) -> ValidationResult {
363 let rate_limit_headers = [
365 "x-ratelimit-limit",
366 "x-ratelimit-remaining",
367 "x-rate-limit-limit",
368 "x-rate-limit-remaining",
369 "ratelimit-limit",
370 "ratelimit-remaining",
371 "retry-after",
372 ];
373
374 let headers_lower: HashMap<String, String> =
375 response.headers.iter().map(|(k, v)| (k.to_lowercase(), v.clone())).collect();
376
377 let has_rate_limit_headers =
378 rate_limit_headers.iter().any(|h| headers_lower.contains_key(*h));
379
380 if response.status == 429 {
382 return ValidationResult {
383 vulnerable: false,
384 category,
385 description: "Rate limiting is active (HTTP 429)".to_string(),
386 confidence: Confidence::High,
387 details: HashMap::new(),
388 };
389 }
390
391 if (200..300).contains(&response.status) && !has_rate_limit_headers {
393 ValidationResult {
394 vulnerable: true,
395 category,
396 description: "No rate limiting detected - request succeeded without limits"
397 .to_string(),
398 confidence: Confidence::Medium,
399 details: HashMap::new(),
400 }
401 } else if has_rate_limit_headers {
402 ValidationResult {
403 vulnerable: false,
404 category,
405 description: "Rate limit headers present".to_string(),
406 confidence: Confidence::High,
407 details: HashMap::new(),
408 }
409 } else {
410 ValidationResult {
411 vulnerable: false,
412 category,
413 description: format!("Request returned HTTP {}", response.status),
414 confidence: Confidence::Medium,
415 details: HashMap::new(),
416 }
417 }
418 }
419
420 fn check_internal_exposure(
422 &self,
423 category: OwaspCategory,
424 response: &ResponseData,
425 ) -> ValidationResult {
426 let body_lower = response.body.to_lowercase();
427
428 let exposure_indicators = [
430 "instance-id",
431 "ami-id",
432 "instance-type",
433 "local-hostname",
434 "public-hostname",
435 "iam/",
436 "security-credentials",
437 "access-key",
438 "secret-key",
439 "token",
440 "root:",
441 "/bin/bash",
442 "/bin/sh",
443 "127.0.0.1",
444 "localhost",
445 "internal",
446 "private",
447 "metadata",
448 "computemetadata",
449 ];
450
451 let has_exposure = exposure_indicators.iter().any(|p| body_lower.contains(*p));
452
453 let is_success = (200..300).contains(&response.status);
455
456 if is_success && has_exposure {
457 ValidationResult {
458 vulnerable: true,
459 category,
460 description: "Internal data or metadata exposed through SSRF".to_string(),
461 confidence: Confidence::High,
462 details: HashMap::new(),
463 }
464 } else if is_success && !response.body.is_empty() {
465 ValidationResult {
466 vulnerable: true,
467 category,
468 description: "Response received from internal URL - potential SSRF".to_string(),
469 confidence: Confidence::Medium,
470 details: HashMap::new(),
471 }
472 } else {
473 ValidationResult {
474 vulnerable: false,
475 category,
476 description: "Internal URL request blocked or failed".to_string(),
477 confidence: Confidence::High,
478 details: HashMap::new(),
479 }
480 }
481 }
482
483 fn check_endpoint_exists(
485 &self,
486 category: OwaspCategory,
487 response: &ResponseData,
488 ) -> ValidationResult {
489 match response.status {
493 404 => ValidationResult {
494 vulnerable: false,
495 category,
496 description: "Endpoint not found (HTTP 404)".to_string(),
497 confidence: Confidence::High,
498 details: HashMap::new(),
499 },
500 401 | 403 => ValidationResult {
501 vulnerable: true,
502 category,
503 description: format!(
504 "Undocumented endpoint exists but is protected (HTTP {})",
505 response.status
506 ),
507 confidence: Confidence::Medium,
508 details: HashMap::new(),
509 },
510 _ if (200..300).contains(&response.status) => ValidationResult {
511 vulnerable: true,
512 category,
513 description: format!(
514 "Undocumented endpoint exists and is accessible (HTTP {})",
515 response.status
516 ),
517 confidence: Confidence::High,
518 details: HashMap::new(),
519 },
520 _ => ValidationResult {
521 vulnerable: false,
522 category,
523 description: format!("Endpoint returned HTTP {}", response.status),
524 confidence: Confidence::Medium,
525 details: HashMap::new(),
526 },
527 }
528 }
529
530 fn check_missing_headers(
532 &self,
533 category: OwaspCategory,
534 response: &ResponseData,
535 ) -> ValidationResult {
536 let headers_lower: HashMap<String, String> =
537 response.headers.iter().map(|(k, v)| (k.to_lowercase(), v.clone())).collect();
538
539 let mut missing = Vec::new();
540 let mut misconfigured = Vec::new();
541
542 for (header, expected_value) in &self.required_headers {
543 if let Some(actual) = headers_lower.get(*header) {
544 if let Some(expected) = expected_value {
546 if !actual.to_lowercase().contains(&expected.to_lowercase()) {
547 misconfigured
548 .push(format!("{}: {} (expected {})", header, actual, expected));
549 }
550 }
551 } else {
552 missing.push(header.to_string());
553 }
554 }
555
556 if let Some(acao) = headers_lower.get("access-control-allow-origin") {
558 if acao == "*" {
559 misconfigured.push("access-control-allow-origin: * (wildcard)".to_string());
560 }
561 }
562
563 if !missing.is_empty() || !misconfigured.is_empty() {
564 let mut details = HashMap::new();
565 if !missing.is_empty() {
566 details.insert("missing_headers".to_string(), missing.join(", "));
567 }
568 if !misconfigured.is_empty() {
569 details.insert("misconfigured_headers".to_string(), misconfigured.join("; "));
570 }
571
572 ValidationResult {
573 vulnerable: true,
574 category,
575 description: format!(
576 "Security headers missing or misconfigured: {} missing, {} misconfigured",
577 missing.len(),
578 misconfigured.len()
579 ),
580 confidence: Confidence::High,
581 details,
582 }
583 } else {
584 ValidationResult {
585 vulnerable: false,
586 category,
587 description: "All required security headers present".to_string(),
588 confidence: Confidence::High,
589 details: HashMap::new(),
590 }
591 }
592 }
593
594 fn check_verbose_errors(
596 &self,
597 category: OwaspCategory,
598 response: &ResponseData,
599 ) -> ValidationResult {
600 let body_lower = response.body.to_lowercase();
601
602 let found_patterns: Vec<&str> = self
603 .error_patterns
604 .iter()
605 .filter(|p| body_lower.contains(&p.to_lowercase()))
606 .copied()
607 .collect();
608
609 if !found_patterns.is_empty() {
610 let mut details = HashMap::new();
611 details.insert("patterns_found".to_string(), found_patterns.join(", "));
612
613 ValidationResult {
614 vulnerable: true,
615 category,
616 description: "Verbose error information exposed".to_string(),
617 confidence: Confidence::High,
618 details,
619 }
620 } else {
621 ValidationResult {
622 vulnerable: false,
623 category,
624 description: "No verbose errors detected".to_string(),
625 confidence: Confidence::Medium,
626 details: HashMap::new(),
627 }
628 }
629 }
630
631 fn check_custom(
633 &self,
634 category: OwaspCategory,
635 response: &ResponseData,
636 expected_desc: &str,
637 baseline: Option<&BaselineResponse>,
638 ) -> ValidationResult {
639 let is_success = (200..300).contains(&response.status);
640 let body_lower = response.body.to_lowercase();
641
642 let vulnerable = match expected_desc.to_lowercase().as_str() {
644 s if s.contains("negative") && s.contains("accepted") => {
645 is_success && (body_lower.contains("success") || body_lower.contains("created"))
646 }
647 s if s.contains("zero") && s.contains("accepted") => {
648 is_success && (body_lower.contains("success") || body_lower.contains("created"))
649 }
650 s if s.contains("cors") || s.contains("acao") => {
651 response.headers.iter().any(|(k, v)| {
652 k.to_lowercase() == "access-control-allow-origin"
653 && (v == "*" || v.contains("evil"))
654 })
655 }
656 s if s.contains("redirect") => {
657 response.status == 302 || response.status == 301 || body_lower.contains("redirect")
658 }
659 s if s.contains("debug") || s.contains("trace") => {
660 response.body.len() > 1000
661 || body_lower.contains("debug")
662 || body_lower.contains("trace")
663 }
664 _ => {
665 if let Some(baseline) = baseline {
667 is_success && response.body != baseline.body
668 } else {
669 is_success
670 }
671 }
672 };
673
674 ValidationResult {
675 vulnerable,
676 category,
677 description: if vulnerable {
678 expected_desc.to_string()
679 } else {
680 format!("Expected behavior not observed: {}", expected_desc)
681 },
682 confidence: if vulnerable {
683 Confidence::Medium
684 } else {
685 Confidence::Medium
686 },
687 details: HashMap::new(),
688 }
689 }
690
691 pub fn validate_category(
693 &self,
694 category: OwaspCategory,
695 response: &ResponseData,
696 baseline: Option<&BaselineResponse>,
697 ) -> Vec<ValidationResult> {
698 let mut results = Vec::new();
699
700 match category {
701 OwaspCategory::Api1Bola => {
702 results.push(self.check_unauthorized_access(category, response, baseline));
703 }
704 OwaspCategory::Api2BrokenAuth => {
705 results.push(self.check_success_when_should_fail(category, response));
706 }
707 OwaspCategory::Api3BrokenObjectProperty => {
708 results.push(self.check_field_accepted(category, response, baseline));
709 }
710 OwaspCategory::Api4ResourceConsumption => {
711 results.push(self.check_no_rate_limiting(category, response));
712 }
713 OwaspCategory::Api5BrokenFunctionAuth => {
714 results.push(self.check_success_when_should_fail(category, response));
715 }
716 OwaspCategory::Api6SensitiveFlows => {
717 results.push(self.check_no_rate_limiting(category, response));
718 }
719 OwaspCategory::Api7Ssrf => {
720 results.push(self.check_internal_exposure(category, response));
721 }
722 OwaspCategory::Api8Misconfiguration => {
723 results.push(self.check_missing_headers(category, response));
724 results.push(self.check_verbose_errors(category, response));
725 }
726 OwaspCategory::Api9ImproperInventory => {
727 results.push(self.check_endpoint_exists(category, response));
728 }
729 OwaspCategory::Api10UnsafeConsumption => {
730 results.push(self.check_internal_exposure(category, response));
731 }
732 }
733
734 results
735 }
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 fn make_response(status: u16, body: &str) -> ResponseData {
743 ResponseData {
744 status,
745 headers: HashMap::new(),
746 body: body.to_string(),
747 response_time_ms: 100,
748 }
749 }
750
751 #[test]
752 fn test_success_when_should_fail() {
753 let validator = OwaspValidator::new();
754
755 let response = make_response(200, r#"{"authenticated": true}"#);
757 let result =
758 validator.check_success_when_should_fail(OwaspCategory::Api2BrokenAuth, &response);
759 assert!(result.vulnerable);
760 assert_eq!(result.confidence, Confidence::High);
761
762 let response = make_response(401, r#"{"error": "unauthorized"}"#);
764 let result =
765 validator.check_success_when_should_fail(OwaspCategory::Api2BrokenAuth, &response);
766 assert!(!result.vulnerable);
767 }
768
769 #[test]
770 fn test_missing_headers() {
771 let validator = OwaspValidator::new();
772
773 let response = make_response(200, "OK");
775 let result =
776 validator.check_missing_headers(OwaspCategory::Api8Misconfiguration, &response);
777 assert!(result.vulnerable);
778 assert!(result.details.contains_key("missing_headers"));
779
780 let mut headers = HashMap::new();
782 headers.insert("X-Content-Type-Options".to_string(), "nosniff".to_string());
783 headers.insert("X-Frame-Options".to_string(), "DENY".to_string());
784 headers.insert("Strict-Transport-Security".to_string(), "max-age=31536000".to_string());
785 headers.insert("Content-Security-Policy".to_string(), "default-src 'self'".to_string());
786 headers.insert("X-XSS-Protection".to_string(), "1; mode=block".to_string());
787
788 let response = ResponseData {
789 status: 200,
790 headers,
791 body: "OK".to_string(),
792 response_time_ms: 100,
793 };
794 let result =
795 validator.check_missing_headers(OwaspCategory::Api8Misconfiguration, &response);
796 assert!(!result.vulnerable);
797 }
798
799 #[test]
800 fn test_verbose_errors() {
801 let validator = OwaspValidator::new();
802
803 let response = make_response(500, r#"{"error": "NullPointerException at line 42"}"#);
805 let result = validator.check_verbose_errors(OwaspCategory::Api8Misconfiguration, &response);
806 assert!(result.vulnerable);
807
808 let response = make_response(500, r#"{"error": "Internal server error"}"#);
810 let result = validator.check_verbose_errors(OwaspCategory::Api8Misconfiguration, &response);
811 assert!(result.vulnerable);
813
814 let response = make_response(500, r#"{"error": "Something went wrong"}"#);
816 let result = validator.check_verbose_errors(OwaspCategory::Api8Misconfiguration, &response);
817 assert!(!result.vulnerable);
818 }
819
820 #[test]
821 fn test_endpoint_exists() {
822 let validator = OwaspValidator::new();
823
824 let response = make_response(404, "Not Found");
826 let result =
827 validator.check_endpoint_exists(OwaspCategory::Api9ImproperInventory, &response);
828 assert!(!result.vulnerable);
829
830 let response = make_response(403, "Forbidden");
832 let result =
833 validator.check_endpoint_exists(OwaspCategory::Api9ImproperInventory, &response);
834 assert!(result.vulnerable);
835
836 let response = make_response(200, "Swagger UI");
838 let result =
839 validator.check_endpoint_exists(OwaspCategory::Api9ImproperInventory, &response);
840 assert!(result.vulnerable);
841 }
842
843 #[test]
844 fn test_rate_limiting() {
845 let validator = OwaspValidator::new();
846
847 let response = make_response(429, "Too Many Requests");
849 let result =
850 validator.check_no_rate_limiting(OwaspCategory::Api4ResourceConsumption, &response);
851 assert!(!result.vulnerable);
852
853 let response = make_response(200, "OK");
855 let result =
856 validator.check_no_rate_limiting(OwaspCategory::Api4ResourceConsumption, &response);
857 assert!(result.vulnerable);
858
859 let mut headers = HashMap::new();
861 headers.insert("X-RateLimit-Limit".to_string(), "100".to_string());
862 headers.insert("X-RateLimit-Remaining".to_string(), "99".to_string());
863 let response = ResponseData {
864 status: 200,
865 headers,
866 body: "OK".to_string(),
867 response_time_ms: 100,
868 };
869 let result =
870 validator.check_no_rate_limiting(OwaspCategory::Api4ResourceConsumption, &response);
871 assert!(!result.vulnerable);
872 }
873}