1use crate::{VeracodeClient, VeracodeError, VeracodeRegion};
6use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
7use chrono_tz::America::New_York;
8use chrono_tz::Europe::Berlin;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize)]
13pub struct AuditReportRequest {
14 pub report_type: String,
16 pub start_date: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub end_date: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub audit_action: Option<Vec<String>>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub action_type: Option<Vec<String>>,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub target_user_id: Option<Vec<String>>,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub modifier_user_id: Option<Vec<String>>,
33}
34
35impl AuditReportRequest {
36 #[must_use]
38 pub fn new(start_date: impl Into<String>, end_date: Option<String>) -> Self {
39 Self {
40 report_type: "AUDIT".to_string(),
41 start_date: start_date.into(),
42 end_date,
43 audit_action: None,
44 action_type: None,
45 target_user_id: None,
46 modifier_user_id: None,
47 }
48 }
49
50 #[must_use]
52 pub fn with_audit_actions(mut self, actions: Vec<String>) -> Self {
53 self.audit_action = Some(actions);
54 self
55 }
56
57 #[must_use]
59 pub fn with_action_types(mut self, types: Vec<String>) -> Self {
60 self.action_type = Some(types);
61 self
62 }
63
64 #[must_use]
66 pub fn with_target_users(mut self, user_ids: Vec<String>) -> Self {
67 self.target_user_id = Some(user_ids);
68 self
69 }
70
71 #[must_use]
73 pub fn with_modifier_users(mut self, user_ids: Vec<String>) -> Self {
74 self.modifier_user_id = Some(user_ids);
75 self
76 }
77}
78
79#[derive(Debug, Clone, Deserialize)]
81pub struct GenerateReportData {
82 pub id: String,
84}
85
86#[derive(Debug, Clone, Deserialize)]
88pub struct GenerateReportResponse {
89 #[serde(rename = "_embedded")]
91 pub embedded: GenerateReportData,
92}
93
94#[derive(Debug, Clone, PartialEq, Deserialize)]
96#[serde(rename_all = "UPPERCASE")]
97pub enum ReportStatus {
98 Queued,
100 Submitted,
102 Processing,
104 Completed,
106 Failed,
108}
109
110impl std::fmt::Display for ReportStatus {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 match self {
113 ReportStatus::Queued => write!(f, "Queued"),
114 ReportStatus::Submitted => write!(f, "Submitted"),
115 ReportStatus::Processing => write!(f, "Processing"),
116 ReportStatus::Completed => write!(f, "Completed"),
117 ReportStatus::Failed => write!(f, "Failed"),
118 }
119 }
120}
121
122#[derive(Debug, Clone, Deserialize)]
124pub struct ReportLinks {
125 pub first: Option<LinkHref>,
127 pub prev: Option<LinkHref>,
129 #[serde(rename = "self")]
131 pub self_link: Option<LinkHref>,
132 pub next: Option<LinkHref>,
134 pub last: Option<LinkHref>,
136}
137
138#[derive(Debug, Clone, Deserialize)]
140pub struct LinkHref {
141 pub href: String,
143}
144
145#[derive(Debug, Clone, Deserialize)]
147pub struct PageMetadata {
148 pub number: u32,
150 pub size: u32,
152 pub total_elements: u32,
154 pub total_pages: u32,
156}
157
158#[derive(Debug, Clone, Serialize)]
164pub struct AuditLogEntry {
165 pub raw_log: String,
167 #[serde(skip_serializing_if = "Option::is_none")]
174 pub timestamp_utc: Option<String>,
175 pub log_hash: String,
177}
178
179#[derive(Debug, Deserialize)]
181struct TimestampExtractor {
182 timestamp: Option<String>,
183}
184
185#[derive(Debug, Clone, Deserialize)]
187pub struct ReportData {
188 pub id: String,
190 pub report_type: String,
192 pub status: ReportStatus,
194 pub requested_by_user: String,
196 pub requested_by_account: u64,
198 pub date_report_requested: String,
200 pub date_report_completed: Option<String>,
202 pub report_expiration_date: Option<String>,
204 pub audit_logs: serde_json::Value,
206 #[serde(rename = "_links")]
208 pub links: Option<ReportLinks>,
209 pub page_metadata: Option<PageMetadata>,
211}
212
213#[derive(Debug, Clone, Deserialize)]
215pub struct ReportResponse {
216 #[serde(rename = "_embedded")]
218 pub embedded: ReportData,
219}
220
221fn convert_regional_timestamp_to_utc(
269 timestamp_str: &str,
270 region: &VeracodeRegion,
271) -> Option<String> {
272 let has_millis = timestamp_str.contains('.');
274
275 let naive_dt = if has_millis {
277 NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S%.f").ok()?
280 } else {
281 NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S").ok()?
282 };
283
284 let utc_time = match region {
286 VeracodeRegion::European => {
287 let regional_time: DateTime<_> = Berlin.from_local_datetime(&naive_dt).single()?;
289 regional_time.with_timezone(&Utc)
290 }
291 VeracodeRegion::Commercial | VeracodeRegion::Federal => {
292 let regional_time: DateTime<_> = New_York.from_local_datetime(&naive_dt).single()?;
294 regional_time.with_timezone(&Utc)
295 }
296 };
297
298 if has_millis {
300 let formatted = utc_time.format("%Y-%m-%d %H:%M:%S%.f").to_string();
302 Some(formatted)
304 } else {
305 Some(utc_time.format("%Y-%m-%d %H:%M:%S").to_string())
306 }
307}
308
309fn generate_log_hash(raw_json: &str) -> String {
340 use xxhash_rust::xxh3::xxh3_128;
341
342 let hash = xxh3_128(raw_json.as_bytes());
344
345 format!("{:032x}", hash)
347}
348
349#[derive(Clone)]
351pub struct ReportingApi {
352 client: VeracodeClient,
353 region: VeracodeRegion,
354}
355
356impl ReportingApi {
357 #[must_use]
359 pub fn new(client: VeracodeClient) -> Self {
360 let region = client.config().region;
361 Self { client, region }
362 }
363
364 pub async fn generate_audit_report(
386 &self,
387 request: &AuditReportRequest,
388 ) -> Result<String, VeracodeError> {
389 let response = self
390 .client
391 .post("/appsec/v1/analytics/report", Some(request))
392 .await?;
393
394 let response_text = response.text().await?;
395 log::debug!("Generate report API response: {}", response_text);
396
397 let generate_response: GenerateReportResponse = serde_json::from_str(&response_text)?;
398 Ok(generate_response.embedded.id)
399 }
400
401 pub async fn get_audit_report(
419 &self,
420 report_id: &str,
421 page: Option<u32>,
422 ) -> Result<ReportResponse, VeracodeError> {
423 let endpoint = if let Some(page_num) = page {
424 format!("/appsec/v1/analytics/report/{report_id}?page={page_num}")
425 } else {
426 format!("/appsec/v1/analytics/report/{report_id}")
427 };
428
429 let response = self.client.get(&endpoint, None).await?;
430 let response_text = response.text().await?;
431 log::debug!("Get audit report API response: {}", response_text);
432
433 let report_response: ReportResponse = serde_json::from_str(&response_text)?;
434 Ok(report_response)
435 }
436
437 pub async fn poll_report_status(
456 &self,
457 report_id: &str,
458 max_attempts: Option<u32>,
459 initial_delay_secs: Option<u64>,
460 ) -> Result<ReportResponse, VeracodeError> {
461 let max_attempts = max_attempts.unwrap_or(30);
462 let initial_delay = initial_delay_secs.unwrap_or(2);
463
464 let mut attempts: u32 = 0;
465 let mut delay_secs = initial_delay;
466
467 loop {
468 attempts = attempts.saturating_add(1);
469
470 let report = self.get_audit_report(report_id, None).await?;
472 let status = &report.embedded.status;
473
474 log::debug!(
475 "Report {} status: {} (attempt {}/{})",
476 report_id,
477 status,
478 attempts,
479 max_attempts
480 );
481
482 match status {
483 ReportStatus::Completed => {
484 log::info!("Report {} completed successfully", report_id);
485 return Ok(report);
486 }
487 ReportStatus::Failed => {
488 return Err(VeracodeError::InvalidResponse(format!(
489 "Report generation failed for report ID: {}",
490 report_id
491 )));
492 }
493 ReportStatus::Queued | ReportStatus::Submitted | ReportStatus::Processing => {
494 if attempts >= max_attempts {
495 return Err(VeracodeError::InvalidResponse(format!(
496 "Report polling timeout after {} attempts. Status: {}",
497 attempts, status
498 )));
499 }
500
501 log::debug!("Report still processing, waiting {} seconds...", delay_secs);
502 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs)).await;
503
504 delay_secs = std::cmp::min(delay_secs.saturating_mul(2), 30);
506 }
507 }
508 }
509 }
510
511 pub async fn get_all_audit_log_pages(
534 &self,
535 report_id: &str,
536 ) -> Result<Vec<AuditLogEntry>, VeracodeError> {
537 let mut all_logs = Vec::new();
538
539 let initial_report = self.get_audit_report(report_id, None).await?;
541
542 if initial_report.embedded.status != ReportStatus::Completed {
544 return Err(VeracodeError::InvalidResponse(format!(
545 "Report is not completed. Status: {}",
546 initial_report.embedded.status
547 )));
548 }
549
550 let page_metadata = match initial_report.embedded.page_metadata {
552 Some(metadata) if metadata.total_elements > 0 => metadata,
553 Some(metadata) => {
554 log::info!(
556 "Report completed but contains no audit log entries (0 total elements, {} total pages)",
557 metadata.total_pages
558 );
559 return Ok(all_logs); }
561 None => {
562 log::info!("Report completed but contains no audit log entries (no page metadata)");
564 return Ok(all_logs); }
566 };
567
568 let mut all_pages_raw = Vec::new();
570
571 let first_page = self.get_audit_report(report_id, Some(0)).await?;
573 all_pages_raw.push(first_page.embedded.audit_logs.clone());
574
575 log::info!(
576 "Retrieved page 1/{} ({} total)",
577 page_metadata.total_pages,
578 page_metadata.total_elements
579 );
580
581 if page_metadata.total_pages > 1 {
583 for page_num in 1..page_metadata.total_pages {
584 log::debug!(
585 "Retrieving page {}/{}",
586 page_num.saturating_add(1),
587 page_metadata.total_pages
588 );
589
590 let page_response = self.get_audit_report(report_id, Some(page_num)).await?;
591 all_pages_raw.push(page_response.embedded.audit_logs.clone());
592
593 log::info!(
594 "Retrieved page {}/{}",
595 page_num.saturating_add(1),
596 page_metadata.total_pages
597 );
598 }
599 }
600
601 let mut conversion_stats: (u32, u32) = (0, 0); let mut total_entries: u32 = 0;
604
605 for page_value in all_pages_raw {
606 if let Some(logs_array) = page_value.as_array() {
607 for log_value in logs_array {
608 total_entries = total_entries.saturating_add(1);
609
610 let raw_log =
612 serde_json::to_string(log_value).unwrap_or_else(|_| "{}".to_string());
613
614 let log_hash = generate_log_hash(&raw_log);
616
617 let timestamp_utc = if let Ok(extractor) =
619 serde_json::from_value::<TimestampExtractor>(log_value.clone())
620 {
621 if let Some(timestamp) = extractor.timestamp {
622 match convert_regional_timestamp_to_utc(×tamp, &self.region) {
623 Some(utc) => {
624 conversion_stats.0 = conversion_stats.0.saturating_add(1);
625 Some(utc)
626 }
627 None => {
628 log::warn!("Failed to convert timestamp to UTC: {}", timestamp);
629 conversion_stats.1 = conversion_stats.1.saturating_add(1);
630 None
631 }
632 }
633 } else {
634 None
635 }
636 } else {
637 None
638 };
639
640 all_logs.push(AuditLogEntry {
642 raw_log,
643 timestamp_utc,
644 log_hash,
645 });
646 }
647 }
648 }
649
650 log::info!(
651 "Successfully processed {} audit log entries across {} pages",
652 total_entries,
653 page_metadata.total_pages
654 );
655
656 let (region_name, source_timezone) = match self.region {
657 VeracodeRegion::Commercial => (
658 "Commercial (api.veracode.com)",
659 "America/New_York (EST/EDT, UTC-5/-4)",
660 ),
661 VeracodeRegion::European => (
662 "European (api.veracode.eu)",
663 "Europe/Berlin (CET/CEST, UTC+1/+2)",
664 ),
665 VeracodeRegion::Federal => (
666 "Federal (api.veracode.us)",
667 "America/New_York (EST/EDT, UTC-5/-4)",
668 ),
669 };
670
671 log::info!(
672 "Converted {} timestamps from {} to UTC - Region: {} ({} failures)",
673 conversion_stats.0,
674 source_timezone,
675 region_name,
676 conversion_stats.1
677 );
678
679 log::info!(
680 "Generated xxHash hashes for {} log entries (optimized: 10-50x faster than SHA256, zero cloning)",
681 total_entries
682 );
683
684 Ok(all_logs)
685 }
686
687 pub async fn get_audit_logs(
704 &self,
705 request: &AuditReportRequest,
706 ) -> Result<serde_json::Value, VeracodeError> {
707 log::info!(
709 "Generating audit report for date range: {} to {}",
710 request.start_date,
711 request.end_date.as_deref().unwrap_or("now")
712 );
713 let report_id = self.generate_audit_report(request).await?;
714 log::info!("Report generated with ID: {}", report_id);
715
716 log::info!("Polling for report completion...");
718 let completed_report = self.poll_report_status(&report_id, None, None).await?;
719 log::info!(
720 "Report completed at: {}",
721 completed_report
722 .embedded
723 .date_report_completed
724 .as_deref()
725 .unwrap_or("unknown")
726 );
727
728 log::info!("Retrieving all audit log pages...");
730 let mut all_logs = self.get_all_audit_log_pages(&report_id).await?;
731
732 log::info!(
734 "Sorting {} audit logs by timestamp (oldest to newest)...",
735 all_logs.len()
736 );
737 all_logs.sort_by(|a, b| {
738 match (&a.timestamp_utc, &b.timestamp_utc) {
739 (Some(ts_a), Some(ts_b)) => {
741 let parsed_a = NaiveDateTime::parse_from_str(ts_a, "%Y-%m-%d %H:%M:%S%.f")
744 .or_else(|_| NaiveDateTime::parse_from_str(ts_a, "%Y-%m-%d %H:%M:%S"));
745 let parsed_b = NaiveDateTime::parse_from_str(ts_b, "%Y-%m-%d %H:%M:%S%.f")
746 .or_else(|_| NaiveDateTime::parse_from_str(ts_b, "%Y-%m-%d %H:%M:%S"));
747
748 match (parsed_a, parsed_b) {
749 (Ok(dt_a), Ok(dt_b)) => dt_a.cmp(&dt_b), (Ok(_), Err(_)) => std::cmp::Ordering::Less, (Err(_), Ok(_)) => std::cmp::Ordering::Greater, (Err(_), Err(_)) => std::cmp::Ordering::Equal, }
754 }
755 (Some(_), None) => std::cmp::Ordering::Less,
757 (None, Some(_)) => std::cmp::Ordering::Greater,
759 (None, None) => std::cmp::Ordering::Equal,
761 }
762 });
763 log::info!("Logs sorted successfully (oldest to newest)");
764
765 let json_logs = serde_json::to_value(&all_logs)?;
767 log::info!(
768 "Successfully retrieved {} total audit log entries",
769 all_logs.len()
770 );
771
772 Ok(json_logs)
773 }
774}
775
776#[derive(Debug, thiserror::Error)]
778#[must_use = "Need to handle all error enum types."]
779pub enum ReportingError {
780 #[error("Veracode API error: {0}")]
782 VeracodeApi(#[from] VeracodeError),
783
784 #[error("Invalid date format: {0}")]
786 InvalidDate(String),
787
788 #[error("Date range exceeds maximum allowed: {0}")]
790 DateRangeExceeded(String),
791}
792
793#[cfg(test)]
794#[allow(clippy::expect_used)]
795mod tests {
796 use super::*;
797
798 #[test]
799 fn test_audit_report_request_new() {
800 let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()));
801
802 assert_eq!(request.report_type, "AUDIT");
803 assert_eq!(request.start_date, "2025-01-01");
804 assert_eq!(request.end_date, Some("2025-01-31".to_string()));
805 assert!(request.audit_action.is_none());
806 assert!(request.action_type.is_none());
807 }
808
809 #[test]
810 fn test_audit_report_request_with_filters() {
811 let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()))
812 .with_audit_actions(vec!["Delete".to_string(), "Create".to_string()])
813 .with_action_types(vec!["Admin".to_string()]);
814
815 assert_eq!(
816 request.audit_action,
817 Some(vec!["Delete".to_string(), "Create".to_string()])
818 );
819 assert_eq!(request.action_type, Some(vec!["Admin".to_string()]));
820 }
821
822 #[test]
823 fn test_audit_report_request_serialization() {
824 let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()));
825 let json = serde_json::to_string(&request).expect("should serialize to json");
826
827 assert!(json.contains("\"report_type\":\"AUDIT\""));
828 assert!(json.contains("\"start_date\":\"2025-01-01\""));
829 assert!(json.contains("\"end_date\":\"2025-01-31\""));
830 }
831
832 #[test]
833 fn test_audit_report_request_serialization_without_optional_fields() {
834 let request = AuditReportRequest::new("2025-01-01", None);
835 let json = serde_json::to_string(&request).expect("should serialize to json");
836
837 assert!(!json.contains("end_date"));
839 assert!(!json.contains("audit_action"));
840 assert!(!json.contains("action_type"));
841 }
842
843 #[test]
844 fn test_convert_european_timezone_winter() {
845 let result =
847 convert_regional_timestamp_to_utc("2025-01-15 10:00:00.000", &VeracodeRegion::European);
848 assert!(result.is_some());
849 assert_eq!(
852 result.expect("should convert timestamp"),
853 "2025-01-15 09:00:00"
854 );
855 }
856
857 #[test]
858 fn test_convert_european_timezone_summer() {
859 let result =
861 convert_regional_timestamp_to_utc("2025-06-15 10:00:00.000", &VeracodeRegion::European);
862 assert!(result.is_some());
863 assert_eq!(
865 result.expect("should convert timestamp"),
866 "2025-06-15 08:00:00"
867 );
868 }
869
870 #[test]
871 fn test_convert_commercial_timezone_winter() {
872 let result = convert_regional_timestamp_to_utc(
874 "2025-01-15 14:30:00.000",
875 &VeracodeRegion::Commercial,
876 );
877 assert!(result.is_some());
878 assert_eq!(
880 result.expect("should convert timestamp"),
881 "2025-01-15 19:30:00"
882 );
883 }
884
885 #[test]
886 fn test_convert_commercial_timezone_summer() {
887 let result = convert_regional_timestamp_to_utc(
889 "2025-06-15 14:30:00.000",
890 &VeracodeRegion::Commercial,
891 );
892 assert!(result.is_some());
893 assert_eq!(
895 result.expect("should convert timestamp"),
896 "2025-06-15 18:30:00"
897 );
898 }
899
900 #[test]
901 fn test_convert_federal_timezone_winter() {
902 let result =
904 convert_regional_timestamp_to_utc("2025-12-15 14:30:00.000", &VeracodeRegion::Federal);
905 assert!(result.is_some());
906 assert_eq!(
908 result.expect("should convert timestamp"),
909 "2025-12-15 19:30:00"
910 );
911 }
912
913 #[test]
914 fn test_convert_timezone_without_milliseconds() {
915 let result =
917 convert_regional_timestamp_to_utc("2025-01-15 10:00:00", &VeracodeRegion::European);
918 assert!(result.is_some());
919 assert_eq!(
921 result.expect("should convert timestamp"),
922 "2025-01-15 09:00:00"
923 );
924 }
925
926 #[test]
927 fn test_convert_timezone_variable_milliseconds() {
928 let result =
930 convert_regional_timestamp_to_utc("2025-01-15 10:00:00.1", &VeracodeRegion::European);
931 assert!(result.is_some());
932
933 let result =
934 convert_regional_timestamp_to_utc("2025-01-15 10:00:00.12", &VeracodeRegion::European);
935 assert!(result.is_some());
936
937 let result = convert_regional_timestamp_to_utc(
938 "2025-01-15 10:00:00.123456",
939 &VeracodeRegion::European,
940 );
941 assert!(result.is_some());
942 }
943
944 #[test]
945 fn test_convert_timezone_invalid_format() {
946 let result = convert_regional_timestamp_to_utc("invalid", &VeracodeRegion::European);
948 assert!(result.is_none());
949
950 let result =
951 convert_regional_timestamp_to_utc("2025-13-45 25:99:99", &VeracodeRegion::Commercial);
952 assert!(result.is_none());
953 }
954}