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)
240 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
241 std::fs::write(path, json)
242 }
243
244 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 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 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 pub fn count_by_severity(&self, severity: Severity) -> usize {
316 self.findings.iter().filter(|f| f.severity == severity).count()
317 }
318
319 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 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 pub fn with_evidence(mut self, evidence: FindingEvidence) -> Self {
368 self.evidence = evidence;
369 self
370 }
371
372 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
374 self.tags.push(tag.into());
375 self
376 }
377
378 pub fn with_severity(mut self, severity: Severity) -> Self {
380 self.severity = severity;
381 self
382 }
383}
384
385fn category_to_cwe(category: OwaspCategory) -> Option<String> {
387 match category {
388 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()), }
399}
400
401#[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
478fn 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
487pub struct ConsoleReporter {
489 verbose: bool,
490 use_color: bool,
491}
492
493impl ConsoleReporter {
494 pub fn new(verbose: bool) -> Self {
496 Self {
497 verbose,
498 use_color: atty::is(atty::Stream::Stdout),
499 }
500 }
501
502 pub fn print_finding(&self, finding: &OwaspFinding) {
504 let severity_color = match finding.severity {
505 Severity::Critical => "\x1b[91m", Severity::High => "\x1b[31m", Severity::Medium => "\x1b[33m", Severity::Low => "\x1b[36m", Severity::Info => "\x1b[37m", };
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 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 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 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}