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