1use super::categories::OwaspCategory;
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: Confidence::Medium,
683 details: HashMap::new(),
684 }
685 }
686
687 pub fn validate_category(
689 &self,
690 category: OwaspCategory,
691 response: &ResponseData,
692 baseline: Option<&BaselineResponse>,
693 ) -> Vec<ValidationResult> {
694 let mut results = Vec::new();
695
696 match category {
697 OwaspCategory::Api1Bola => {
698 results.push(self.check_unauthorized_access(category, response, baseline));
699 }
700 OwaspCategory::Api2BrokenAuth => {
701 results.push(self.check_success_when_should_fail(category, response));
702 }
703 OwaspCategory::Api3BrokenObjectProperty => {
704 results.push(self.check_field_accepted(category, response, baseline));
705 }
706 OwaspCategory::Api4ResourceConsumption => {
707 results.push(self.check_no_rate_limiting(category, response));
708 }
709 OwaspCategory::Api5BrokenFunctionAuth => {
710 results.push(self.check_success_when_should_fail(category, response));
711 }
712 OwaspCategory::Api6SensitiveFlows => {
713 results.push(self.check_no_rate_limiting(category, response));
714 }
715 OwaspCategory::Api7Ssrf => {
716 results.push(self.check_internal_exposure(category, response));
717 }
718 OwaspCategory::Api8Misconfiguration => {
719 results.push(self.check_missing_headers(category, response));
720 results.push(self.check_verbose_errors(category, response));
721 }
722 OwaspCategory::Api9ImproperInventory => {
723 results.push(self.check_endpoint_exists(category, response));
724 }
725 OwaspCategory::Api10UnsafeConsumption => {
726 results.push(self.check_internal_exposure(category, response));
727 }
728 }
729
730 results
731 }
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 fn make_response(status: u16, body: &str) -> ResponseData {
739 ResponseData {
740 status,
741 headers: HashMap::new(),
742 body: body.to_string(),
743 response_time_ms: 100,
744 }
745 }
746
747 #[test]
748 fn test_success_when_should_fail() {
749 let validator = OwaspValidator::new();
750
751 let response = make_response(200, r#"{"authenticated": true}"#);
753 let result =
754 validator.check_success_when_should_fail(OwaspCategory::Api2BrokenAuth, &response);
755 assert!(result.vulnerable);
756 assert_eq!(result.confidence, Confidence::High);
757
758 let response = make_response(401, r#"{"error": "unauthorized"}"#);
760 let result =
761 validator.check_success_when_should_fail(OwaspCategory::Api2BrokenAuth, &response);
762 assert!(!result.vulnerable);
763 }
764
765 #[test]
766 fn test_missing_headers() {
767 let validator = OwaspValidator::new();
768
769 let response = make_response(200, "OK");
771 let result =
772 validator.check_missing_headers(OwaspCategory::Api8Misconfiguration, &response);
773 assert!(result.vulnerable);
774 assert!(result.details.contains_key("missing_headers"));
775
776 let mut headers = HashMap::new();
778 headers.insert("X-Content-Type-Options".to_string(), "nosniff".to_string());
779 headers.insert("X-Frame-Options".to_string(), "DENY".to_string());
780 headers.insert("Strict-Transport-Security".to_string(), "max-age=31536000".to_string());
781 headers.insert("Content-Security-Policy".to_string(), "default-src 'self'".to_string());
782 headers.insert("X-XSS-Protection".to_string(), "1; mode=block".to_string());
783
784 let response = ResponseData {
785 status: 200,
786 headers,
787 body: "OK".to_string(),
788 response_time_ms: 100,
789 };
790 let result =
791 validator.check_missing_headers(OwaspCategory::Api8Misconfiguration, &response);
792 assert!(!result.vulnerable);
793 }
794
795 #[test]
796 fn test_verbose_errors() {
797 let validator = OwaspValidator::new();
798
799 let response = make_response(500, r#"{"error": "NullPointerException at line 42"}"#);
801 let result = validator.check_verbose_errors(OwaspCategory::Api8Misconfiguration, &response);
802 assert!(result.vulnerable);
803
804 let response = make_response(500, r#"{"error": "Internal server error"}"#);
806 let result = validator.check_verbose_errors(OwaspCategory::Api8Misconfiguration, &response);
807 assert!(result.vulnerable);
809
810 let response = make_response(500, r#"{"error": "Something went wrong"}"#);
812 let result = validator.check_verbose_errors(OwaspCategory::Api8Misconfiguration, &response);
813 assert!(!result.vulnerable);
814 }
815
816 #[test]
817 fn test_endpoint_exists() {
818 let validator = OwaspValidator::new();
819
820 let response = make_response(404, "Not Found");
822 let result =
823 validator.check_endpoint_exists(OwaspCategory::Api9ImproperInventory, &response);
824 assert!(!result.vulnerable);
825
826 let response = make_response(403, "Forbidden");
828 let result =
829 validator.check_endpoint_exists(OwaspCategory::Api9ImproperInventory, &response);
830 assert!(result.vulnerable);
831
832 let response = make_response(200, "Swagger UI");
834 let result =
835 validator.check_endpoint_exists(OwaspCategory::Api9ImproperInventory, &response);
836 assert!(result.vulnerable);
837 }
838
839 #[test]
840 fn test_rate_limiting() {
841 let validator = OwaspValidator::new();
842
843 let response = make_response(429, "Too Many Requests");
845 let result =
846 validator.check_no_rate_limiting(OwaspCategory::Api4ResourceConsumption, &response);
847 assert!(!result.vulnerable);
848
849 let response = make_response(200, "OK");
851 let result =
852 validator.check_no_rate_limiting(OwaspCategory::Api4ResourceConsumption, &response);
853 assert!(result.vulnerable);
854
855 let mut headers = HashMap::new();
857 headers.insert("X-RateLimit-Limit".to_string(), "100".to_string());
858 headers.insert("X-RateLimit-Remaining".to_string(), "99".to_string());
859 let response = ResponseData {
860 status: 200,
861 headers,
862 body: "OK".to_string(),
863 response_time_ms: 100,
864 };
865 let result =
866 validator.check_no_rate_limiting(OwaspCategory::Api4ResourceConsumption, &response);
867 assert!(!result.vulnerable);
868 }
869}