Skip to main content

mockforge_bench/owasp_api/
report.rs

1//! OWASP API Security Report Structures
2//!
3//! This module defines the output formats for OWASP API security test results,
4//! including JSON and SARIF report formats.
5
6use super::categories::{OwaspCategory, Severity};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::Path;
11
12/// Complete OWASP API Security scan report
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct OwaspReport {
15    /// Scan metadata
16    pub scan_info: OwaspScanInfo,
17    /// All findings from the scan
18    pub findings: Vec<OwaspFinding>,
19    /// Summary statistics
20    pub summary: OwaspSummary,
21}
22
23/// Metadata about the scan
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct OwaspScanInfo {
26    /// Timestamp when the scan started
27    pub timestamp: DateTime<Utc>,
28    /// Timestamp when the scan completed
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub completed_at: Option<DateTime<Utc>>,
31    /// Target URL(s) that were scanned
32    pub target: String,
33    /// OpenAPI spec file used
34    pub spec: String,
35    /// MockForge version
36    pub mockforge_version: String,
37    /// Categories that were tested
38    pub categories_tested: Vec<OwaspCategory>,
39    /// Scan configuration summary
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub config_summary: Option<ConfigSummary>,
42}
43
44/// Summary of scan configuration
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ConfigSummary {
47    /// Auth header used
48    pub auth_header: String,
49    /// Whether valid auth token was provided
50    pub has_valid_token: bool,
51    /// Number of admin paths tested
52    pub admin_paths_count: usize,
53    /// Concurrency level
54    pub concurrency: usize,
55}
56
57/// A single security finding
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct OwaspFinding {
60    /// Unique ID for this finding
61    pub id: String,
62    /// OWASP category
63    pub category: OwaspCategory,
64    /// Full category name
65    pub category_name: String,
66    /// Severity of the finding
67    pub severity: Severity,
68    /// The endpoint where the vulnerability was found
69    pub endpoint: String,
70    /// HTTP method
71    pub method: String,
72    /// Human-readable description
73    pub description: String,
74    /// Evidence of the vulnerability
75    pub evidence: FindingEvidence,
76    /// Remediation guidance
77    pub remediation: String,
78    /// CWE ID if applicable
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub cwe_id: Option<String>,
81    /// CVSS score if applicable
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub cvss_score: Option<f32>,
84    /// Additional tags/labels
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub tags: Vec<String>,
87}
88
89/// Evidence supporting a finding
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct FindingEvidence {
92    /// The request that triggered the finding
93    pub request: RequestEvidence,
94    /// The response received
95    pub response: ResponseEvidence,
96    /// The payload that was used
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub payload: Option<String>,
99    /// Additional notes
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub notes: Option<String>,
102}
103
104/// Request evidence
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct RequestEvidence {
107    /// HTTP method
108    pub method: String,
109    /// Request path
110    pub path: String,
111    /// Selected request headers (sensitive values redacted)
112    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
113    pub headers: HashMap<String, String>,
114    /// Request body preview (truncated)
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub body_preview: Option<String>,
117}
118
119/// Response evidence
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ResponseEvidence {
122    /// HTTP status code
123    pub status: u16,
124    /// Selected response headers
125    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
126    pub headers: HashMap<String, String>,
127    /// Response body preview (truncated)
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub body_preview: Option<String>,
130    /// Response time in milliseconds
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub response_time_ms: Option<u64>,
133}
134
135/// Summary statistics for the scan
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct OwaspSummary {
138    /// Total number of endpoints tested
139    pub total_endpoints_tested: usize,
140    /// Total number of requests made
141    pub total_requests: usize,
142    /// Total number of findings
143    pub total_findings: usize,
144    /// Findings broken down by category
145    pub findings_by_category: HashMap<String, usize>,
146    /// Findings broken down by severity
147    pub findings_by_severity: HashMap<String, usize>,
148    /// Pass/fail status for each category
149    pub category_status: HashMap<String, CategoryStatus>,
150    /// Scan duration in seconds
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub duration_seconds: Option<f64>,
153}
154
155/// Status for a category
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "lowercase")]
158pub enum CategoryStatus {
159    /// All tests passed (no findings)
160    Pass,
161    /// Findings were detected
162    Fail,
163    /// Category was skipped
164    Skipped,
165    /// Error during testing
166    Error,
167}
168
169impl OwaspReport {
170    /// Create a new empty report
171    pub fn new(target: String, spec: String, categories: Vec<OwaspCategory>) -> Self {
172        Self {
173            scan_info: OwaspScanInfo {
174                timestamp: Utc::now(),
175                completed_at: None,
176                target,
177                spec,
178                mockforge_version: env!("CARGO_PKG_VERSION").to_string(),
179                categories_tested: categories,
180                config_summary: None,
181            },
182            findings: Vec::new(),
183            summary: OwaspSummary {
184                total_endpoints_tested: 0,
185                total_requests: 0,
186                total_findings: 0,
187                findings_by_category: HashMap::new(),
188                findings_by_severity: HashMap::new(),
189                category_status: HashMap::new(),
190                duration_seconds: None,
191            },
192        }
193    }
194
195    /// Add a finding to the report
196    pub fn add_finding(&mut self, finding: OwaspFinding) {
197        // Update summary stats
198        *self
199            .summary
200            .findings_by_category
201            .entry(finding.category.cli_name().to_string())
202            .or_insert(0) += 1;
203
204        *self
205            .summary
206            .findings_by_severity
207            .entry(finding.severity.as_str().to_string())
208            .or_insert(0) += 1;
209
210        self.summary.total_findings += 1;
211
212        // Mark category as failed
213        self.summary
214            .category_status
215            .insert(finding.category.cli_name().to_string(), CategoryStatus::Fail);
216
217        self.findings.push(finding);
218    }
219
220    /// Mark scan as completed
221    pub fn complete(&mut self) {
222        self.scan_info.completed_at = Some(Utc::now());
223        if let Some(start) = self.scan_info.timestamp.timestamp_millis().checked_sub(0) {
224            let end = Utc::now().timestamp_millis();
225            self.summary.duration_seconds = Some((end - start) as f64 / 1000.0);
226        }
227    }
228
229    /// Set category status to pass if no findings
230    pub fn finalize_category_status(&mut self) {
231        for category in &self.scan_info.categories_tested {
232            let key = category.cli_name().to_string();
233            self.summary.category_status.entry(key).or_insert(CategoryStatus::Pass);
234        }
235    }
236
237    /// Write report to JSON file
238    pub fn write_json(&self, path: &Path) -> std::io::Result<()> {
239        let json = serde_json::to_string_pretty(self)
240            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
241        std::fs::write(path, json)
242    }
243
244    /// Write report to SARIF format
245    pub fn write_sarif(&self, path: &Path) -> std::io::Result<()> {
246        let sarif = self.to_sarif();
247        let json = serde_json::to_string_pretty(&sarif)
248            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
249        std::fs::write(path, json)
250    }
251
252    /// Convert to SARIF format
253    fn to_sarif(&self) -> SarifReport {
254        let mut results = Vec::new();
255        let mut rules = Vec::new();
256        let mut rule_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
257
258        for finding in &self.findings {
259            let rule_id = format!("OWASP-{}", finding.category.cli_name().to_uppercase());
260
261            // Add rule if not already present
262            if rule_ids.insert(rule_id.clone()) {
263                rules.push(SarifRule {
264                    id: rule_id.clone(),
265                    name: finding.category.short_name().to_string(),
266                    short_description: SarifMessage {
267                        text: finding.category.full_name().to_string(),
268                    },
269                    full_description: SarifMessage {
270                        text: finding.category.description().to_string(),
271                    },
272                    help: SarifMessage {
273                        text: finding.category.remediation().to_string(),
274                    },
275                    default_configuration: SarifConfiguration {
276                        level: severity_to_sarif_level(finding.severity),
277                    },
278                });
279            }
280
281            results.push(SarifResult {
282                rule_id: rule_id.clone(),
283                level: severity_to_sarif_level(finding.severity),
284                message: SarifMessage {
285                    text: finding.description.clone(),
286                },
287                locations: vec![SarifLocation {
288                    physical_location: SarifPhysicalLocation {
289                        artifact_location: SarifArtifactLocation {
290                            uri: finding.endpoint.clone(),
291                        },
292                    },
293                }],
294            });
295        }
296
297        SarifReport {
298            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
299            version: "2.1.0".to_string(),
300            runs: vec![SarifRun {
301                tool: SarifTool {
302                    driver: SarifDriver {
303                        name: "MockForge OWASP API Scanner".to_string(),
304                        version: self.scan_info.mockforge_version.clone(),
305                        information_uri: "https://mockforge.dev".to_string(),
306                        rules,
307                    },
308                },
309                results,
310            }],
311        }
312    }
313
314    /// Get count of findings by severity
315    pub fn count_by_severity(&self, severity: Severity) -> usize {
316        self.findings.iter().filter(|f| f.severity == severity).count()
317    }
318
319    /// Check if there are any critical or high severity findings
320    pub fn has_critical_findings(&self) -> bool {
321        self.findings
322            .iter()
323            .any(|f| f.severity == Severity::Critical || f.severity == Severity::High)
324    }
325}
326
327impl OwaspFinding {
328    /// Create a new finding
329    pub fn new(
330        category: OwaspCategory,
331        endpoint: String,
332        method: String,
333        description: String,
334    ) -> Self {
335        Self {
336            id: uuid::Uuid::new_v4().to_string(),
337            category,
338            category_name: category.full_name().to_string(),
339            severity: category.severity(),
340            endpoint,
341            method,
342            description,
343            evidence: FindingEvidence {
344                request: RequestEvidence {
345                    method: String::new(),
346                    path: String::new(),
347                    headers: HashMap::new(),
348                    body_preview: None,
349                },
350                response: ResponseEvidence {
351                    status: 0,
352                    headers: HashMap::new(),
353                    body_preview: None,
354                    response_time_ms: None,
355                },
356                payload: None,
357                notes: None,
358            },
359            remediation: category.remediation().to_string(),
360            cwe_id: category_to_cwe(category),
361            cvss_score: None,
362            tags: Vec::new(),
363        }
364    }
365
366    /// Set evidence for this finding
367    pub fn with_evidence(mut self, evidence: FindingEvidence) -> Self {
368        self.evidence = evidence;
369        self
370    }
371
372    /// Add a tag to this finding
373    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
374        self.tags.push(tag.into());
375        self
376    }
377
378    /// Override the severity
379    pub fn with_severity(mut self, severity: Severity) -> Self {
380        self.severity = severity;
381        self
382    }
383}
384
385/// Map OWASP category to CWE ID
386fn category_to_cwe(category: OwaspCategory) -> Option<String> {
387    match category {
388        OwaspCategory::Api1Bola => Some("CWE-639".to_string()), // Authorization Bypass Through User-Controlled Key
389        OwaspCategory::Api2BrokenAuth => Some("CWE-287".to_string()), // Improper Authentication
390        OwaspCategory::Api3BrokenObjectProperty => Some("CWE-915".to_string()), // Mass Assignment
391        OwaspCategory::Api4ResourceConsumption => Some("CWE-770".to_string()), // Allocation Without Limits
392        OwaspCategory::Api5BrokenFunctionAuth => Some("CWE-285".to_string()), // Improper Authorization
393        OwaspCategory::Api6SensitiveFlows => Some("CWE-840".to_string()), // Business Logic Errors
394        OwaspCategory::Api7Ssrf => Some("CWE-918".to_string()),           // SSRF
395        OwaspCategory::Api8Misconfiguration => Some("CWE-16".to_string()), // Configuration
396        OwaspCategory::Api9ImproperInventory => Some("CWE-1059".to_string()), // Insufficient Documentation
397        OwaspCategory::Api10UnsafeConsumption => Some("CWE-20".to_string()), // Improper Input Validation
398    }
399}
400
401// SARIF format structures
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404struct SarifReport {
405    #[serde(rename = "$schema")]
406    schema: String,
407    version: String,
408    runs: Vec<SarifRun>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
412struct SarifRun {
413    tool: SarifTool,
414    results: Vec<SarifResult>,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
418struct SarifTool {
419    driver: SarifDriver,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
423#[serde(rename_all = "camelCase")]
424struct SarifDriver {
425    name: String,
426    version: String,
427    information_uri: String,
428    rules: Vec<SarifRule>,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(rename_all = "camelCase")]
433struct SarifRule {
434    id: String,
435    name: String,
436    short_description: SarifMessage,
437    full_description: SarifMessage,
438    help: SarifMessage,
439    default_configuration: SarifConfiguration,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
443struct SarifConfiguration {
444    level: String,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
448#[serde(rename_all = "camelCase")]
449struct SarifResult {
450    rule_id: String,
451    level: String,
452    message: SarifMessage,
453    locations: Vec<SarifLocation>,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
457struct SarifMessage {
458    text: String,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
462#[serde(rename_all = "camelCase")]
463struct SarifLocation {
464    physical_location: SarifPhysicalLocation,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
468#[serde(rename_all = "camelCase")]
469struct SarifPhysicalLocation {
470    artifact_location: SarifArtifactLocation,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
474struct SarifArtifactLocation {
475    uri: String,
476}
477
478/// Convert severity to SARIF level
479fn severity_to_sarif_level(severity: Severity) -> String {
480    match severity {
481        Severity::Critical | Severity::High => "error".to_string(),
482        Severity::Medium => "warning".to_string(),
483        Severity::Low | Severity::Info => "note".to_string(),
484    }
485}
486
487/// Console reporter for real-time output
488pub struct ConsoleReporter {
489    verbose: bool,
490    use_color: bool,
491}
492
493impl ConsoleReporter {
494    /// Create a new console reporter
495    pub fn new(verbose: bool) -> Self {
496        Self {
497            verbose,
498            use_color: atty::is(atty::Stream::Stdout),
499        }
500    }
501
502    /// Print a finding to the console
503    pub fn print_finding(&self, finding: &OwaspFinding) {
504        let severity_color = match finding.severity {
505            Severity::Critical => "\x1b[91m", // Bright red
506            Severity::High => "\x1b[31m",     // Red
507            Severity::Medium => "\x1b[33m",   // Yellow
508            Severity::Low => "\x1b[36m",      // Cyan
509            Severity::Info => "\x1b[37m",     // White
510        };
511        let reset = "\x1b[0m";
512
513        if self.use_color {
514            println!(
515                "  {}[FINDING]{} {} {} - {}",
516                severity_color, reset, finding.method, finding.endpoint, finding.description
517            );
518        } else {
519            println!(
520                "  [FINDING] {} {} - {}",
521                finding.method, finding.endpoint, finding.description
522            );
523        }
524
525        if self.verbose {
526            println!("    Severity: {:?}", finding.severity);
527            println!("    Remediation: {}", finding.remediation);
528            if let Some(payload) = &finding.evidence.payload {
529                println!("    Payload: {}", payload);
530            }
531        }
532    }
533
534    /// Print category header
535    pub fn print_category_header(&self, category: OwaspCategory) {
536        let bold = if self.use_color { "\x1b[1m" } else { "" };
537        let reset = if self.use_color { "\x1b[0m" } else { "" };
538
539        println!(
540            "{}[{}]{} {}: Testing {}...",
541            bold,
542            category.cli_name().to_uppercase(),
543            reset,
544            category.short_name(),
545            category.description()
546        );
547    }
548
549    /// Print category result
550    pub fn print_category_result(&self, category: OwaspCategory, finding_count: usize) {
551        let green = if self.use_color { "\x1b[32m" } else { "" };
552        let red = if self.use_color { "\x1b[31m" } else { "" };
553        let reset = if self.use_color { "\x1b[0m" } else { "" };
554
555        if finding_count == 0 {
556            println!("  {}[PASS]{} {} - All tests passed", green, reset, category.short_name());
557        } else {
558            println!(
559                "  {}[FAIL]{} {} - {} finding(s)",
560                red,
561                reset,
562                category.short_name(),
563                finding_count
564            );
565        }
566    }
567
568    /// Print final summary
569    pub fn print_summary(&self, report: &OwaspReport) {
570        let bold = if self.use_color { "\x1b[1m" } else { "" };
571        let green = if self.use_color { "\x1b[32m" } else { "" };
572        let red = if self.use_color { "\x1b[31m" } else { "" };
573        let reset = if self.use_color { "\x1b[0m" } else { "" };
574
575        println!();
576        println!("{}OWASP API Top 10 Scan Results{}", bold, reset);
577        println!("==============================");
578        println!("Target: {}", report.scan_info.target);
579        println!("Endpoints tested: {}", report.summary.total_endpoints_tested);
580        println!("Total requests: {}", report.summary.total_requests);
581
582        if let Some(duration) = report.summary.duration_seconds {
583            println!("Duration: {:.2}s", duration);
584        }
585
586        println!();
587
588        if report.summary.total_findings == 0 {
589            println!("{}No vulnerabilities found!{}", green, reset);
590        } else {
591            println!(
592                "{}Found {} vulnerability/ies across {} categories{}",
593                red,
594                report.summary.total_findings,
595                report.summary.findings_by_category.len(),
596                reset
597            );
598
599            println!();
600            println!("Findings by severity:");
601            for severity in [
602                Severity::Critical,
603                Severity::High,
604                Severity::Medium,
605                Severity::Low,
606            ] {
607                let count = report.count_by_severity(severity);
608                if count > 0 {
609                    println!("  {:?}: {}", severity, count);
610                }
611            }
612
613            println!();
614            println!("Findings by category:");
615            for (category, count) in &report.summary.findings_by_category {
616                println!("  {}: {}", category, count);
617            }
618        }
619    }
620}
621
622impl Default for ConsoleReporter {
623    fn default() -> Self {
624        Self::new(false)
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn test_report_creation() {
634        let report = OwaspReport::new(
635            "https://api.example.com".to_string(),
636            "api.yaml".to_string(),
637            vec![OwaspCategory::Api1Bola, OwaspCategory::Api2BrokenAuth],
638        );
639
640        assert_eq!(report.scan_info.target, "https://api.example.com");
641        assert_eq!(report.scan_info.categories_tested.len(), 2);
642        assert_eq!(report.summary.total_findings, 0);
643    }
644
645    #[test]
646    fn test_add_finding() {
647        let mut report = OwaspReport::new(
648            "https://api.example.com".to_string(),
649            "api.yaml".to_string(),
650            vec![OwaspCategory::Api1Bola],
651        );
652
653        let finding = OwaspFinding::new(
654            OwaspCategory::Api1Bola,
655            "/users/123".to_string(),
656            "GET".to_string(),
657            "ID manipulation accepted".to_string(),
658        );
659
660        report.add_finding(finding);
661
662        assert_eq!(report.summary.total_findings, 1);
663        assert_eq!(report.findings.len(), 1);
664        assert!(report.summary.findings_by_category.contains_key("api1"));
665    }
666
667    #[test]
668    fn test_sarif_conversion() {
669        let mut report = OwaspReport::new(
670            "https://api.example.com".to_string(),
671            "api.yaml".to_string(),
672            vec![OwaspCategory::Api1Bola],
673        );
674
675        let finding = OwaspFinding::new(
676            OwaspCategory::Api1Bola,
677            "/users/123".to_string(),
678            "GET".to_string(),
679            "Test finding".to_string(),
680        );
681
682        report.add_finding(finding);
683
684        let sarif = report.to_sarif();
685        assert_eq!(sarif.version, "2.1.0");
686        assert_eq!(sarif.runs.len(), 1);
687        assert_eq!(sarif.runs[0].results.len(), 1);
688    }
689
690    #[test]
691    fn test_category_to_cwe() {
692        assert_eq!(category_to_cwe(OwaspCategory::Api1Bola), Some("CWE-639".to_string()));
693        assert_eq!(category_to_cwe(OwaspCategory::Api7Ssrf), Some("CWE-918".to_string()));
694    }
695}