Skip to main content

veracode_platform/
reporting.rs

1//! Veracode Reporting API
2//!
3//! This module provides access to the Veracode Reporting REST API for retrieving
4//! audit logs and generating compliance reports.
5use crate::json_validator::{MAX_JSON_DEPTH, validate_json_depth};
6use crate::{VeracodeClient, VeracodeError, VeracodeRegion};
7use async_stream::try_stream;
8use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
9use chrono_tz::America::New_York;
10use chrono_tz::Europe::Berlin;
11use futures_core::stream::Stream;
12use serde::{Deserialize, Serialize};
13use urlencoding;
14
15/// Request payload for generating an audit report
16#[derive(Debug, Clone, Serialize)]
17pub struct AuditReportRequest {
18    /// The type of report to generate (always "AUDIT" for audit logs)
19    pub report_type: String,
20    /// Start date in YYYY-MM-DD format
21    pub start_date: String,
22    /// Optional end date in YYYY-MM-DD format
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub end_date: Option<String>,
25    /// Optional list of audit actions to filter (e.g., "Delete", "Create", "Update")
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub audit_action: Option<Vec<String>>,
28    /// Optional list of action types to filter (e.g., "Login", "Admin")
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub action_type: Option<Vec<String>>,
31    /// Optional list of target user IDs to filter
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub target_user_id: Option<Vec<String>>,
34    /// Optional list of modifier user IDs to filter
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub modifier_user_id: Option<Vec<String>>,
37}
38
39impl AuditReportRequest {
40    /// Create a new audit report request with just start and end dates
41    #[must_use]
42    pub fn new(start_date: impl Into<String>, end_date: Option<String>) -> Self {
43        Self {
44            report_type: "AUDIT".to_string(),
45            start_date: start_date.into(),
46            end_date,
47            audit_action: None,
48            action_type: None,
49            target_user_id: None,
50            modifier_user_id: None,
51        }
52    }
53
54    /// Add audit action filters
55    #[must_use]
56    pub fn with_audit_actions(mut self, actions: Vec<String>) -> Self {
57        self.audit_action = Some(actions);
58        self
59    }
60
61    /// Add action type filters
62    #[must_use]
63    pub fn with_action_types(mut self, types: Vec<String>) -> Self {
64        self.action_type = Some(types);
65        self
66    }
67
68    /// Add target user ID filters
69    #[must_use]
70    pub fn with_target_users(mut self, user_ids: Vec<String>) -> Self {
71        self.target_user_id = Some(user_ids);
72        self
73    }
74
75    /// Add modifier user ID filters
76    #[must_use]
77    pub fn with_modifier_users(mut self, user_ids: Vec<String>) -> Self {
78        self.modifier_user_id = Some(user_ids);
79        self
80    }
81}
82
83/// Embedded data in generate report response
84#[derive(Debug, Clone, Deserialize)]
85pub struct GenerateReportData {
86    /// The report ID used to retrieve the generated report
87    pub id: String,
88}
89
90/// Response when generating a report
91#[derive(Debug, Clone, Deserialize)]
92pub struct GenerateReportResponse {
93    /// Embedded report data
94    #[serde(rename = "_embedded")]
95    pub embedded: GenerateReportData,
96}
97
98/// Report status values
99#[derive(Debug, Clone, PartialEq, Deserialize)]
100#[serde(rename_all = "UPPERCASE")]
101pub enum ReportStatus {
102    /// Report request has been queued
103    Queued,
104    /// Report request has been submitted
105    Submitted,
106    /// Report is being processed
107    Processing,
108    /// Report has been completed and is ready
109    Completed,
110    /// Report generation failed
111    Failed,
112}
113
114impl std::fmt::Display for ReportStatus {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            ReportStatus::Queued => write!(f, "Queued"),
118            ReportStatus::Submitted => write!(f, "Submitted"),
119            ReportStatus::Processing => write!(f, "Processing"),
120            ReportStatus::Completed => write!(f, "Completed"),
121            ReportStatus::Failed => write!(f, "Failed"),
122        }
123    }
124}
125
126/// Pagination links for navigating report pages
127#[derive(Debug, Clone, Deserialize)]
128pub struct ReportLinks {
129    /// Link to first page
130    pub first: Option<LinkHref>,
131    /// Link to previous page
132    pub prev: Option<LinkHref>,
133    /// Link to current page (self)
134    #[serde(rename = "self")]
135    pub self_link: Option<LinkHref>,
136    /// Link to next page
137    pub next: Option<LinkHref>,
138    /// Link to last page
139    pub last: Option<LinkHref>,
140}
141
142/// A link with href field
143#[derive(Debug, Clone, Deserialize)]
144pub struct LinkHref {
145    /// The URL path for the link
146    pub href: String,
147}
148
149/// Page metadata for pagination
150#[derive(Debug, Clone, Deserialize)]
151pub struct PageMetadata {
152    /// Current page number (0-indexed)
153    pub number: u32,
154    /// Number of items per page
155    pub size: u32,
156    /// Total number of audit log entries across all pages
157    pub total_elements: u32,
158    /// Total number of pages
159    pub total_pages: u32,
160}
161
162/// A single audit log entry (optimized for minimal deserialization)
163///
164/// This struct only deserializes the timestamp field and keeps the rest as raw JSON
165/// to minimize parsing overhead and memory allocations. The hash is computed using
166/// xxHash (much faster than SHA256 for duplicate detection).
167#[derive(Debug, Clone, Serialize)]
168pub struct AuditLogEntry {
169    /// Raw JSON string of the log entry (as received from API)
170    pub raw_log: String,
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the API request fails, the resource is not found,
175    /// or authentication/authorization fails.
176    /// Timestamp converted to UTC (computed from the timestamp field in `raw_log`)
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub timestamp_utc: Option<String>,
179    /// xxHash (128-bit) of the raw log entry for fast duplicate detection
180    pub log_hash: String,
181}
182
183/// Helper struct to extract only the timestamp during deserialization
184#[derive(Debug, Deserialize)]
185struct TimestampExtractor {
186    timestamp: Option<String>,
187}
188
189/// Report data embedded in the response
190#[derive(Debug, Clone, Deserialize)]
191pub struct ReportData {
192    /// Report ID
193    pub id: String,
194    /// Report type (always "AUDIT" for audit reports)
195    pub report_type: String,
196    /// Current status of the report
197    pub status: ReportStatus,
198    /// User who requested the report
199    pub requested_by_user: String,
200    /// Account ID that requested the report
201    pub requested_by_account: u64,
202    /// Date when report was requested
203    pub date_report_requested: String,
204    /// Date when report was completed (null if not completed)
205    pub date_report_completed: Option<String>,
206    /// Date when report expires (null if not completed)
207    pub report_expiration_date: Option<String>,
208    /// Array of audit log entries (raw JSON, processed later for efficiency)
209    pub audit_logs: serde_json::Value,
210    /// Links for pagination (null if not completed)
211    #[serde(rename = "_links")]
212    pub links: Option<ReportLinks>,
213    /// Page metadata (null if not completed)
214    pub page_metadata: Option<PageMetadata>,
215}
216
217/// Full report response with embedded data
218#[derive(Debug, Clone, Deserialize)]
219pub struct ReportResponse {
220    /// Embedded report data
221    #[serde(rename = "_embedded")]
222    pub embedded: ReportData,
223}
224
225/// Convert a timestamp from region-specific timezone to UTC
226///
227/// Each Veracode API region returns timestamps in its corresponding timezone:
228///
229/// # Errors
230///
231/// Returns an error if the API request fails, the resource is not found,
232/// or authentication/authorization fails.
233/// - **Commercial** (api.veracode.com): `America/New_York` (US-East-1)
234///   - EST (Eastern Standard Time): UTC-5 (winter)
235///   - EDT (Eastern Daylight Time): UTC-4 (summer)
236/// - **European** (api.veracode.eu): Europe/Berlin (eu-central-1)
237///   - CET (Central European Time): UTC+1 (winter)
238///   - CEST (Central European Summer Time): UTC+2 (summer)
239///
240/// # Errors
241///
242/// Returns an error if the API request fails, the resource is not found,
243/// or authentication/authorization fails.
244/// - **Federal** (api.veracode.us): `America/New_York` (US-East-1)
245///   - EST/EDT same as Commercial
246///
247/// This function automatically handles Daylight Saving Time (DST) transitions
248/// for each region using the IANA timezone database.
249///
250/// # Arguments
251///
252/// * `timestamp_str` - Timestamp string in format "YYYY-MM-DD HH:MM:SS.sss"
253/// * `region` - The Veracode region determining source timezone
254///
255/// # Returns
256///
257/// UTC timestamp string in same format, or None if parsing fails
258///
259/// # Examples
260///
261/// ```ignore
262/// use veracode_platform::VeracodeRegion;
263///
264/// // European region: Summer timestamp (CEST, UTC+2)
265/// let utc = convert_regional_timestamp_to_utc("2025-06-15 14:30:00.000", &VeracodeRegion::European);
266/// assert_eq!(utc, Some("2025-06-15 12:30:00".to_string())); // 14:30 CEST = 12:30 UTC
267///
268/// // Commercial region: Winter timestamp (EST, UTC-5)
269/// let utc = convert_regional_timestamp_to_utc("2025-12-15 14:30:00.000", &VeracodeRegion::Commercial);
270/// assert_eq!(utc, Some("2025-12-15 19:30:00".to_string())); // 14:30 EST = 19:30 UTC
271/// ```
272fn convert_regional_timestamp_to_utc(
273    timestamp_str: &str,
274    region: &VeracodeRegion,
275) -> Option<String> {
276    // Parse timestamp string - handle variable-length milliseconds
277    let has_millis = timestamp_str.contains('.');
278
279    // Parse the base datetime without milliseconds
280    let naive_dt = if has_millis {
281        // Try to parse with variable-length fractional seconds
282        // The %.f format is flexible and handles 1-9 digits
283        NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S%.f").ok()?
284    } else {
285        NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S").ok()?
286    };
287
288    // Convert from region-specific timezone to UTC
289    let utc_time = match region {
290        VeracodeRegion::European => {
291            // European region uses Europe/Berlin timezone (CET/CEST)
292            // Use earliest() to handle ambiguous times during DST fall-back
293            let regional_time: DateTime<_> = Berlin.from_local_datetime(&naive_dt).earliest()?;
294            regional_time.with_timezone(&Utc)
295        }
296        VeracodeRegion::Commercial | VeracodeRegion::Federal => {
297            // Commercial and Federal regions use America/New_York timezone (EST/EDT)
298            // Use earliest() to handle ambiguous times during DST fall-back
299            let regional_time: DateTime<_> = New_York.from_local_datetime(&naive_dt).earliest()?;
300            regional_time.with_timezone(&Utc)
301        }
302    };
303
304    // Format back to string (preserve original millisecond precision)
305    if has_millis {
306        // Format with the same number of decimal places as input
307        let formatted = utc_time.format("%Y-%m-%d %H:%M:%S%.f").to_string();
308        // Ensure we preserve the original precision
309        Some(formatted)
310    } else {
311        Some(utc_time.format("%Y-%m-%d %H:%M:%S").to_string())
312    }
313}
314
315/// Generate a fast hash of a raw log entry JSON string for duplicate detection
316///
317///
318/// # Errors
319///
320/// Returns an error if the API request fails, the resource is not found,
321/// or authentication/authorization fails.
322/// Uses xxHash (`xxh3_128`) which is significantly faster than SHA256 while still
323/// providing excellent collision resistance for deduplication purposes. This is
324/// NOT a cryptographic hash - use only for duplicate detection, not security.
325///
326/// Performance comparison vs SHA256:
327/// - xxHash: ~10-50x faster than SHA256
328/// - Still has excellent collision resistance for duplicate detection
329/// - Returns 32 hex characters (128-bit hash)
330///
331/// # Arguments
332///
333/// * `raw_json` - The raw JSON string of the log entry
334///
335/// # Returns
336///
337/// Hex-encoded xxHash3 (128-bit) hash string (32 characters)
338///
339/// # Examples
340///
341/// ```ignore
342/// let hash = generate_log_hash(r#"{"timestamp":"2025-01-01 12:00:00.000"}"#);
343/// assert_eq!(hash.len(), 32); // xxh3_128 produces 32 hex characters
344/// ```
345fn generate_log_hash(raw_json: &str) -> String {
346    use xxhash_rust::xxh3::xxh3_128;
347
348    // Hash the raw JSON bytes (extremely fast!)
349    let hash = xxh3_128(raw_json.as_bytes());
350
351    // Convert to hex string (128-bit = 32 hex chars)
352    format!("{:032x}", hash)
353}
354
355/// Sort log JSON values by `timestamp_utc` field (oldest first)
356fn sort_log_values_by_timestamp(logs: &mut [serde_json::Value]) {
357    logs.sort_by(|a, b| {
358        let ts_a = a.get("timestamp_utc").and_then(|v| v.as_str());
359        let ts_b = b.get("timestamp_utc").and_then(|v| v.as_str());
360        match (ts_a, ts_b) {
361            (Some(ta), Some(tb)) => ta.cmp(tb),
362            (Some(_), None) => std::cmp::Ordering::Less,
363            (None, Some(_)) => std::cmp::Ordering::Greater,
364            (None, None) => std::cmp::Ordering::Equal,
365        }
366    });
367}
368
369/// The Reporting API interface
370#[derive(Clone)]
371pub struct ReportingApi {
372    client: VeracodeClient,
373    region: VeracodeRegion,
374}
375
376impl ReportingApi {
377    /// Create a new Reporting API instance
378    #[must_use]
379    pub fn new(client: VeracodeClient) -> Self {
380        let region = client.config().region;
381        Self { client, region }
382    }
383
384    /// Generate an audit report (step 1 of the process)
385    ///
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if the API request fails, the resource is not found,
390    /// or authentication/authorization fails.
391    /// This sends a request to generate the report. The API returns a `report_id`
392    /// which can be used to retrieve the report after it's generated.
393    ///
394    /// # Arguments
395    ///
396    /// * `request` - The audit report request parameters
397    ///
398    /// # Returns
399    ///
400    /// The report ID that can be used to retrieve the generated report
401    ///
402    /// # Errors
403    ///
404    /// Returns `VeracodeError` if the request fails
405    pub async fn generate_audit_report(
406        &self,
407        request: &AuditReportRequest,
408    ) -> Result<String, VeracodeError> {
409        let response = self
410            .client
411            .post("/appsec/v1/analytics/report", Some(request))
412            .await?;
413
414        let response_text = response.text().await?;
415        log::debug!("Generate report API response: {}", response_text);
416
417        // Validate JSON depth before parsing to prevent DoS attacks
418        validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
419            VeracodeError::InvalidResponse(format!("JSON validation failed: {}", e))
420        })?;
421
422        let generate_response: GenerateReportResponse = serde_json::from_str(&response_text)?;
423        Ok(generate_response.embedded.id)
424    }
425
426    /// Retrieve a generated audit report (step 2 of the process)
427    ///
428    /// This retrieves the report content. The report may still be processing,
429    /// so check the status field in the response.
430    ///
431    /// # Arguments
432    ///
433    /// * `report_id` - The report ID returned from `generate_audit_report`
434    /// * `page` - Optional page number (0-indexed) for pagination
435    ///
436    /// # Returns
437    ///
438    /// The report response with status and audit log data
439    ///
440    /// # Errors
441    ///
442    /// Returns `VeracodeError` if the request fails
443    pub async fn get_audit_report(
444        &self,
445        report_id: &str,
446        page: Option<u32>,
447    ) -> Result<ReportResponse, VeracodeError> {
448        // URL-encode the report_id to prevent injection attacks
449        let encoded_report_id = urlencoding::encode(report_id);
450
451        let endpoint = if let Some(page_num) = page {
452            format!("/appsec/v1/analytics/report/{encoded_report_id}?page={page_num}")
453        } else {
454            format!("/appsec/v1/analytics/report/{encoded_report_id}")
455        };
456
457        let response = self.client.get(&endpoint, None).await?;
458        let response_text = response.text().await?;
459        log::debug!("Get audit report API response: {}", response_text);
460
461        // Validate JSON depth before parsing to prevent DoS attacks
462        validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
463            VeracodeError::InvalidResponse(format!("JSON validation failed: {}", e))
464        })?;
465
466        let report_response: ReportResponse = serde_json::from_str(&response_text)?;
467        Ok(report_response)
468    }
469
470    /// Poll for report status until it's completed or failed
471    ///
472    /// This method polls the report status with exponential backoff until
473    /// the report is either completed or failed.
474    ///
475    /// # Arguments
476    ///
477    /// * `report_id` - The report ID to poll
478    /// * `max_attempts` - Maximum number of polling attempts (default: 30)
479    /// * `initial_delay_secs` - Initial delay between polls in seconds (default: 2)
480    ///
481    /// # Returns
482    ///
483    /// The completed report response
484    ///
485    /// # Errors
486    ///
487    /// Returns `VeracodeError` if polling fails or report generation fails
488    pub async fn poll_report_status(
489        &self,
490        report_id: &str,
491        max_attempts: Option<u32>,
492        initial_delay_secs: Option<u64>,
493    ) -> Result<ReportResponse, VeracodeError> {
494        let max_attempts = max_attempts.unwrap_or(30);
495        let initial_delay = initial_delay_secs.unwrap_or(2);
496
497        let mut attempts: u32 = 0;
498        let mut delay_secs = initial_delay;
499
500        loop {
501            attempts = attempts.saturating_add(1);
502
503            // Get current report status
504            let report = self.get_audit_report(report_id, None).await?;
505            let status = &report.embedded.status;
506
507            log::debug!(
508                "Report {} status: {} (attempt {}/{})",
509                report_id,
510                status,
511                attempts,
512                max_attempts
513            );
514
515            match status {
516                ReportStatus::Completed => {
517                    log::info!("Report {} completed successfully", report_id);
518                    return Ok(report);
519                }
520                ReportStatus::Failed => {
521                    return Err(VeracodeError::InvalidResponse(format!(
522                        "Report generation failed for report ID: {}",
523                        report_id
524                    )));
525                }
526                ReportStatus::Queued | ReportStatus::Submitted | ReportStatus::Processing => {
527                    if attempts >= max_attempts {
528                        return Err(VeracodeError::InvalidResponse(format!(
529                            "Report polling timeout after {} attempts. Status: {}",
530                            attempts, status
531                        )));
532                    }
533
534                    log::debug!("Report still processing, waiting {} seconds...", delay_secs);
535                    tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs)).await;
536
537                    // Exponential backoff with max delay of 30 seconds
538                    delay_secs = std::cmp::min(delay_secs.saturating_mul(2), 30);
539                }
540            }
541        }
542    }
543
544    /// Retrieve all audit logs across all pages (OPTIMIZED)
545    ///
546    /// This method handles pagination automatically and collects all audit log
547    /// entries from all pages into a single vector. It uses optimized processing
548    /// that only deserializes the timestamp field and keeps raw JSON for efficiency.
549    ///
550    /// Performance optimizations:
551    /// - Minimal deserialization: Only extracts timestamp field
552    /// - Zero cloning: Keeps raw JSON strings instead of parsing all fields
553    /// - Fast hashing: Uses xxHash (10-50x faster than SHA256) for duplicate detection
554    ///
555    /// # Arguments
556    ///
557    /// * `report_id` - The report ID (must be in COMPLETED status)
558    ///
559    /// # Returns
560    ///
561    /// A vector containing all audit log entries from all pages
562    ///
563    /// # Errors
564    ///
565    /// Returns `VeracodeError` if any page retrieval fails
566    pub async fn get_all_audit_log_pages(
567        &self,
568        report_id: &str,
569    ) -> Result<Vec<AuditLogEntry>, VeracodeError> {
570        let mut all_logs = Vec::new();
571
572        // Get report status without page parameter first
573        let initial_report = self.get_audit_report(report_id, None).await?;
574
575        // Check if report is completed
576        if initial_report.embedded.status != ReportStatus::Completed {
577            return Err(VeracodeError::InvalidResponse(format!(
578                "Report is not completed. Status: {}",
579                initial_report.embedded.status
580            )));
581        }
582
583        // Check if there are any results
584        let page_metadata = match initial_report.embedded.page_metadata {
585            Some(metadata) if metadata.total_elements > 0 => metadata,
586            Some(metadata) => {
587                // Report has metadata but no elements (total_elements = 0)
588                log::info!(
589                    "Report completed but contains no audit log entries (0 total elements, {} total pages)",
590                    metadata.total_pages
591                );
592                return Ok(all_logs); // Return empty vector
593            }
594            None => {
595                // No metadata at all
596                log::info!("Report completed but contains no audit log entries (no page metadata)");
597                return Ok(all_logs); // Return empty vector
598            }
599        };
600
601        // Collect all pages of raw JSON
602        let mut all_pages_raw = Vec::new();
603
604        // Get first page
605        let first_page = self.get_audit_report(report_id, Some(0)).await?;
606        all_pages_raw.push(first_page.embedded.audit_logs.clone());
607
608        log::info!(
609            "Retrieved page 1/{} ({} total)",
610            page_metadata.total_pages,
611            page_metadata.total_elements
612        );
613
614        // If there are more pages, retrieve them
615        if page_metadata.total_pages > 1 {
616            for page_num in 1..page_metadata.total_pages {
617                log::debug!(
618                    "Retrieving page {}/{}",
619                    page_num.saturating_add(1),
620                    page_metadata.total_pages
621                );
622
623                let page_response = self.get_audit_report(report_id, Some(page_num)).await?;
624                all_pages_raw.push(page_response.embedded.audit_logs.clone());
625
626                log::info!(
627                    "Retrieved page {}/{}",
628                    page_num.saturating_add(1),
629                    page_metadata.total_pages
630                );
631            }
632        }
633
634        // Process all raw log entries efficiently
635        let mut conversion_stats: (u32, u32) = (0, 0); // (successes, failures)
636        let mut serialization_stats: (u32, u32) = (0, 0); // (successes, failures)
637        let mut total_entries: u32 = 0;
638
639        for page_value in all_pages_raw {
640            if let Some(logs_array) = page_value.as_array() {
641                for log_value in logs_array {
642                    total_entries = total_entries.saturating_add(1);
643
644                    // Get raw JSON string (canonical form for hashing)
645                    let raw_log = match serde_json::to_string(log_value) {
646                        Ok(json_str) => {
647                            serialization_stats.0 = serialization_stats.0.saturating_add(1);
648                            json_str
649                        }
650                        Err(e) => {
651                            log::error!(
652                                "Failed to serialize audit log entry {}: {}. Entry will be replaced with empty object.",
653                                total_entries,
654                                e
655                            );
656                            serialization_stats.1 = serialization_stats.1.saturating_add(1);
657                            "{}".to_string()
658                        }
659                    };
660
661                    // Generate hash from raw JSON (extremely fast with xxHash!)
662                    let log_hash = generate_log_hash(&raw_log);
663
664                    // Extract only the timestamp field for UTC conversion
665                    let timestamp_utc = if let Ok(extractor) =
666                        serde_json::from_value::<TimestampExtractor>(log_value.clone())
667                    {
668                        if let Some(timestamp) = extractor.timestamp {
669                            match convert_regional_timestamp_to_utc(&timestamp, &self.region) {
670                                Some(utc) => {
671                                    conversion_stats.0 = conversion_stats.0.saturating_add(1);
672                                    Some(utc)
673                                }
674                                None => {
675                                    log::warn!("Failed to convert timestamp to UTC: {}", timestamp);
676                                    conversion_stats.1 = conversion_stats.1.saturating_add(1);
677                                    None
678                                }
679                            }
680                        } else {
681                            None
682                        }
683                    } else {
684                        None
685                    };
686
687                    // Create optimized log entry with minimal allocations
688                    all_logs.push(AuditLogEntry {
689                        raw_log,
690                        timestamp_utc,
691                        log_hash,
692                    });
693                }
694            }
695        }
696
697        log::info!(
698            "Successfully processed {} audit log entries across {} pages",
699            total_entries,
700            page_metadata.total_pages
701        );
702
703        let (region_name, source_timezone) = match self.region {
704            VeracodeRegion::Commercial => (
705                "Commercial (api.veracode.com)",
706                "America/New_York (EST/EDT, UTC-5/-4)",
707            ),
708            VeracodeRegion::European => (
709                "European (api.veracode.eu)",
710                "Europe/Berlin (CET/CEST, UTC+1/+2)",
711            ),
712            VeracodeRegion::Federal => (
713                "Federal (api.veracode.us)",
714                "America/New_York (EST/EDT, UTC-5/-4)",
715            ),
716        };
717
718        log::info!(
719            "Converted {} timestamps from {} to UTC - Region: {} ({} failures)",
720            conversion_stats.0,
721            source_timezone,
722            region_name,
723            conversion_stats.1
724        );
725
726        log::info!(
727            "Generated xxHash hashes for {} log entries (optimized: 10-50x faster than SHA256, zero cloning)",
728            total_entries
729        );
730
731        if serialization_stats.1 > 0 {
732            log::warn!(
733                "Serialization statistics: {} successful, {} failed (replaced with empty objects)",
734                serialization_stats.0,
735                serialization_stats.1
736            );
737        } else {
738            log::info!(
739                "Serialization statistics: {} successful, 0 failed",
740                serialization_stats.0
741            );
742        }
743
744        Ok(all_logs)
745    }
746
747    /// Convenience method to generate and retrieve audit logs in one call
748    ///
749    /// This method combines report generation, status polling, and pagination
750    /// to retrieve all audit logs. It's the recommended way to retrieve audit logs.
751    ///
752    /// # Arguments
753    ///
754    /// * `request` - The audit report request parameters
755    ///
756    /// # Returns
757    ///
758    /// The audit log data as a JSON value containing all entries from all pages
759    ///
760    /// # Errors
761    ///
762    /// Returns `VeracodeError` if the request fails
763    pub async fn get_audit_logs(
764        &self,
765        request: &AuditReportRequest,
766    ) -> Result<serde_json::Value, VeracodeError> {
767        // Step 1: Generate the report
768        log::info!(
769            "Generating audit report for date range: {} to {}",
770            request.start_date,
771            request.end_date.as_deref().unwrap_or("now")
772        );
773        let report_id = self.generate_audit_report(request).await?;
774        log::info!("Report generated with ID: {}", report_id);
775
776        // Step 2: Poll for report completion
777        log::info!("Polling for report completion...");
778        let completed_report = self.poll_report_status(&report_id, None, None).await?;
779        log::info!(
780            "Report completed at: {}",
781            completed_report
782                .embedded
783                .date_report_completed
784                .as_deref()
785                .unwrap_or("unknown")
786        );
787
788        // Step 3: Retrieve all pages
789        log::info!("Retrieving all audit log pages...");
790        let mut all_logs = self.get_all_audit_log_pages(&report_id).await?;
791
792        // Step 4: Sort logs by timestamp_utc (oldest first, newest last)
793        log::info!(
794            "Sorting {} audit logs by timestamp (oldest to newest)...",
795            all_logs.len()
796        );
797        all_logs.sort_by(|a, b| {
798            match (&a.timestamp_utc, &b.timestamp_utc) {
799                // Both have timestamps - parse and compare them
800                (Some(ts_a), Some(ts_b)) => {
801                    // Parse timestamps for comparison
802                    // Format is "YYYY-MM-DD HH:MM:SS" (possibly with milliseconds)
803                    let parsed_a = NaiveDateTime::parse_from_str(ts_a, "%Y-%m-%d %H:%M:%S%.f")
804                        .or_else(|_| NaiveDateTime::parse_from_str(ts_a, "%Y-%m-%d %H:%M:%S"));
805                    let parsed_b = NaiveDateTime::parse_from_str(ts_b, "%Y-%m-%d %H:%M:%S%.f")
806                        .or_else(|_| NaiveDateTime::parse_from_str(ts_b, "%Y-%m-%d %H:%M:%S"));
807
808                    match (parsed_a, parsed_b) {
809                        (Ok(dt_a), Ok(dt_b)) => dt_a.cmp(&dt_b), // Both parsed successfully
810                        (Ok(_), Err(_)) => std::cmp::Ordering::Less, // a is valid, b is not - a comes first
811                        (Err(_), Ok(_)) => std::cmp::Ordering::Greater, // b is valid, a is not - b comes first
812                        (Err(_), Err(_)) => std::cmp::Ordering::Equal, // Neither parsed - keep original order
813                    }
814                }
815                // Only a has timestamp - a comes first
816                (Some(_), None) => std::cmp::Ordering::Less,
817                // Only b has timestamp - b comes first
818                (None, Some(_)) => std::cmp::Ordering::Greater,
819                // Neither has timestamp - keep original order
820                (None, None) => std::cmp::Ordering::Equal,
821            }
822        });
823        log::info!("Logs sorted successfully (oldest to newest)");
824
825        // Convert to JSON for backward compatibility with veraaudit
826        let json_logs = serde_json::to_value(&all_logs)?;
827        log::info!(
828            "Successfully retrieved {} total audit log entries",
829            all_logs.len()
830        );
831
832        Ok(json_logs)
833    }
834
835    /// Stream audit logs page-by-page, yielding batches when memory threshold is reached
836    ///
837    /// This method handles report generation, polling, and pagination automatically,
838    /// yielding batches of processed log entries as they accumulate past the threshold.
839    /// Enables progressive file writes without holding the full dataset in memory.
840    ///
841    /// # Arguments
842    ///
843    /// * `request` - The audit report request parameters
844    /// * `flush_threshold_bytes` - Approximate byte threshold triggering a batch yield
845    ///
846    /// # Returns
847    ///
848    /// A stream of log entry batches (each batch is a `Vec<serde_json::Value>`)
849    ///
850    /// # Errors
851    ///
852    /// Stream items may be `Err(VeracodeError)` if any page retrieval or report generation fails
853    pub fn get_audit_logs_stream(
854        &self,
855        request: AuditReportRequest,
856        flush_threshold_bytes: usize,
857    ) -> impl Stream<Item = Result<Vec<serde_json::Value>, VeracodeError>> {
858        let api = self.clone();
859
860        try_stream! {
861            // Step 1: Generate the report
862            log::info!(
863                "Generating audit report for date range: {} to {}",
864                request.start_date,
865                request.end_date.as_deref().unwrap_or("now")
866            );
867            let report_id = api.generate_audit_report(&request).await?;
868            log::info!("Report generated with ID: {}", report_id);
869
870            // Step 2: Poll for completion
871            log::info!("Polling for report completion...");
872            let completed_report = api.poll_report_status(&report_id, None, None).await?;
873            log::info!(
874                "Report completed at: {}",
875                completed_report.embedded.date_report_completed.as_deref().unwrap_or("unknown")
876            );
877
878            // Step 3: Get page metadata
879            let initial = api.get_audit_report(&report_id, None).await?;
880            let page_metadata = match initial.embedded.page_metadata {
881                Some(ref m) if m.total_elements > 0 => m.clone(),
882                Some(ref m) => {
883                    log::info!(
884                        "Report completed but contains no audit log entries ({} total pages)",
885                        m.total_pages
886                    );
887                    return;
888                }
889                None => {
890                    log::info!("Report completed but contains no audit log entries (no page metadata)");
891                    return;
892                }
893            };
894
895            log::info!(
896                "Streaming {} entries across {} pages (flush threshold: {} bytes)",
897                page_metadata.total_elements,
898                page_metadata.total_pages,
899                flush_threshold_bytes
900            );
901
902            let mut buffer: Vec<serde_json::Value> = Vec::new();
903            let mut buffer_bytes: usize = 0;
904            let mut batch_num: usize = 0;
905
906            // Step 4: Stream pages
907            for page_num in 0..page_metadata.total_pages {
908                let page = api.get_audit_report(&report_id, Some(page_num)).await?;
909                log::debug!(
910                    "Processing page {}/{}",
911                    page_num.saturating_add(1),
912                    page_metadata.total_pages
913                );
914
915                if let Some(logs) = page.embedded.audit_logs.as_array() {
916                    for log_value in logs {
917                        // Serialize to raw JSON string
918                        let raw_log = match serde_json::to_string(log_value) {
919                            Ok(s) => s,
920                            Err(e) => {
921                                log::error!("Failed to serialize log entry: {}", e);
922                                "{}".to_string()
923                            }
924                        };
925
926                        // Generate hash
927                        let log_hash = generate_log_hash(&raw_log);
928
929                        // Extract and convert timestamp
930                        let timestamp_utc = serde_json::from_value::<TimestampExtractor>(log_value.clone())
931                            .ok()
932                            .and_then(|e| e.timestamp)
933                            .and_then(|ts| convert_regional_timestamp_to_utc(&ts, &api.region));
934
935                        let entry = AuditLogEntry { raw_log, timestamp_utc, log_hash };
936                        let entry_value = serde_json::to_value(&entry)?;
937
938                        // Track approximate byte size
939                        let entry_size = entry_value.to_string().len();
940                        buffer_bytes = buffer_bytes.saturating_add(entry_size);
941                        buffer.push(entry_value);
942
943                        // Flush when threshold exceeded
944                        if buffer_bytes >= flush_threshold_bytes {
945                            batch_num = batch_num.saturating_add(1);
946                            sort_log_values_by_timestamp(&mut buffer);
947                            log::info!(
948                                "Flushing batch {} ({} entries, ~{} bytes)",
949                                batch_num,
950                                buffer.len(),
951                                buffer_bytes
952                            );
953                            let batch = std::mem::take(&mut buffer);
954                            buffer_bytes = 0;
955                            yield batch;
956                        }
957                    }
958                }
959            }
960
961            // Yield remaining buffer
962            if !buffer.is_empty() {
963                batch_num = batch_num.saturating_add(1);
964                sort_log_values_by_timestamp(&mut buffer);
965                log::info!(
966                    "Flushing final batch {} ({} entries, ~{} bytes)",
967                    batch_num,
968                    buffer.len(),
969                    buffer_bytes
970                );
971                yield buffer;
972            }
973        }
974    }
975}
976
977/// Error type for reporting operations
978#[derive(Debug, thiserror::Error)]
979#[must_use = "Need to handle all error enum types."]
980pub enum ReportingError {
981    /// Wraps a Veracode API error
982    #[error("Veracode API error: {0}")]
983    VeracodeApi(#[from] VeracodeError),
984
985    /// Invalid date format
986    #[error("Invalid date format: {0}")]
987    InvalidDate(String),
988
989    /// Date range exceeds maximum allowed (6 months)
990    #[error("Date range exceeds maximum allowed: {0}")]
991    DateRangeExceeded(String),
992}
993
994#[cfg(test)]
995#[allow(clippy::expect_used)]
996mod tests {
997    use super::*;
998
999    #[test]
1000    fn test_audit_report_request_new() {
1001        let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()));
1002
1003        assert_eq!(request.report_type, "AUDIT");
1004        assert_eq!(request.start_date, "2025-01-01");
1005        assert_eq!(request.end_date, Some("2025-01-31".to_string()));
1006        assert!(request.audit_action.is_none());
1007        assert!(request.action_type.is_none());
1008    }
1009
1010    #[test]
1011    fn test_audit_report_request_with_filters() {
1012        let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()))
1013            .with_audit_actions(vec!["Delete".to_string(), "Create".to_string()])
1014            .with_action_types(vec!["Admin".to_string()]);
1015
1016        assert_eq!(
1017            request.audit_action,
1018            Some(vec!["Delete".to_string(), "Create".to_string()])
1019        );
1020        assert_eq!(request.action_type, Some(vec!["Admin".to_string()]));
1021    }
1022
1023    #[test]
1024    fn test_audit_report_request_serialization() {
1025        let request = AuditReportRequest::new("2025-01-01", Some("2025-01-31".to_string()));
1026        let json = serde_json::to_string(&request).expect("should serialize to json");
1027
1028        assert!(json.contains("\"report_type\":\"AUDIT\""));
1029        assert!(json.contains("\"start_date\":\"2025-01-01\""));
1030        assert!(json.contains("\"end_date\":\"2025-01-31\""));
1031    }
1032
1033    #[test]
1034    fn test_audit_report_request_serialization_without_optional_fields() {
1035        let request = AuditReportRequest::new("2025-01-01", None);
1036        let json = serde_json::to_string(&request).expect("should serialize to json");
1037
1038        // Optional fields should not be present when None
1039        assert!(!json.contains("end_date"));
1040        assert!(!json.contains("audit_action"));
1041        assert!(!json.contains("action_type"));
1042    }
1043
1044    #[test]
1045    fn test_convert_european_timezone_winter() {
1046        // Winter timestamp: CET is UTC+1
1047        let result =
1048            convert_regional_timestamp_to_utc("2025-01-15 10:00:00.000", &VeracodeRegion::European);
1049        assert!(result.is_some());
1050        // 10:00 CET = 09:00 UTC
1051        // Note: %.f format drops trailing zeros
1052        assert_eq!(
1053            result.expect("should convert timestamp"),
1054            "2025-01-15 09:00:00"
1055        );
1056    }
1057
1058    #[test]
1059    fn test_convert_european_timezone_summer() {
1060        // Summer timestamp: CEST is UTC+2
1061        let result =
1062            convert_regional_timestamp_to_utc("2025-06-15 10:00:00.000", &VeracodeRegion::European);
1063        assert!(result.is_some());
1064        // 10:00 CEST = 08:00 UTC
1065        assert_eq!(
1066            result.expect("should convert timestamp"),
1067            "2025-06-15 08:00:00"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_convert_commercial_timezone_winter() {
1073        // Winter timestamp: EST is UTC-5
1074        let result = convert_regional_timestamp_to_utc(
1075            "2025-01-15 14:30:00.000",
1076            &VeracodeRegion::Commercial,
1077        );
1078        assert!(result.is_some());
1079        // 14:30 EST = 19:30 UTC
1080        assert_eq!(
1081            result.expect("should convert timestamp"),
1082            "2025-01-15 19:30:00"
1083        );
1084    }
1085
1086    #[test]
1087    fn test_convert_commercial_timezone_summer() {
1088        // Summer timestamp: EDT is UTC-4
1089        let result = convert_regional_timestamp_to_utc(
1090            "2025-06-15 14:30:00.000",
1091            &VeracodeRegion::Commercial,
1092        );
1093        assert!(result.is_some());
1094        // 14:30 EDT = 18:30 UTC
1095        assert_eq!(
1096            result.expect("should convert timestamp"),
1097            "2025-06-15 18:30:00"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_convert_federal_timezone_winter() {
1103        // Federal uses same timezone as Commercial (America/New_York)
1104        let result =
1105            convert_regional_timestamp_to_utc("2025-12-15 14:30:00.000", &VeracodeRegion::Federal);
1106        assert!(result.is_some());
1107        // 14:30 EST = 19:30 UTC
1108        assert_eq!(
1109            result.expect("should convert timestamp"),
1110            "2025-12-15 19:30:00"
1111        );
1112    }
1113
1114    #[test]
1115    fn test_convert_timezone_without_milliseconds() {
1116        // Test without milliseconds
1117        let result =
1118            convert_regional_timestamp_to_utc("2025-01-15 10:00:00", &VeracodeRegion::European);
1119        assert!(result.is_some());
1120        // Should not have milliseconds in output
1121        assert_eq!(
1122            result.expect("should convert timestamp"),
1123            "2025-01-15 09:00:00"
1124        );
1125    }
1126
1127    #[test]
1128    fn test_convert_timezone_variable_milliseconds() {
1129        // Test with different millisecond precisions
1130        let result =
1131            convert_regional_timestamp_to_utc("2025-01-15 10:00:00.1", &VeracodeRegion::European);
1132        assert!(result.is_some());
1133
1134        let result =
1135            convert_regional_timestamp_to_utc("2025-01-15 10:00:00.12", &VeracodeRegion::European);
1136        assert!(result.is_some());
1137
1138        let result = convert_regional_timestamp_to_utc(
1139            "2025-01-15 10:00:00.123456",
1140            &VeracodeRegion::European,
1141        );
1142        assert!(result.is_some());
1143    }
1144
1145    #[test]
1146    fn test_convert_timezone_invalid_format() {
1147        // Invalid format should return None
1148        let result = convert_regional_timestamp_to_utc("invalid", &VeracodeRegion::European);
1149        assert!(result.is_none());
1150
1151        let result =
1152            convert_regional_timestamp_to_utc("2025-13-45 25:99:99", &VeracodeRegion::Commercial);
1153        assert!(result.is_none());
1154    }
1155
1156    #[test]
1157    fn test_convert_timezone_dst_fallback_ambiguous() {
1158        // Regression test for DST fall-back ambiguous time
1159        // November 5, 2028 at 1:00 AM is ambiguous in America/New_York
1160        // (occurs twice when clocks fall back from 2:00 AM to 1:00 AM)
1161        // The function should use earliest() to resolve ambiguity
1162        let result =
1163            convert_regional_timestamp_to_utc("2028-11-05 01:00:00", &VeracodeRegion::Commercial);
1164        assert!(
1165            result.is_some(),
1166            "Should handle DST fall-back ambiguous time"
1167        );
1168
1169        // Verify conversion produces valid UTC timestamp
1170        let utc = result.expect("result should be Some as asserted above");
1171        assert!(utc.len() >= 19, "UTC timestamp should be well-formed");
1172        assert!(utc.starts_with("2028-11-05"), "Date should be preserved");
1173    }
1174
1175    // ============================================================================
1176    // SECURITY TESTS: Property-Based Testing with Proptest
1177    // ============================================================================
1178
1179    mod security_tests {
1180        use super::*;
1181        use proptest::prelude::*;
1182
1183        // ========================================================================
1184        // SECURITY TEST 1: Timestamp Parsing Edge Cases
1185        // ========================================================================
1186        // Tests: convert_regional_timestamp_to_utc
1187        // Goals: Ensure robust handling of malformed timestamps, extreme dates,
1188        //        leap years, DST transitions, and edge cases
1189
1190        proptest! {
1191            #![proptest_config(ProptestConfig {
1192                cases: if cfg!(miri) { 5 } else { 1000 },
1193                failure_persistence: None,
1194                .. ProptestConfig::default()
1195            })]
1196
1197            /// Property: Valid timestamps should always convert successfully
1198            /// Tests legitimate date ranges that should work
1199            #[test]
1200            fn proptest_valid_timestamp_conversion(
1201                year in 2000u32..2100u32,
1202                month in 1u32..=12u32,
1203                day in 1u32..=28u32, // Safe range that works for all months
1204                hour in prop::sample::select(vec![0u32, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]), // Avoid hour 2 (DST transition)
1205                minute in 0u32..=59u32,
1206                second in 0u32..=59u32,
1207                region in prop_oneof![
1208                    Just(VeracodeRegion::Commercial),
1209                    Just(VeracodeRegion::European),
1210                    Just(VeracodeRegion::Federal),
1211                ]
1212            ) {
1213                let timestamp = format!(
1214                    "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
1215                    year, month, day, hour, minute, second
1216                );
1217
1218                let result = convert_regional_timestamp_to_utc(&timestamp, &region);
1219
1220                // Valid timestamps should always convert (DST transition hour 2 is avoided)
1221                prop_assert!(result.is_some(), "Failed to convert valid timestamp: {}", timestamp);
1222
1223                // Result should be well-formed
1224                if let Some(utc) = result {
1225                    prop_assert!(utc.len() >= 19, "UTC timestamp too short: {}", utc);
1226                    prop_assert!(utc.contains('-'), "UTC timestamp missing date separator");
1227                    prop_assert!(utc.contains(':'), "UTC timestamp missing time separator");
1228                }
1229            }
1230
1231            /// Property: Malformed timestamps should fail gracefully
1232            /// Tests that invalid input doesn't panic or cause undefined behavior
1233            #[test]
1234            fn proptest_malformed_timestamp_handling(
1235                input in "\\PC{0,256}", // Any Unicode string up to 256 chars
1236            ) {
1237                // Should never panic, even on malformed input
1238                let _ = convert_regional_timestamp_to_utc(&input, &VeracodeRegion::Commercial);
1239                let _ = convert_regional_timestamp_to_utc(&input, &VeracodeRegion::European);
1240                let _ = convert_regional_timestamp_to_utc(&input, &VeracodeRegion::Federal);
1241            }
1242
1243            /// Property: Timestamps with variable millisecond precision should work
1244            /// Tests fractional seconds handling (0-9 digits)
1245            #[test]
1246            fn proptest_variable_millisecond_precision(
1247                milliseconds in "[0-9]{1,9}",
1248            ) {
1249                let timestamp = format!("2025-06-15 10:30:45.{}", milliseconds);
1250                let result = convert_regional_timestamp_to_utc(&timestamp, &VeracodeRegion::Commercial);
1251
1252                // Should handle variable precision gracefully
1253                // Either succeeds or fails safely
1254                if let Some(utc) = result {
1255                    prop_assert!(utc.len() >= 19, "UTC timestamp too short");
1256                }
1257            }
1258
1259            /// Property: Extreme dates should be handled safely
1260            /// Tests boundary conditions for year, month, day
1261            #[test]
1262            fn proptest_extreme_dates(
1263                year in 1900u32..2200u32,
1264                month in 0u32..=13u32, // Include invalid months
1265                day in 0u32..=32u32,   // Include invalid days
1266            ) {
1267                let timestamp = format!(
1268                    "{:04}-{:02}-{:02} 12:00:00",
1269                    year, month, day
1270                );
1271
1272                // Should never panic on extreme dates
1273                let _ = convert_regional_timestamp_to_utc(&timestamp, &VeracodeRegion::Commercial);
1274            }
1275        }
1276
1277        // ========================================================================
1278        // SECURITY TEST 2: Hash Function Collision Resistance
1279        // ========================================================================
1280        // Tests: generate_log_hash
1281        // Goals: Verify hash properties, collision resistance, determinism
1282
1283        proptest! {
1284            #![proptest_config(ProptestConfig {
1285                cases: if cfg!(miri) { 5 } else { 1000 },
1286                failure_persistence: None,
1287                .. ProptestConfig::default()
1288            })]
1289
1290            /// Property: Hash output should always be 32 hex characters (128-bit)
1291            /// Tests hash format consistency
1292            #[test]
1293            fn proptest_hash_format_consistency(
1294                input in "\\PC{0,1024}", // Any Unicode string up to 1KB
1295            ) {
1296                let hash = generate_log_hash(&input);
1297
1298                // xxh3_128 should always produce 32 hex characters
1299                prop_assert_eq!(hash.len(), 32, "Hash length should be 32 chars");
1300
1301                // Should only contain hex characters
1302                prop_assert!(
1303                    hash.chars().all(|c| c.is_ascii_hexdigit()),
1304                    "Hash should only contain hex chars: {}",
1305                    hash
1306                );
1307            }
1308
1309            /// Property: Same input should always produce same hash (determinism)
1310            /// Tests hash function determinism
1311            #[test]
1312            fn proptest_hash_determinism(
1313                input in "\\PC{0,2048}",
1314            ) {
1315                let hash1 = generate_log_hash(&input);
1316                let hash2 = generate_log_hash(&input);
1317
1318                prop_assert_eq!(
1319                    hash1, hash2,
1320                    "Hash function should be deterministic"
1321                );
1322            }
1323
1324            /// Property: Different inputs should produce different hashes (collision resistance)
1325            /// Tests basic collision resistance
1326            #[test]
1327            fn proptest_hash_collision_resistance(
1328                input1 in "\\PC{1,256}",
1329                input2 in "\\PC{1,256}",
1330            ) {
1331                // Only test when inputs are actually different
1332                if input1 != input2 {
1333                    let hash1 = generate_log_hash(&input1);
1334                    let hash2 = generate_log_hash(&input2);
1335
1336                    // Different inputs should produce different hashes
1337                    // (collisions are possible but extremely rare)
1338                    prop_assert_ne!(
1339                        hash1, hash2,
1340                        "Collision detected for different inputs"
1341                    );
1342                }
1343            }
1344
1345            /// Property: Small changes should produce completely different hashes (avalanche effect)
1346            /// Tests that single-bit changes cascade through the hash
1347            #[test]
1348            fn proptest_hash_avalanche_effect(
1349                base in "[a-zA-Z0-9]{10,100}",
1350                suffix in "[a-z]",
1351            ) {
1352                let input1 = base.clone();
1353                let input2 = format!("{}{}", base, suffix);
1354
1355                let hash1 = generate_log_hash(&input1);
1356                let hash2 = generate_log_hash(&input2);
1357
1358                // Adding one character should completely change the hash
1359                prop_assert_ne!(
1360                    &hash1, &hash2,
1361                    "Avalanche effect failed: similar inputs produced similar hashes"
1362                );
1363
1364                // Count differing characters (should be significant)
1365                let diff_count = hash1.chars()
1366                    .zip(hash2.chars())
1367                    .filter(|(a, b)| a != b)
1368                    .count();
1369
1370                // At least 40% of hash should change (good avalanche)
1371                prop_assert!(
1372                    diff_count >= 12,
1373                    "Poor avalanche effect: only {} of 32 chars changed",
1374                    diff_count
1375                );
1376            }
1377        }
1378
1379        // ========================================================================
1380        // SECURITY TEST 3: URL Encoding Injection Prevention
1381        // ========================================================================
1382        // Tests: URL encoding in get_audit_report
1383        // Goals: Prevent path traversal, command injection, XSS via report_id
1384
1385        proptest! {
1386            #![proptest_config(ProptestConfig {
1387                cases: if cfg!(miri) { 5 } else { 1000 },
1388                failure_persistence: None,
1389                .. ProptestConfig::default()
1390            })]
1391
1392            /// Property: URL encoding should escape all special characters
1393            /// Tests that dangerous characters are properly encoded
1394            #[test]
1395            fn proptest_url_encoding_escapes_special_chars(
1396                special_chars in prop::sample::select(vec![
1397                    "/", "\\", "?", "&", "=", "#", " ", "<", ">",
1398                    "\"", "'", "|", ";", "\n", "\r", "\0", "$"
1399                ]),
1400                base in "[a-zA-Z0-9]{5,20}",
1401            ) {
1402                let malicious_id = format!("{}{}{}", base, special_chars, base);
1403                let encoded = urlencoding::encode(&malicious_id);
1404
1405                // Encoded output should not contain the original special char
1406                // (it should be percent-encoded)
1407                prop_assert!(
1408                    !encoded.contains(special_chars),
1409                    "Special character '{}' not encoded properly",
1410                    special_chars
1411                );
1412
1413                // Should contain % for percent-encoding (or + for space)
1414                if !special_chars.chars().all(|c| c.is_alphanumeric()) {
1415                    prop_assert!(
1416                        encoded.contains('%') || (special_chars == " " && encoded.contains('+')),
1417                        "Expected encoding for '{}'",
1418                        special_chars
1419                    );
1420                }
1421            }
1422
1423            /// Property: Path traversal sequences should be encoded
1424            /// Tests protection against directory traversal attacks
1425            #[test]
1426            fn proptest_url_encoding_prevents_path_traversal(
1427                traversal in prop_oneof![
1428                    Just("../"),
1429                    Just("..\\"),
1430                    Just("../../"),
1431                    Just("..%2f"),
1432                    Just("..%5c"),
1433                    Just("%2e%2e%2f"),
1434                ],
1435                prefix in "[a-z]{1,10}",
1436                suffix in "[a-z]{1,10}",
1437            ) {
1438                let malicious_id = format!("{}{}{}", prefix, traversal, suffix);
1439                let encoded = urlencoding::encode(&malicious_id);
1440
1441                // Encoded string should not contain literal path traversal
1442                prop_assert!(
1443                    !encoded.contains("../") && !encoded.contains("..\\"),
1444                    "Path traversal not properly encoded: {}",
1445                    encoded
1446                );
1447            }
1448
1449            /// Property: Command injection characters should be encoded
1450            /// Tests protection against shell command injection
1451            #[test]
1452            fn proptest_url_encoding_prevents_command_injection(
1453                injection_char in prop::sample::select(vec![
1454                    ";", "|", "&", "$", "`", "$(", ")", "{", "}", "\n", "\r"
1455                ]),
1456                base in "[a-zA-Z0-9]{5,15}",
1457            ) {
1458                let malicious_id = format!("{}{}rm -rf /", base, injection_char);
1459                let encoded = urlencoding::encode(&malicious_id);
1460
1461                // Encoded output should not contain injection characters
1462                prop_assert!(
1463                    !encoded.contains(injection_char),
1464                    "Injection character '{}' not encoded",
1465                    injection_char
1466                );
1467            }
1468        }
1469
1470        // ========================================================================
1471        // SECURITY TEST 4: Integer Overflow Protection (Saturating Arithmetic)
1472        // ========================================================================
1473        // Tests: saturating_add, saturating_mul operations
1474        // Goals: Verify overflow protection doesn't wrap around
1475
1476        proptest! {
1477            #![proptest_config(ProptestConfig {
1478                cases: if cfg!(miri) { 5 } else { 1000 },
1479                failure_persistence: None,
1480                .. ProptestConfig::default()
1481            })]
1482
1483            /// Property: Saturating add should never overflow
1484            /// Tests u32 saturating addition behavior
1485            #[test]
1486            fn proptest_saturating_add_never_overflows(
1487                a in 0u32..=u32::MAX,
1488                b in 0u32..=1000u32,
1489            ) {
1490                let result = a.saturating_add(b);
1491
1492                // Result should be >= both operands
1493                prop_assert!(result >= a, "Saturating add decreased value");
1494
1495                // If overflow would occur, result should be MAX
1496                #[allow(clippy::arithmetic_side_effects)]
1497                {
1498                    if a as u64 + b as u64 > u32::MAX as u64 {
1499                        prop_assert_eq!(
1500                            result,
1501                            u32::MAX,
1502                            "Expected saturation at MAX for {} + {}",
1503                            a, b
1504                        );
1505                    } else {
1506                        prop_assert_eq!(
1507                            result,
1508                            a + b,
1509                            "Expected normal addition for {} + {}",
1510                            a, b
1511                        );
1512                    }
1513                }
1514            }
1515
1516            /// Property: Saturating multiply should never overflow
1517            /// Tests u64 saturating multiplication behavior (for delay_secs)
1518            #[test]
1519            fn proptest_saturating_mul_never_overflows(
1520                a in 0u64..=u64::MAX / 2,
1521                b in 0u64..=100u64,
1522            ) {
1523                let result = a.saturating_mul(b);
1524
1525                // If overflow would occur, result should be MAX
1526                if let Some(expected) = a.checked_mul(b) {
1527                    prop_assert_eq!(result, expected, "Multiplication mismatch");
1528                } else {
1529                    prop_assert_eq!(
1530                        result,
1531                        u64::MAX,
1532                        "Expected saturation at MAX for {} * {}",
1533                        a, b
1534                    );
1535                }
1536            }
1537
1538            /// Property: Counter increments should never overflow
1539            /// Tests the specific pattern used in poll_report_status and get_all_audit_log_pages
1540            #[test]
1541            fn proptest_counter_increment_safety(
1542                start in 0u32..=u32::MAX - 1000,
1543                increments in 1usize..=100,
1544            ) {
1545                let mut counter = start;
1546
1547                for _ in 0..increments {
1548                    let old_value = counter;
1549                    counter = counter.saturating_add(1);
1550
1551                    // Counter should never decrease
1552                    prop_assert!(
1553                        counter >= old_value,
1554                        "Counter decreased from {} to {}",
1555                        old_value, counter
1556                    );
1557
1558                    // If we hit MAX, it should stay at MAX
1559                    if old_value == u32::MAX {
1560                        prop_assert_eq!(counter, u32::MAX, "Counter should saturate at MAX");
1561                    }
1562                }
1563            }
1564
1565            /// Property: Page iteration should handle near-MAX values safely
1566            /// Tests the pagination loop in get_all_audit_log_pages
1567            #[test]
1568            fn proptest_page_iteration_overflow_safety(
1569                total_pages in 1u32..=1000u32,
1570            ) {
1571                // Simulate the pagination loop from get_all_audit_log_pages
1572                let mut processed = 0u32;
1573
1574                for page_num in 1..total_pages {
1575                    // This is the pattern used in line 598-612
1576                    let page_display = page_num.saturating_add(1);
1577
1578                    prop_assert!(
1579                        page_display >= page_num,
1580                        "Page display calculation overflow"
1581                    );
1582
1583                    processed = processed.saturating_add(1);
1584                }
1585
1586                // Should process total_pages - 1 pages (page 0 handled separately)
1587                prop_assert_eq!(
1588                    processed,
1589                    total_pages.saturating_sub(1),
1590                    "Page count mismatch"
1591                );
1592            }
1593        }
1594
1595        // ========================================================================
1596        // SECURITY TEST 5: Input Validation for AuditReportRequest
1597        // ========================================================================
1598        // Tests: AuditReportRequest builder methods
1599        // Goals: Ensure safe handling of user-controlled inputs
1600
1601        proptest! {
1602            #![proptest_config(ProptestConfig {
1603                cases: if cfg!(miri) { 5 } else { 1000 },
1604                failure_persistence: None,
1605                .. ProptestConfig::default()
1606            })]
1607
1608            /// Property: Request builder should handle arbitrary strings safely
1609            /// Tests that builder methods don't panic on unusual input
1610            #[test]
1611            fn proptest_request_builder_handles_arbitrary_input(
1612                start_date in "\\PC{0,256}",
1613                end_date in "\\PC{0,256}",
1614                action in "\\PC{0,100}",
1615            ) {
1616                // Should never panic, even with unusual input
1617                let request = AuditReportRequest::new(
1618                    start_date.clone(),
1619                    if end_date.is_empty() { None } else { Some(end_date.clone()) }
1620                );
1621
1622                // Verify fields are set correctly
1623                prop_assert_eq!(&request.start_date, &start_date);
1624
1625                if !end_date.is_empty() {
1626                    prop_assert_eq!(&request.end_date, &Some(end_date.clone()));
1627                }
1628
1629                // Test with_audit_actions
1630                let request = request.with_audit_actions(vec![action.clone()]);
1631                prop_assert!(request.audit_action.is_some());
1632            }
1633
1634            /// Property: Builder methods should preserve data integrity
1635            /// Tests that chained builder calls work correctly
1636            #[test]
1637            fn proptest_request_builder_data_integrity(
1638                start in "[0-9]{4}-[0-9]{2}-[0-9]{2}",
1639                actions in prop::collection::vec("[A-Za-z]{5,15}", 0..10),
1640                types in prop::collection::vec("[A-Za-z]{5,15}", 0..10),
1641                user_ids in prop::collection::vec("[0-9]{1,10}", 0..10),
1642            ) {
1643                let request = AuditReportRequest::new(start.clone(), None)
1644                    .with_audit_actions(actions.clone())
1645                    .with_action_types(types.clone())
1646                    .with_target_users(user_ids.clone())
1647                    .with_modifier_users(user_ids.clone());
1648
1649                // Verify all fields are preserved correctly
1650                prop_assert_eq!(request.start_date, start);
1651                prop_assert_eq!(request.audit_action, Some(actions));
1652                prop_assert_eq!(request.action_type, Some(types));
1653                prop_assert_eq!(request.target_user_id, Some(user_ids.clone()));
1654                prop_assert_eq!(request.modifier_user_id, Some(user_ids));
1655            }
1656
1657            /// Property: Empty collections should be handled correctly
1658            /// Tests edge case of empty filter arrays
1659            #[test]
1660            fn proptest_request_builder_empty_collections(
1661                start_date in "[0-9]{4}-[0-9]{2}-[0-9]{2}",
1662            ) {
1663                let request = AuditReportRequest::new(start_date.clone(), None)
1664                    .with_audit_actions(vec![])
1665                    .with_action_types(vec![])
1666                    .with_target_users(vec![])
1667                    .with_modifier_users(vec![]);
1668
1669                // Empty vecs should still be Some (not None)
1670                prop_assert!(request.audit_action.is_some());
1671                prop_assert!(request.action_type.is_some());
1672                prop_assert!(request.target_user_id.is_some());
1673                prop_assert!(request.modifier_user_id.is_some());
1674
1675                // But should be empty
1676                if let Some(ref actions) = request.audit_action {
1677                    prop_assert_eq!(actions.len(), 0);
1678                }
1679            }
1680
1681            /// Property: Large collections should be handled safely
1682            /// Tests that builders can handle many filter values
1683            #[test]
1684            fn proptest_request_builder_large_collections(
1685                start_date in "[0-9]{4}-[0-9]{2}-[0-9]{2}",
1686                collection_size in 1usize..=100,
1687            ) {
1688                let large_vec: Vec<String> = (0..collection_size)
1689                    .map(|i| format!("item_{}", i))
1690                    .collect();
1691
1692                let request = AuditReportRequest::new(start_date, None)
1693                    .with_audit_actions(large_vec.clone());
1694
1695                if let Some(ref actions) = request.audit_action {
1696                    prop_assert_eq!(
1697                        actions.len(),
1698                        collection_size,
1699                        "Collection size mismatch"
1700                    );
1701                }
1702            }
1703        }
1704
1705        // ========================================================================
1706        // SECURITY TEST 6: JSON Serialization Safety
1707        // ========================================================================
1708        // Tests: AuditReportRequest serialization, AuditLogEntry serialization
1709        // Goals: Ensure no injection via JSON serialization
1710
1711        proptest! {
1712            #![proptest_config(ProptestConfig {
1713                cases: if cfg!(miri) { 5 } else { 1000 },
1714                failure_persistence: None,
1715                .. ProptestConfig::default()
1716            })]
1717
1718            /// Property: Request serialization should never panic
1719            /// Tests that arbitrary input can be safely serialized
1720            #[test]
1721            fn proptest_request_serialization_safety(
1722                start_date in "\\PC{0,100}",
1723                actions in prop::collection::vec("\\PC{0,50}", 0..10),
1724            ) {
1725                let request = AuditReportRequest::new(start_date, None)
1726                    .with_audit_actions(actions);
1727
1728                // Serialization should never panic
1729                let result = serde_json::to_string(&request);
1730                prop_assert!(result.is_ok(), "Serialization failed");
1731
1732                // Serialized JSON should be valid
1733                if let Ok(json) = result {
1734                    prop_assert!(json.contains("\"report_type\""), "Missing report_type");
1735                    prop_assert!(json.contains("\"AUDIT\""), "Wrong report_type value");
1736                }
1737            }
1738
1739            /// Property: Special characters in dates should be escaped
1740            /// Tests JSON injection prevention
1741            #[test]
1742            fn proptest_json_injection_prevention(
1743                injection in prop::sample::select(vec![
1744                    r#"","malicious":"value"#,
1745                    "\n\r\t",
1746                    "\\",
1747                    "\"",
1748                    "</script>",
1749                ]),
1750                base_date in "[0-9]{4}-[0-9]{2}-[0-9]{2}",
1751            ) {
1752                let malicious_date = format!("{}{}", base_date, injection);
1753                let request = AuditReportRequest::new(malicious_date, None);
1754
1755                let json = serde_json::to_string(&request)
1756                    .expect("Should serialize even with special chars");
1757
1758                // Verify JSON is still valid after serialization
1759                let parsed: serde_json::Value = serde_json::from_str(&json)
1760                    .expect("Serialized JSON should be parseable");
1761
1762                prop_assert!(parsed.is_object(), "Should be valid JSON object");
1763            }
1764        }
1765
1766        // ========================================================================
1767        // SECURITY TEST 7: Error Handling Paths
1768        // ========================================================================
1769        // Tests: Timestamp parsing errors, hash function edge cases
1770        // Goals: Ensure errors are handled gracefully without panics
1771
1772        proptest! {
1773            #![proptest_config(ProptestConfig {
1774                cases: if cfg!(miri) { 5 } else { 500 }, // Fewer cases for error paths
1775                failure_persistence: None,
1776                .. ProptestConfig::default()
1777            })]
1778
1779            /// Property: All timestamp error paths should return None, never panic
1780            /// Tests comprehensive error handling
1781            #[test]
1782            fn proptest_timestamp_error_handling_never_panics(
1783                malformed in prop_oneof![
1784                    // Empty and whitespace
1785                    Just(""),
1786                    Just(" "),
1787                    Just("\n\t\r"),
1788
1789                    // Wrong formats
1790                    Just("2025/01/01 12:00:00"),
1791                    Just("01-01-2025 12:00:00"),
1792                    Just("2025-01-01T12:00:00Z"),
1793
1794                    // Invalid values
1795                    Just("2025-13-01 12:00:00"), // Invalid month
1796                    Just("2025-01-32 12:00:00"), // Invalid day
1797                    Just("2025-01-01 25:00:00"), // Invalid hour
1798                    Just("2025-01-01 12:60:00"), // Invalid minute
1799                    Just("2025-01-01 12:00:60"), // Invalid second
1800
1801                    // Truncated
1802                    Just("2025-01-01"),
1803                    Just("2025-01-01 12"),
1804                    Just("2025-01-01 12:00"),
1805
1806                    // Special characters
1807                    Just("2025-01-01; DROP TABLE;"),
1808                    Just("../../etc/passwd"),
1809                    Just("<script>alert('xss')</script>"),
1810
1811                    // Extreme values
1812                    Just("9999-99-99 99:99:99"),
1813                    Just("0000-00-00 00:00:00"),
1814                ],
1815            ) {
1816                // Should never panic, regardless of input
1817                let result_commercial = convert_regional_timestamp_to_utc(malformed, &VeracodeRegion::Commercial);
1818                let result_european = convert_regional_timestamp_to_utc(malformed, &VeracodeRegion::European);
1819                let result_federal = convert_regional_timestamp_to_utc(malformed, &VeracodeRegion::Federal);
1820
1821                // All should return None (error), not panic
1822                prop_assert!(result_commercial.is_none() || result_commercial.is_some());
1823                prop_assert!(result_european.is_none() || result_european.is_some());
1824                prop_assert!(result_federal.is_none() || result_federal.is_some());
1825            }
1826
1827            /// Property: Hash function should handle all input lengths
1828            /// Tests from empty to very large inputs
1829            #[test]
1830            fn proptest_hash_handles_all_input_sizes(
1831                size in 0usize..=10_000,
1832            ) {
1833                let input = "x".repeat(size);
1834
1835                // Should never panic, even with large inputs
1836                let hash = generate_log_hash(&input);
1837
1838                // Hash should always be valid format
1839                prop_assert_eq!(hash.len(), 32);
1840                prop_assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1841            }
1842
1843            /// Property: Hash function should handle binary data safely
1844            /// Tests non-UTF8 byte sequences (via valid UTF8 with null bytes)
1845            #[test]
1846            fn proptest_hash_handles_binary_data(
1847                null_count in 0usize..=100,
1848            ) {
1849                // Create string with embedded nulls
1850                let input = format!("data{}\0{}\0end", "x".repeat(null_count), "y".repeat(null_count));
1851
1852                // Should handle null bytes gracefully
1853                let hash = generate_log_hash(&input);
1854
1855                prop_assert_eq!(hash.len(), 32);
1856                prop_assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1857            }
1858        }
1859
1860        // ========================================================================
1861        // UNIT TESTS: Specific Security Scenarios
1862        // ========================================================================
1863
1864        #[test]
1865        fn test_url_encoding_sql_injection_attempt() {
1866            let sql_injection = "1' OR '1'='1";
1867            let encoded = urlencoding::encode(sql_injection);
1868
1869            // Should not contain unescaped quotes or spaces
1870            assert!(!encoded.contains('\''));
1871            assert!(!encoded.contains(' ') || encoded.contains('+') || encoded.contains("%20"));
1872        }
1873
1874        #[test]
1875        fn test_url_encoding_path_traversal_variants() {
1876            let variants = vec![
1877                "../../../etc/passwd",
1878                "..%2f..%2f..%2fetc%2fpasswd",
1879                "..\\..\\..\\windows\\system32",
1880            ];
1881
1882            for variant in variants {
1883                let encoded = urlencoding::encode(variant);
1884
1885                // Should not contain literal path traversal sequences
1886                assert!(!encoded.contains("../"));
1887                assert!(!encoded.contains("..\\"));
1888            }
1889        }
1890
1891        #[test]
1892        fn test_hash_known_collision_resistance() {
1893            // Test known collision-prone patterns
1894            let similar_inputs = [
1895                r#"{"timestamp":"2025-01-01 12:00:00.000"}"#,
1896                r#"{"timestamp":"2025-01-01 12:00:00.001"}"#,
1897                r#"{"timestamp":"2025-01-01 12:00:01.000"}"#,
1898            ];
1899
1900            let hashes: Vec<String> = similar_inputs
1901                .iter()
1902                .map(|input| generate_log_hash(input))
1903                .collect();
1904
1905            // All hashes should be unique
1906            for i in 0..hashes.len() {
1907                for j in i + 1..hashes.len() {
1908                    if let (Some(hash_i), Some(hash_j)) = (hashes.get(i), hashes.get(j)) {
1909                        assert_ne!(
1910                            hash_i, hash_j,
1911                            "Collision between similar inputs {} and {}",
1912                            i, j
1913                        );
1914                    }
1915                }
1916            }
1917        }
1918
1919        #[test]
1920        fn test_saturating_arithmetic_at_boundaries() {
1921            // Test u32::MAX boundary
1922            assert_eq!(u32::MAX.saturating_add(1), u32::MAX);
1923            assert_eq!((u32::MAX - 1).saturating_add(2), u32::MAX);
1924
1925            // Test u64::MAX boundary (for delay_secs)
1926            assert_eq!(u64::MAX.saturating_mul(2), u64::MAX);
1927            assert_eq!((u64::MAX / 2).saturating_mul(3), u64::MAX);
1928        }
1929
1930        #[test]
1931        fn test_timestamp_dst_transitions() {
1932            // Test DST spring forward (2025-03-09 02:00 -> 03:00 EST -> EDT)
1933            // 2:30 AM doesn't exist on this date in New_York
1934            let result = convert_regional_timestamp_to_utc(
1935                "2025-03-09 02:30:00",
1936                &VeracodeRegion::Commercial,
1937            );
1938            // Should handle gracefully (either succeed or return None)
1939            assert!(result.is_some() || result.is_none());
1940
1941            // Test DST fall back (2025-11-02 02:00 happens twice)
1942            let result = convert_regional_timestamp_to_utc(
1943                "2025-11-02 01:30:00",
1944                &VeracodeRegion::Commercial,
1945            );
1946            // Should succeed with single() if unambiguous, or fail gracefully
1947            assert!(result.is_some() || result.is_none());
1948        }
1949
1950        #[test]
1951        fn test_leap_year_handling() {
1952            // 2024 is a leap year - Feb 29 should work
1953            let result =
1954                convert_regional_timestamp_to_utc("2024-02-29 12:00:00", &VeracodeRegion::European);
1955            assert!(result.is_some(), "Leap year Feb 29 should be valid");
1956
1957            // 2025 is not a leap year - Feb 29 should fail
1958            let result =
1959                convert_regional_timestamp_to_utc("2025-02-29 12:00:00", &VeracodeRegion::European);
1960            assert!(result.is_none(), "Non-leap year Feb 29 should be invalid");
1961        }
1962
1963        #[test]
1964        fn test_empty_request_serialization() {
1965            let request = AuditReportRequest::new("2025-01-01", None);
1966            let json = serde_json::to_string(&request).expect("Should serialize");
1967
1968            // Should not include optional fields when None
1969            assert!(!json.contains("audit_action"));
1970            assert!(!json.contains("action_type"));
1971            assert!(!json.contains("target_user_id"));
1972            assert!(!json.contains("modifier_user_id"));
1973
1974            // Should include required fields
1975            assert!(json.contains("report_type"));
1976            assert!(json.contains("start_date"));
1977        }
1978    }
1979}