Skip to main content

mockforge_bench/owasp_api/
validators.rs

1//! OWASP API Response Validators
2//!
3//! This module provides validation logic to detect vulnerabilities
4//! based on API responses during security testing.
5
6use super::categories::{OwaspCategory, Severity};
7use super::payloads::ExpectedBehavior;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Result of validating a response
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ValidationResult {
14    /// Whether a vulnerability was detected
15    pub vulnerable: bool,
16    /// Category being tested
17    pub category: OwaspCategory,
18    /// Description of what was found
19    pub description: String,
20    /// Confidence level of the detection
21    pub confidence: Confidence,
22    /// Additional details
23    #[serde(default)]
24    pub details: HashMap<String, String>,
25}
26
27/// Confidence level in the detection
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Confidence {
31    /// High confidence - clear indicator
32    High,
33    /// Medium confidence - likely vulnerable
34    Medium,
35    /// Low confidence - possible but uncertain
36    Low,
37}
38
39/// Response data for validation
40#[derive(Debug, Clone)]
41pub struct ResponseData {
42    /// HTTP status code
43    pub status: u16,
44    /// Response headers
45    pub headers: HashMap<String, String>,
46    /// Response body
47    pub body: String,
48    /// Response time in milliseconds
49    pub response_time_ms: u64,
50}
51
52/// Baseline response for comparison
53#[derive(Debug, Clone)]
54pub struct BaselineResponse {
55    /// Original response status
56    pub status: u16,
57    /// Original response body (for comparison)
58    pub body: String,
59    /// Response time
60    pub response_time_ms: u64,
61}
62
63/// Validator for OWASP API security testing
64pub struct OwaspValidator {
65    /// Security headers to check
66    required_headers: Vec<(&'static str, Option<&'static str>)>,
67    /// Patterns indicating verbose errors
68    error_patterns: Vec<&'static str>,
69    /// Patterns indicating successful auth bypass
70    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    /// Create a new validator
81    pub fn new() -> Self {
82        Self {
83            required_headers: vec![
84                ("x-content-type-options", Some("nosniff")),
85                ("x-frame-options", None), // DENY or SAMEORIGIN
86                ("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    /// Validate a response based on the expected behavior
140    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    /// Check if the request succeeded when it should have failed
171    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            // Check for additional auth bypass indicators
180            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    /// Check for unauthorized data access (BOLA)
217    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 we have a baseline, check if we got different data
236        if let Some(baseline) = baseline {
237            if response.body != baseline.body && !response.body.is_empty() {
238                // Got different data - this is a BOLA vulnerability
239                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        // No baseline - check if we got any data at all
256        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    /// Check if a field was accepted (mass assignment)
279    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        // Check if the response body contains indicators of field acceptance
298        let body_lower = response.body.to_lowercase();
299
300        // Look for privilege escalation indicators in response
301        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            // Check if response differs from baseline (field might have been accepted)
329            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    /// Check for missing rate limiting
358    fn check_no_rate_limiting(
359        &self,
360        category: OwaspCategory,
361        response: &ResponseData,
362    ) -> ValidationResult {
363        // Check for rate limit headers
364        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        // Check if we got rate limited (429)
381        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        // Success with no rate limit headers
392        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    /// Check for internal data exposure (SSRF)
421    fn check_internal_exposure(
422        &self,
423        category: OwaspCategory,
424        response: &ResponseData,
425    ) -> ValidationResult {
426        let body_lower = response.body.to_lowercase();
427
428        // Indicators of internal/cloud metadata exposure
429        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        // Check for non-error responses with content
454        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    /// Check if an undocumented endpoint exists
484    fn check_endpoint_exists(
485        &self,
486        category: OwaspCategory,
487        response: &ResponseData,
488    ) -> ValidationResult {
489        // 404 = not found (good)
490        // 401/403 = exists but protected (finding)
491        // 200/other = exists (finding)
492        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    /// Check for missing security headers
531    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                // Check if value matches expected
545                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        // Check CORS
557        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    /// Check for verbose error messages
595    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    /// Custom validation based on description
632    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        // Try to detect based on the expected description
643        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                // Generic check - success when different from baseline
666                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    /// Validate response for a specific category
692    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        // Should detect vulnerability when request succeeds
756        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        // Should not detect vulnerability when request fails
763        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        // Response with no security headers
774        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        // Response with all headers
781        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        // Response with stack trace
804        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        // Clean error response
809        let response = make_response(500, r#"{"error": "Internal server error"}"#);
810        let result = validator.check_verbose_errors(OwaspCategory::Api8Misconfiguration, &response);
811        // "internal server error" is in the patterns
812        assert!(result.vulnerable);
813
814        // Very clean error
815        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        // 404 = not found (good)
825        let response = make_response(404, "Not Found");
826        let result =
827            validator.check_endpoint_exists(OwaspCategory::Api9ImproperInventory, &response);
828        assert!(!result.vulnerable);
829
830        // 403 = exists but protected (finding)
831        let response = make_response(403, "Forbidden");
832        let result =
833            validator.check_endpoint_exists(OwaspCategory::Api9ImproperInventory, &response);
834        assert!(result.vulnerable);
835
836        // 200 = exists and accessible (finding)
837        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        // 429 = rate limited (good)
848        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        // 200 with no rate limit headers (bad)
854        let response = make_response(200, "OK");
855        let result =
856            validator.check_no_rate_limiting(OwaspCategory::Api4ResourceConsumption, &response);
857        assert!(result.vulnerable);
858
859        // 200 with rate limit headers (good)
860        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}