1use super::categories::{OwaspCategory, Severity};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::Path;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct OwaspReport {
15 pub scan_info: OwaspScanInfo,
17 pub findings: Vec<OwaspFinding>,
19 pub summary: OwaspSummary,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct OwaspScanInfo {
26 pub timestamp: DateTime<Utc>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub completed_at: Option<DateTime<Utc>>,
31 pub target: String,
33 pub spec: String,
35 pub mockforge_version: String,
37 pub categories_tested: Vec<OwaspCategory>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub config_summary: Option<ConfigSummary>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ConfigSummary {
47 pub auth_header: String,
49 pub has_valid_token: bool,
51 pub admin_paths_count: usize,
53 pub concurrency: usize,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct OwaspFinding {
60 pub id: String,
62 pub category: OwaspCategory,
64 pub category_name: String,
66 pub severity: Severity,
68 pub endpoint: String,
70 pub method: String,
72 pub description: String,
74 pub evidence: FindingEvidence,
76 pub remediation: String,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub cwe_id: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub cvss_score: Option<f32>,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub tags: Vec<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct FindingEvidence {
92 pub request: RequestEvidence,
94 pub response: ResponseEvidence,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub payload: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub notes: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct RequestEvidence {
107 pub method: String,
109 pub path: String,
111 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
113 pub headers: HashMap<String, String>,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub body_preview: Option<String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ResponseEvidence {
122 pub status: u16,
124 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
126 pub headers: HashMap<String, String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub body_preview: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub response_time_ms: Option<u64>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct OwaspSummary {
138 pub total_endpoints_tested: usize,
140 pub total_requests: usize,
142 pub total_findings: usize,
144 pub findings_by_category: HashMap<String, usize>,
146 pub findings_by_severity: HashMap<String, usize>,
148 pub category_status: HashMap<String, CategoryStatus>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub duration_seconds: Option<f64>,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "lowercase")]
158pub enum CategoryStatus {
159 Pass,
161 Fail,
163 Skipped,
165 Error,
167}
168
169impl OwaspReport {
170 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 pub fn add_finding(&mut self, finding: OwaspFinding) {
197 *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 self.summary
214 .category_status
215 .insert(finding.category.cli_name().to_string(), CategoryStatus::Fail);
216
217 self.findings.push(finding);
218 }
219
220 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 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 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 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 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 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 pub fn count_by_severity(&self, severity: Severity) -> usize {
314 self.findings.iter().filter(|f| f.severity == severity).count()
315 }
316
317 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 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 pub fn with_evidence(mut self, evidence: FindingEvidence) -> Self {
366 self.evidence = evidence;
367 self
368 }
369
370 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
372 self.tags.push(tag.into());
373 self
374 }
375
376 pub fn with_severity(mut self, severity: Severity) -> Self {
378 self.severity = severity;
379 self
380 }
381}
382
383fn category_to_cwe(category: OwaspCategory) -> Option<String> {
385 match category {
386 OwaspCategory::Api1Bola => Some("CWE-639".to_string()), OwaspCategory::Api2BrokenAuth => Some("CWE-287".to_string()), OwaspCategory::Api3BrokenObjectProperty => Some("CWE-915".to_string()), OwaspCategory::Api4ResourceConsumption => Some("CWE-770".to_string()), OwaspCategory::Api5BrokenFunctionAuth => Some("CWE-285".to_string()), OwaspCategory::Api6SensitiveFlows => Some("CWE-840".to_string()), OwaspCategory::Api7Ssrf => Some("CWE-918".to_string()), OwaspCategory::Api8Misconfiguration => Some("CWE-16".to_string()), OwaspCategory::Api9ImproperInventory => Some("CWE-1059".to_string()), OwaspCategory::Api10UnsafeConsumption => Some("CWE-20".to_string()), }
397}
398
399#[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
476fn 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
485pub struct ConsoleReporter {
487 verbose: bool,
488 use_color: bool,
489}
490
491impl ConsoleReporter {
492 pub fn new(verbose: bool) -> Self {
494 Self {
495 verbose,
496 use_color: atty::is(atty::Stream::Stdout),
497 }
498 }
499
500 pub fn print_finding(&self, finding: &OwaspFinding) {
502 let severity_color = match finding.severity {
503 Severity::Critical => "\x1b[91m", Severity::High => "\x1b[31m", Severity::Medium => "\x1b[33m", Severity::Low => "\x1b[36m", Severity::Info => "\x1b[37m", };
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 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 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 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}