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).map_err(std::io::Error::other)?;
240        std::fs::write(path, json)
241    }
242
243    /// Write report to SARIF format
244    pub fn write_sarif(&self, path: &Path) -> std::io::Result<()> {
245        let sarif = self.to_sarif();
246        let json = serde_json::to_string_pretty(&sarif).map_err(std::io::Error::other)?;
247        std::fs::write(path, json)
248    }
249
250    /// Convert to SARIF format
251    fn to_sarif(&self) -> SarifReport {
252        let mut results = Vec::new();
253        let mut rules = Vec::new();
254        let mut rule_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
255
256        for finding in &self.findings {
257            let rule_id = format!("OWASP-{}", finding.category.cli_name().to_uppercase());
258
259            // Add rule if not already present
260            if rule_ids.insert(rule_id.clone()) {
261                rules.push(SarifRule {
262                    id: rule_id.clone(),
263                    name: finding.category.short_name().to_string(),
264                    short_description: SarifMessage {
265                        text: finding.category.full_name().to_string(),
266                    },
267                    full_description: SarifMessage {
268                        text: finding.category.description().to_string(),
269                    },
270                    help: SarifMessage {
271                        text: finding.category.remediation().to_string(),
272                    },
273                    default_configuration: SarifConfiguration {
274                        level: severity_to_sarif_level(finding.severity),
275                    },
276                });
277            }
278
279            results.push(SarifResult {
280                rule_id: rule_id.clone(),
281                level: severity_to_sarif_level(finding.severity),
282                message: SarifMessage {
283                    text: finding.description.clone(),
284                },
285                locations: vec![SarifLocation {
286                    physical_location: SarifPhysicalLocation {
287                        artifact_location: SarifArtifactLocation {
288                            uri: finding.endpoint.clone(),
289                        },
290                    },
291                }],
292            });
293        }
294
295        SarifReport {
296            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
297            version: "2.1.0".to_string(),
298            runs: vec![SarifRun {
299                tool: SarifTool {
300                    driver: SarifDriver {
301                        name: "MockForge OWASP API Scanner".to_string(),
302                        version: self.scan_info.mockforge_version.clone(),
303                        information_uri: "https://mockforge.dev".to_string(),
304                        rules,
305                    },
306                },
307                results,
308            }],
309        }
310    }
311
312    /// Get count of findings by severity
313    pub fn count_by_severity(&self, severity: Severity) -> usize {
314        self.findings.iter().filter(|f| f.severity == severity).count()
315    }
316
317    /// Check if there are any critical or high severity findings
318    pub fn has_critical_findings(&self) -> bool {
319        self.findings
320            .iter()
321            .any(|f| f.severity == Severity::Critical || f.severity == Severity::High)
322    }
323}
324
325impl OwaspFinding {
326    /// Create a new finding
327    pub fn new(
328        category: OwaspCategory,
329        endpoint: String,
330        method: String,
331        description: String,
332    ) -> Self {
333        Self {
334            id: uuid::Uuid::new_v4().to_string(),
335            category,
336            category_name: category.full_name().to_string(),
337            severity: category.severity(),
338            endpoint,
339            method,
340            description,
341            evidence: FindingEvidence {
342                request: RequestEvidence {
343                    method: String::new(),
344                    path: String::new(),
345                    headers: HashMap::new(),
346                    body_preview: None,
347                },
348                response: ResponseEvidence {
349                    status: 0,
350                    headers: HashMap::new(),
351                    body_preview: None,
352                    response_time_ms: None,
353                },
354                payload: None,
355                notes: None,
356            },
357            remediation: category.remediation().to_string(),
358            cwe_id: category_to_cwe(category),
359            cvss_score: None,
360            tags: Vec::new(),
361        }
362    }
363
364    /// Set evidence for this finding
365    pub fn with_evidence(mut self, evidence: FindingEvidence) -> Self {
366        self.evidence = evidence;
367        self
368    }
369
370    /// Add a tag to this finding
371    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
372        self.tags.push(tag.into());
373        self
374    }
375
376    /// Override the severity
377    pub fn with_severity(mut self, severity: Severity) -> Self {
378        self.severity = severity;
379        self
380    }
381}
382
383/// Map OWASP category to CWE ID
384fn category_to_cwe(category: OwaspCategory) -> Option<String> {
385    match category {
386        OwaspCategory::Api1Bola => Some("CWE-639".to_string()), // Authorization Bypass Through User-Controlled Key
387        OwaspCategory::Api2BrokenAuth => Some("CWE-287".to_string()), // Improper Authentication
388        OwaspCategory::Api3BrokenObjectProperty => Some("CWE-915".to_string()), // Mass Assignment
389        OwaspCategory::Api4ResourceConsumption => Some("CWE-770".to_string()), // Allocation Without Limits
390        OwaspCategory::Api5BrokenFunctionAuth => Some("CWE-285".to_string()), // Improper Authorization
391        OwaspCategory::Api6SensitiveFlows => Some("CWE-840".to_string()), // Business Logic Errors
392        OwaspCategory::Api7Ssrf => Some("CWE-918".to_string()),           // SSRF
393        OwaspCategory::Api8Misconfiguration => Some("CWE-16".to_string()), // Configuration
394        OwaspCategory::Api9ImproperInventory => Some("CWE-1059".to_string()), // Insufficient Documentation
395        OwaspCategory::Api10UnsafeConsumption => Some("CWE-20".to_string()), // Improper Input Validation
396    }
397}
398
399// SARIF format structures
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
402struct SarifReport {
403    #[serde(rename = "$schema")]
404    schema: String,
405    version: String,
406    runs: Vec<SarifRun>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
410struct SarifRun {
411    tool: SarifTool,
412    results: Vec<SarifResult>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416struct SarifTool {
417    driver: SarifDriver,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
421#[serde(rename_all = "camelCase")]
422struct SarifDriver {
423    name: String,
424    version: String,
425    information_uri: String,
426    rules: Vec<SarifRule>,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
430#[serde(rename_all = "camelCase")]
431struct SarifRule {
432    id: String,
433    name: String,
434    short_description: SarifMessage,
435    full_description: SarifMessage,
436    help: SarifMessage,
437    default_configuration: SarifConfiguration,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
441struct SarifConfiguration {
442    level: String,
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
446#[serde(rename_all = "camelCase")]
447struct SarifResult {
448    rule_id: String,
449    level: String,
450    message: SarifMessage,
451    locations: Vec<SarifLocation>,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455struct SarifMessage {
456    text: String,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(rename_all = "camelCase")]
461struct SarifLocation {
462    physical_location: SarifPhysicalLocation,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466#[serde(rename_all = "camelCase")]
467struct SarifPhysicalLocation {
468    artifact_location: SarifArtifactLocation,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
472struct SarifArtifactLocation {
473    uri: String,
474}
475
476/// Convert severity to SARIF level
477fn severity_to_sarif_level(severity: Severity) -> String {
478    match severity {
479        Severity::Critical | Severity::High => "error".to_string(),
480        Severity::Medium => "warning".to_string(),
481        Severity::Low | Severity::Info => "note".to_string(),
482    }
483}
484
485/// Console reporter for real-time output
486pub struct ConsoleReporter {
487    verbose: bool,
488    use_color: bool,
489}
490
491impl ConsoleReporter {
492    /// Create a new console reporter
493    pub fn new(verbose: bool) -> Self {
494        Self {
495            verbose,
496            use_color: atty::is(atty::Stream::Stdout),
497        }
498    }
499
500    /// Print a finding to the console
501    pub fn print_finding(&self, finding: &OwaspFinding) {
502        let severity_color = match finding.severity {
503            Severity::Critical => "\x1b[91m", // Bright red
504            Severity::High => "\x1b[31m",     // Red
505            Severity::Medium => "\x1b[33m",   // Yellow
506            Severity::Low => "\x1b[36m",      // Cyan
507            Severity::Info => "\x1b[37m",     // White
508        };
509        let reset = "\x1b[0m";
510
511        if self.use_color {
512            println!(
513                "  {}[FINDING]{} {} {} - {}",
514                severity_color, reset, finding.method, finding.endpoint, finding.description
515            );
516        } else {
517            println!(
518                "  [FINDING] {} {} - {}",
519                finding.method, finding.endpoint, finding.description
520            );
521        }
522
523        if self.verbose {
524            println!("    Severity: {:?}", finding.severity);
525            println!("    Remediation: {}", finding.remediation);
526            if let Some(payload) = &finding.evidence.payload {
527                println!("    Payload: {}", payload);
528            }
529        }
530    }
531
532    /// Print category header
533    pub fn print_category_header(&self, category: OwaspCategory) {
534        let bold = if self.use_color { "\x1b[1m" } else { "" };
535        let reset = if self.use_color { "\x1b[0m" } else { "" };
536
537        println!(
538            "{}[{}]{} {}: Testing {}...",
539            bold,
540            category.cli_name().to_uppercase(),
541            reset,
542            category.short_name(),
543            category.description()
544        );
545    }
546
547    /// Print category result
548    pub fn print_category_result(&self, category: OwaspCategory, finding_count: usize) {
549        let green = if self.use_color { "\x1b[32m" } else { "" };
550        let red = if self.use_color { "\x1b[31m" } else { "" };
551        let reset = if self.use_color { "\x1b[0m" } else { "" };
552
553        if finding_count == 0 {
554            println!("  {}[PASS]{} {} - All tests passed", green, reset, category.short_name());
555        } else {
556            println!(
557                "  {}[FAIL]{} {} - {} finding(s)",
558                red,
559                reset,
560                category.short_name(),
561                finding_count
562            );
563        }
564    }
565
566    /// Print final summary
567    pub fn print_summary(&self, report: &OwaspReport) {
568        let bold = if self.use_color { "\x1b[1m" } else { "" };
569        let green = if self.use_color { "\x1b[32m" } else { "" };
570        let red = if self.use_color { "\x1b[31m" } else { "" };
571        let reset = if self.use_color { "\x1b[0m" } else { "" };
572
573        println!();
574        println!("{}OWASP API Top 10 Scan Results{}", bold, reset);
575        println!("==============================");
576        println!("Target: {}", report.scan_info.target);
577        println!("Endpoints tested: {}", report.summary.total_endpoints_tested);
578        println!("Total requests: {}", report.summary.total_requests);
579
580        if let Some(duration) = report.summary.duration_seconds {
581            println!("Duration: {:.2}s", duration);
582        }
583
584        println!();
585
586        if report.summary.total_findings == 0 {
587            println!("{}No vulnerabilities found!{}", green, reset);
588        } else {
589            println!(
590                "{}Found {} vulnerability/ies across {} categories{}",
591                red,
592                report.summary.total_findings,
593                report.summary.findings_by_category.len(),
594                reset
595            );
596
597            println!();
598            println!("Findings by severity:");
599            for severity in [
600                Severity::Critical,
601                Severity::High,
602                Severity::Medium,
603                Severity::Low,
604            ] {
605                let count = report.count_by_severity(severity);
606                if count > 0 {
607                    println!("  {:?}: {}", severity, count);
608                }
609            }
610
611            println!();
612            println!("Findings by category:");
613            for (category, count) in &report.summary.findings_by_category {
614                println!("  {}: {}", category, count);
615            }
616        }
617    }
618}
619
620impl Default for ConsoleReporter {
621    fn default() -> Self {
622        Self::new(false)
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn test_report_creation() {
632        let report = OwaspReport::new(
633            "https://api.example.com".to_string(),
634            "api.yaml".to_string(),
635            vec![OwaspCategory::Api1Bola, OwaspCategory::Api2BrokenAuth],
636        );
637
638        assert_eq!(report.scan_info.target, "https://api.example.com");
639        assert_eq!(report.scan_info.categories_tested.len(), 2);
640        assert_eq!(report.summary.total_findings, 0);
641    }
642
643    #[test]
644    fn test_add_finding() {
645        let mut report = OwaspReport::new(
646            "https://api.example.com".to_string(),
647            "api.yaml".to_string(),
648            vec![OwaspCategory::Api1Bola],
649        );
650
651        let finding = OwaspFinding::new(
652            OwaspCategory::Api1Bola,
653            "/users/123".to_string(),
654            "GET".to_string(),
655            "ID manipulation accepted".to_string(),
656        );
657
658        report.add_finding(finding);
659
660        assert_eq!(report.summary.total_findings, 1);
661        assert_eq!(report.findings.len(), 1);
662        assert!(report.summary.findings_by_category.contains_key("api1"));
663    }
664
665    #[test]
666    fn test_sarif_conversion() {
667        let mut report = OwaspReport::new(
668            "https://api.example.com".to_string(),
669            "api.yaml".to_string(),
670            vec![OwaspCategory::Api1Bola],
671        );
672
673        let finding = OwaspFinding::new(
674            OwaspCategory::Api1Bola,
675            "/users/123".to_string(),
676            "GET".to_string(),
677            "Test finding".to_string(),
678        );
679
680        report.add_finding(finding);
681
682        let sarif = report.to_sarif();
683        assert_eq!(sarif.version, "2.1.0");
684        assert_eq!(sarif.runs.len(), 1);
685        assert_eq!(sarif.runs[0].results.len(), 1);
686    }
687
688    #[test]
689    fn test_category_to_cwe() {
690        assert_eq!(category_to_cwe(OwaspCategory::Api1Bola), Some("CWE-639".to_string()));
691        assert_eq!(category_to_cwe(OwaspCategory::Api7Ssrf), Some("CWE-918".to_string()));
692    }
693}