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