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;
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: Confidence::Medium,
683            details: HashMap::new(),
684        }
685    }
686
687    /// Validate response for a specific category
688    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        // Should detect vulnerability when request succeeds
752        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        // Should not detect vulnerability when request fails
759        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        // Response with no security headers
770        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        // Response with all headers
777        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        // Response with stack trace
800        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        // Clean error response
805        let response = make_response(500, r#"{"error": "Internal server error"}"#);
806        let result = validator.check_verbose_errors(OwaspCategory::Api8Misconfiguration, &response);
807        // "internal server error" is in the patterns
808        assert!(result.vulnerable);
809
810        // Very clean error
811        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        // 404 = not found (good)
821        let response = make_response(404, "Not Found");
822        let result =
823            validator.check_endpoint_exists(OwaspCategory::Api9ImproperInventory, &response);
824        assert!(!result.vulnerable);
825
826        // 403 = exists but protected (finding)
827        let response = make_response(403, "Forbidden");
828        let result =
829            validator.check_endpoint_exists(OwaspCategory::Api9ImproperInventory, &response);
830        assert!(result.vulnerable);
831
832        // 200 = exists and accessible (finding)
833        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        // 429 = rate limited (good)
844        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        // 200 with no rate limit headers (bad)
850        let response = make_response(200, "OK");
851        let result =
852            validator.check_no_rate_limiting(OwaspCategory::Api4ResourceConsumption, &response);
853        assert!(result.vulnerable);
854
855        // 200 with rate limit headers (good)
856        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}