Skip to main content

veracode_platform/
findings.rs

1//! Findings API for retrieving security findings from Veracode scans
2//!
3//! This module provides structured access to both policy scan and sandbox scan findings
4//! with support for pagination, filtering, and automatic collection of all results.
5
6use crate::json_validator::{MAX_JSON_DEPTH, validate_json_depth};
7use crate::{VeracodeClient, VeracodeError};
8use log::{debug, error, warn};
9use serde::{Deserialize, Serialize};
10use std::borrow::Cow;
11
12/// CWE (Common Weakness Enumeration) information
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CweInfo {
15    /// CWE ID number
16    pub id: u32,
17    /// CWE name/description
18    pub name: String,
19    /// API reference URL for this CWE
20    pub href: String,
21}
22
23/// Finding category information
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct FindingCategory {
26    /// Category ID
27    pub id: u32,
28    /// Category name
29    pub name: String,
30    /// API reference URL for this category
31    pub href: String,
32}
33
34/// Status information for a finding
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct FindingStatus {
37    /// When this finding was first discovered
38    pub first_found_date: String,
39    /// Current status (OPEN, FIXED, etc.)
40    pub status: String,
41    /// Resolution status (RESOLVED, UNRESOLVED, etc.)
42    pub resolution: String,
43    /// Mitigation review status
44    pub mitigation_review_status: String,
45    /// Whether this is a new finding
46    pub new: bool,
47    /// Resolution status category
48    pub resolution_status: String,
49    /// When this finding was last seen
50    pub last_seen_date: String,
51}
52
53/// Detailed information about a finding
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct FindingDetails {
56    /// Severity level (0-5, where 5 is highest)
57    pub severity: u32,
58    /// CWE information
59    pub cwe: CweInfo,
60    /// File path where finding was located
61    pub file_path: String,
62    /// File name
63    pub file_name: String,
64    /// Module/library name
65    pub module: String,
66    /// Relative location within the file
67    pub relative_location: i32,
68    /// Finding category
69    pub finding_category: FindingCategory,
70    /// Procedure/method name where finding occurs
71    pub procedure: String,
72    /// Exploitability rating
73    pub exploitability: i32,
74    /// Attack vector description
75    pub attack_vector: String,
76    /// Line number in the file
77    pub file_line_number: u32,
78}
79
80/// A security finding from a Veracode scan
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct RestFinding {
83    /// Unique issue ID
84    pub issue_id: u32,
85    /// Type of scan that found this issue
86    pub scan_type: String,
87    /// Detailed description of the finding
88    pub description: String,
89    /// Number of occurrences
90    pub count: u32,
91    /// Context type (SANDBOX, POLICY, etc.)
92    pub context_type: String,
93    /// Context GUID (sandbox GUID for sandbox scans)
94    pub context_guid: String,
95    /// Whether this finding violates policy
96    pub violates_policy: bool,
97    /// Status information
98    pub finding_status: FindingStatus,
99    /// Detailed finding information
100    pub finding_details: FindingDetails,
101    /// Build ID where this finding was discovered
102    pub build_id: u64,
103}
104
105/// Embedded findings in HAL response
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct FindingsEmbedded {
108    /// Array of findings
109    pub findings: Vec<RestFinding>,
110}
111
112/// HAL link structure
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct HalLink {
115    /// URL for this link
116    pub href: String,
117    /// Whether this URL is a template
118    #[serde(default)]
119    pub templated: Option<bool>,
120}
121
122/// HAL links in findings response
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct FindingsLinks {
125    /// Link to first page (optional - may not be present for single page results)
126    pub first: Option<HalLink>,
127    /// Link to current page (self)
128    #[serde(rename = "self")]
129    pub self_link: HalLink,
130    /// Link to next page (optional)
131    pub next: Option<HalLink>,
132    /// Link to last page (optional - may not be present for single page results)
133    pub last: Option<HalLink>,
134    /// Link to application
135    pub application: HalLink,
136    /// Link to SCA findings (optional)
137    pub sca: Option<HalLink>,
138    /// Link to sandbox (optional for sandbox scans)
139    pub sandbox: Option<HalLink>,
140}
141
142/// Pagination information
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct PageInfo {
145    /// Number of items per page
146    pub size: u32,
147    /// Total number of findings across all pages
148    pub total_elements: u32,
149    /// Total number of pages
150    pub total_pages: u32,
151    /// Current page number (0-based)
152    pub number: u32,
153}
154
155/// Complete findings API response
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct FindingsResponse {
158    /// Embedded findings data
159    #[serde(rename = "_embedded")]
160    pub embedded: FindingsEmbedded,
161    /// HAL navigation links
162    #[serde(rename = "_links")]
163    pub links: FindingsLinks,
164    /// Pagination information
165    pub page: PageInfo,
166}
167
168impl FindingsResponse {
169    /// Get the findings from this response
170    #[must_use]
171    pub fn findings(&self) -> &[RestFinding] {
172        &self.embedded.findings
173    }
174
175    /// Check if there's a next page available
176    #[must_use]
177    pub fn has_next_page(&self) -> bool {
178        self.links.next.is_some()
179    }
180
181    /// Get current page number (0-based)
182    #[must_use]
183    pub fn current_page(&self) -> u32 {
184        self.page.number
185    }
186
187    /// Get total number of pages
188    #[must_use]
189    pub fn total_pages(&self) -> u32 {
190        self.page.total_pages
191    }
192
193    /// Check if this is the last page
194    #[must_use]
195    pub fn is_last_page(&self) -> bool {
196        self.page.number.saturating_add(1) >= self.page.total_pages
197    }
198
199    /// Get total number of findings across all pages
200    #[must_use]
201    pub fn total_elements(&self) -> u32 {
202        self.page.total_elements
203    }
204}
205
206/// Query parameters for findings API
207#[derive(Debug, Clone)]
208pub struct FindingsQuery<'a> {
209    /// Application GUID
210    pub app_guid: Cow<'a, str>,
211    /// Context (sandbox GUID for sandbox scans, None for policy scans)
212    pub context: Option<Cow<'a, str>>,
213    /// Page number (0-based)
214    pub page: Option<u32>,
215    /// Items per page
216    pub size: Option<u32>,
217    /// Filter by severity levels
218    pub severity: Option<Vec<u32>>,
219    /// Filter by CWE IDs
220    pub cwe_id: Option<Vec<String>>,
221    /// Filter by scan type
222    pub scan_type: Option<Cow<'a, str>>,
223    /// Filter by policy violations only
224    pub violates_policy: Option<bool>,
225}
226
227impl<'a> FindingsQuery<'a> {
228    /// Create new query for policy scan findings
229    #[must_use]
230    pub fn new(app_guid: &'a str) -> Self {
231        Self {
232            app_guid: Cow::Borrowed(app_guid),
233            context: None,
234            page: None,
235            size: None,
236            severity: None,
237            cwe_id: None,
238            scan_type: None,
239            violates_policy: None,
240        }
241    }
242
243    /// Create new query for sandbox scan findings
244    #[must_use]
245    pub fn for_sandbox(app_guid: &'a str, sandbox_guid: &'a str) -> Self {
246        Self {
247            app_guid: Cow::Borrowed(app_guid),
248            context: Some(Cow::Borrowed(sandbox_guid)),
249            page: None,
250            size: None,
251            severity: None,
252            cwe_id: None,
253            scan_type: None,
254            violates_policy: None,
255        }
256    }
257
258    /// Add sandbox context to existing query
259    #[must_use]
260    pub fn with_sandbox(mut self, sandbox_guid: &'a str) -> Self {
261        self.context = Some(Cow::Borrowed(sandbox_guid));
262        self
263    }
264
265    /// Add pagination parameters
266    #[must_use]
267    pub fn with_pagination(mut self, page: u32, size: u32) -> Self {
268        self.page = Some(page);
269        self.size = Some(size);
270        self
271    }
272
273    /// Filter by severity levels (0-5)
274    #[must_use]
275    pub fn with_severity(mut self, severity: Vec<u32>) -> Self {
276        self.severity = Some(severity);
277        self
278    }
279
280    /// Filter by CWE IDs
281    #[must_use]
282    pub fn with_cwe(mut self, cwe_ids: Vec<String>) -> Self {
283        self.cwe_id = Some(cwe_ids);
284        self
285    }
286
287    /// Filter by scan type
288    #[must_use]
289    pub fn with_scan_type(mut self, scan_type: &'a str) -> Self {
290        self.scan_type = Some(Cow::Borrowed(scan_type));
291        self
292    }
293
294    /// Filter to policy violations only
295    #[must_use]
296    pub fn policy_violations_only(mut self) -> Self {
297        self.violates_policy = Some(true);
298        self
299    }
300}
301
302/// Custom error types for findings API
303#[derive(Debug, thiserror::Error)]
304#[must_use = "Need to handle all error enum types."]
305pub enum FindingsError {
306    /// Application not found
307    #[error("Application not found: {app_guid}")]
308    ApplicationNotFound { app_guid: String },
309
310    /// Sandbox not found
311    #[error("Sandbox not found: {sandbox_guid} in application {app_guid}")]
312    SandboxNotFound {
313        app_guid: String,
314        sandbox_guid: String,
315    },
316
317    /// Invalid pagination parameters
318    #[error("Invalid pagination parameters: page={page}, size={size}")]
319    InvalidPagination { page: u32, size: u32 },
320
321    /// No findings available
322    #[error("No findings available for the specified context")]
323    NoFindings,
324
325    /// API request failed
326    #[error("Findings API request failed: {source}")]
327    RequestFailed {
328        #[from]
329        source: VeracodeError,
330    },
331}
332
333/// Findings API client
334#[derive(Clone)]
335pub struct FindingsApi {
336    client: VeracodeClient,
337}
338
339impl FindingsApi {
340    /// Create new findings API client
341    #[must_use]
342    pub fn new(client: VeracodeClient) -> Self {
343        Self { client }
344    }
345
346    /// Get findings with pagination
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the API request fails, the findings cannot be retrieved,
351    /// or authentication/authorization fails.
352    pub async fn get_findings(
353        &self,
354        query: &FindingsQuery<'_>,
355    ) -> Result<FindingsResponse, FindingsError> {
356        debug!("Getting findings for app: {}", query.app_guid);
357
358        let endpoint = format!("/appsec/v2/applications/{}/findings", query.app_guid);
359        let mut params = Vec::new();
360
361        // Add context for sandbox scans
362        if let Some(context) = &query.context {
363            params.push(("context".to_string(), context.to_string()));
364            debug!("Using sandbox context: {context}");
365        }
366
367        // Add pagination parameters
368        if let Some(page) = query.page {
369            params.push(("page".to_string(), page.to_string()));
370        }
371
372        if let Some(size) = query.size {
373            params.push(("size".to_string(), size.to_string()));
374        }
375
376        // Add filtering parameters
377        if let Some(severity) = &query.severity {
378            for sev in severity {
379                params.push(("severity".to_string(), sev.to_string()));
380            }
381        }
382
383        if let Some(cwe_ids) = &query.cwe_id {
384            for cwe in cwe_ids {
385                params.push(("cwe".to_string(), cwe.clone()));
386            }
387        }
388
389        if let Some(scan_type) = &query.scan_type {
390            params.push(("scan_type".to_string(), scan_type.to_string()));
391        }
392
393        if let Some(violates_policy) = query.violates_policy {
394            params.push(("violates_policy".to_string(), violates_policy.to_string()));
395        }
396
397        debug!(
398            "Calling findings endpoint: {} with {} parameters",
399            endpoint,
400            params.len()
401        );
402
403        // Convert Vec<(String, String)> to Vec<(&str, &str)>
404        let params_ref: Vec<(&str, &str)> = params
405            .iter()
406            .map(|(k, v)| (k.as_str(), v.as_str()))
407            .collect();
408
409        let response = self
410            .client
411            .get_with_query_params(&endpoint, &params_ref)
412            .await
413            .map_err(|e| match (&e, &query.context) {
414                (VeracodeError::NotFound { .. }, Some(context)) => FindingsError::SandboxNotFound {
415                    app_guid: query.app_guid.to_string(),
416                    sandbox_guid: context.to_string(),
417                },
418                (VeracodeError::NotFound { .. }, None) => FindingsError::ApplicationNotFound {
419                    app_guid: query.app_guid.to_string(),
420                },
421                (
422                    VeracodeError::Http(_)
423                    | VeracodeError::Serialization(_)
424                    | VeracodeError::Authentication(_)
425                    | VeracodeError::InvalidResponse(_)
426                    | VeracodeError::HttpStatus { .. }
427                    | VeracodeError::InvalidConfig(_)
428                    | VeracodeError::RetryExhausted(_)
429                    | VeracodeError::RateLimited { .. }
430                    | VeracodeError::Validation(_),
431                    _,
432                ) => FindingsError::RequestFailed { source: e },
433            })?;
434
435        // Get response text for debugging if parsing fails
436        let response_text = response
437            .text()
438            .await
439            .map_err(|e| FindingsError::RequestFailed {
440                source: VeracodeError::Http(e),
441            })?;
442
443        let char_count = response_text.chars().count();
444        if char_count > 500 {
445            let truncated: String = response_text.chars().take(500).collect();
446            debug!(
447                "Raw API response (first 500 chars): {}... [truncated {} more characters]",
448                truncated,
449                char_count.saturating_sub(500)
450            );
451        } else {
452            debug!("Raw API response: {response_text}");
453        }
454
455        // Validate JSON depth before parsing to prevent DoS attacks
456        validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
457            error!("JSON validation failed: {e}");
458            FindingsError::RequestFailed {
459                source: VeracodeError::InvalidResponse(format!("JSON validation failed: {}", e)),
460            }
461        })?;
462
463        let findings_response: FindingsResponse =
464            serde_json::from_str(&response_text).map_err(|e| {
465                error!("JSON parsing error: {e}");
466                debug!("Full response that failed to parse: {response_text}");
467                FindingsError::RequestFailed {
468                    source: VeracodeError::Serialization(e),
469                }
470            })?;
471
472        debug!(
473            "Retrieved {} findings on page {}/{}",
474            findings_response.findings().len(),
475            findings_response.current_page().saturating_add(1),
476            findings_response.total_pages()
477        );
478
479        Ok(findings_response)
480    }
481
482    /// Get all findings across all pages automatically
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if the API request fails, the findings cannot be retrieved,
487    /// or authentication/authorization fails.
488    pub async fn get_all_findings(
489        &self,
490        query: &FindingsQuery<'_>,
491    ) -> Result<Vec<RestFinding>, FindingsError> {
492        debug!("Getting all findings for app: {}", query.app_guid);
493
494        let mut all_findings = Vec::new();
495        let mut current_page = 0;
496        let page_size = 500; // Use large page size for efficiency
497
498        loop {
499            let mut page_query = query.clone();
500            page_query.page = Some(current_page);
501            page_query.size = Some(page_size);
502
503            let response = self.get_findings(&page_query).await?;
504
505            if response.findings().is_empty() {
506                debug!("No more findings found on page {current_page}");
507                break;
508            }
509
510            let page_findings = response.findings().len();
511            all_findings.extend_from_slice(response.findings());
512
513            debug!(
514                "Added {} findings from page {}, total so far: {}",
515                page_findings,
516                current_page,
517                all_findings.len()
518            );
519
520            // Check if we've reached the last page
521            if response.is_last_page() {
522                debug!("Reached last page ({current_page}), stopping");
523                break;
524            }
525
526            current_page = current_page.saturating_add(1);
527
528            // Safety check to prevent infinite loops
529            if current_page > 1000 {
530                warn!(
531                    "Reached maximum page limit (1000) while fetching findings for app: {}",
532                    query.app_guid
533                );
534                break;
535            }
536        }
537
538        debug!(
539            "Retrieved total of {} findings across {} pages",
540            all_findings.len(),
541            current_page.saturating_add(1)
542        );
543        Ok(all_findings)
544    }
545
546    /// Get policy scan findings (convenience method)
547    ///
548    /// # Errors
549    ///
550    /// Returns an error if the API request fails, the findings cannot be retrieved,
551    /// or authentication/authorization fails.
552    pub async fn get_policy_findings(
553        &self,
554        app_guid: &str,
555    ) -> Result<FindingsResponse, FindingsError> {
556        self.get_findings(&FindingsQuery::new(app_guid)).await
557    }
558
559    /// Get sandbox findings (convenience method)
560    ///
561    /// # Errors
562    ///
563    /// Returns an error if the API request fails, the findings cannot be retrieved,
564    /// or authentication/authorization fails.
565    pub async fn get_sandbox_findings(
566        &self,
567        app_guid: &str,
568        sandbox_guid: &str,
569    ) -> Result<FindingsResponse, FindingsError> {
570        self.get_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
571            .await
572    }
573
574    /// Get all policy scan findings (convenience method)
575    ///
576    /// # Errors
577    ///
578    /// Returns an error if the API request fails, the findings cannot be retrieved,
579    /// or authentication/authorization fails.
580    pub async fn get_all_policy_findings(
581        &self,
582        app_guid: &str,
583    ) -> Result<Vec<RestFinding>, FindingsError> {
584        self.get_all_findings(&FindingsQuery::new(app_guid)).await
585    }
586
587    /// Get all sandbox findings (convenience method)
588    ///
589    /// # Errors
590    ///
591    /// Returns an error if the API request fails, the findings cannot be retrieved,
592    /// or authentication/authorization fails.
593    pub async fn get_all_sandbox_findings(
594        &self,
595        app_guid: &str,
596        sandbox_guid: &str,
597    ) -> Result<Vec<RestFinding>, FindingsError> {
598        self.get_all_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
599            .await
600    }
601}
602
603#[cfg(test)]
604#[allow(clippy::expect_used)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn test_findings_query_builder() {
610        let query = FindingsQuery::new("app-123")
611            .with_pagination(0, 50)
612            .with_severity(vec![3, 4, 5])
613            .policy_violations_only();
614
615        assert_eq!(query.app_guid, "app-123");
616        assert_eq!(query.page, Some(0));
617        assert_eq!(query.size, Some(50));
618        assert_eq!(query.severity, Some(vec![3, 4, 5]));
619        assert_eq!(query.violates_policy, Some(true));
620        assert!(query.context.is_none());
621    }
622
623    #[test]
624    fn test_sandbox_query_builder() {
625        let query = FindingsQuery::for_sandbox("app-123", "sandbox-456").with_pagination(1, 100);
626
627        assert_eq!(query.app_guid, "app-123");
628        assert_eq!(
629            query.context.as_ref().expect("should have context"),
630            "sandbox-456"
631        );
632        assert_eq!(query.page, Some(1));
633        assert_eq!(query.size, Some(100));
634    }
635
636    #[test]
637    fn test_findings_response_helpers() {
638        let response = FindingsResponse {
639            embedded: FindingsEmbedded {
640                findings: vec![], // Empty for test
641            },
642            links: FindingsLinks {
643                first: Some(HalLink {
644                    href: "first".to_string(),
645                    templated: None,
646                }),
647                self_link: HalLink {
648                    href: "self".to_string(),
649                    templated: None,
650                },
651                next: Some(HalLink {
652                    href: "next".to_string(),
653                    templated: None,
654                }),
655                last: Some(HalLink {
656                    href: "last".to_string(),
657                    templated: None,
658                }),
659                application: HalLink {
660                    href: "app".to_string(),
661                    templated: None,
662                },
663                sca: None,
664                sandbox: None,
665            },
666            page: PageInfo {
667                size: 20,
668                total_elements: 100,
669                total_pages: 5,
670                number: 2,
671            },
672        };
673
674        assert_eq!(response.current_page(), 2);
675        assert_eq!(response.total_pages(), 5);
676        assert_eq!(response.total_elements(), 100);
677        assert!(response.has_next_page());
678        assert!(!response.is_last_page());
679    }
680}
681
682// ============================================================================
683// SECURITY TESTS: Property-Based Testing with Proptest
684// ============================================================================
685
686#[cfg(test)]
687#[allow(clippy::expect_used)]
688mod proptest_security {
689    use super::*;
690    use proptest::prelude::*;
691
692    // ========================================================================
693    // Test 1: Pagination Boundary Conditions
694    // ========================================================================
695
696    proptest! {
697        #![proptest_config(ProptestConfig {
698            cases: if cfg!(miri) { 5 } else { 1000 },
699            failure_persistence: None,
700            .. ProptestConfig::default()
701        })]
702
703        /// Property: is_last_page() must correctly handle edge cases and overflow
704        ///
705        /// Security concern: Off-by-one errors in pagination could cause:
706        /// - Infinite loops fetching data
707        /// - Missing the last page of findings
708        /// - Integer overflow in page calculations
709        #[test]
710        fn prop_is_last_page_handles_overflow(
711            current_page in any::<u32>(),
712            total_pages in any::<u32>()
713        ) {
714            let response = create_test_response(current_page, total_pages);
715
716            // Property 1: Should never panic regardless of input values
717            let is_last = response.is_last_page();
718
719            // Property 2: Overflow-safe comparison logic
720            if current_page == u32::MAX {
721                // At max value, saturating_add returns u32::MAX
722                // u32::MAX >= any total_pages should be true
723                assert!(is_last);
724            } else if total_pages == 0 {
725                // If total_pages is 0, any page >= 0 is "last"
726                assert!(is_last);
727            } else {
728                // Normal case: current_page + 1 >= total_pages
729                let expected = current_page.saturating_add(1) >= total_pages;
730                assert_eq!(is_last, expected);
731            }
732        }
733
734        /// Property: has_next_page() must be consistent with next link presence
735        ///
736        /// Security concern: Inconsistent state could cause data loss or infinite loops
737        #[test]
738        fn prop_has_next_page_consistency(
739            current_page in 0u32..1000u32,
740            total_pages in 1u32..1001u32
741        ) {
742            let mut response = create_test_response(current_page, total_pages);
743
744            // If we're on the last page, remove the next link (realistic API behavior)
745            if response.is_last_page() {
746                response.links.next = None;
747            }
748
749            let has_next = response.has_next_page();
750            let is_last = response.is_last_page();
751
752            // Property 1: has_next_page depends only on the next link
753            if response.links.next.is_some() {
754                assert!(has_next);
755            } else {
756                assert!(!has_next);
757            }
758
759            // Property 2: If not on last page and has next link, has_next should be true
760            if !is_last && response.links.next.is_some() {
761                assert!(has_next);
762            }
763
764            // Test with no next link
765            response.links.next = None;
766            assert!(!response.has_next_page());
767        }
768
769        /// Property: Page number accessors must never panic
770        ///
771        /// Security concern: Panics in public API functions could cause DoS
772        #[test]
773        fn prop_page_accessors_never_panic(
774            current in any::<u32>(),
775            total in any::<u32>(),
776            elements in any::<u32>()
777        ) {
778            let mut response = create_test_response(current, total);
779            response.page.total_elements = elements;
780
781            // All these should work without panic
782            let _ = response.current_page();
783            let _ = response.total_pages();
784            let _ = response.total_elements();
785            let _ = response.is_last_page();
786            let _ = response.has_next_page();
787        }
788    }
789
790    // ========================================================================
791    // Test 2: FindingsQuery Builder Input Validation
792    // ========================================================================
793
794    proptest! {
795        #![proptest_config(ProptestConfig {
796            cases: if cfg!(miri) { 5 } else { 1000 },
797            failure_persistence: None,
798            .. ProptestConfig::default()
799        })]
800
801        /// Property: Query builder must handle arbitrary string inputs safely
802        ///
803        /// Security concern: SQL injection, command injection, or buffer overflow
804        /// via malicious GUID strings
805        #[test]
806        fn prop_query_builder_handles_malicious_strings(
807            app_guid in "\\PC*",
808            sandbox_guid in "\\PC*",
809            scan_type in "\\PC*"
810        ) {
811            // Should not panic with any string input
812            let query = FindingsQuery::new(&app_guid);
813            assert_eq!(query.app_guid.as_ref(), &app_guid);
814
815            let query = FindingsQuery::for_sandbox(&app_guid, &sandbox_guid);
816            assert_eq!(query.app_guid.as_ref(), &app_guid);
817            assert_eq!(query.context.as_ref().map(|s| s.as_ref()), Some(sandbox_guid.as_str()));
818
819            let query = query.with_scan_type(&scan_type);
820            assert_eq!(query.scan_type.as_ref().map(|s| s.as_ref()), Some(scan_type.as_str()));
821        }
822
823        /// Property: Severity filter must only accept valid severity levels (0-5)
824        ///
825        /// Security concern: Invalid severity values could bypass filters
826        #[test]
827        fn prop_severity_filter_accepts_any_u32(
828            severity_values in prop::collection::vec(any::<u32>(), 0..10)
829        ) {
830            let query = FindingsQuery::new("test-app")
831                .with_severity(severity_values.clone());
832
833            // Query should store the values even if invalid (API will validate)
834            assert_eq!(query.severity, Some(severity_values));
835        }
836
837        /// Property: CWE ID filter must handle arbitrary strings
838        ///
839        /// Security concern: Injection attacks via CWE IDs
840        #[test]
841        fn prop_cwe_filter_handles_arbitrary_strings(
842            cwe_ids in prop::collection::vec("\\PC*", 0..20)
843        ) {
844            let query = FindingsQuery::new("test-app")
845                .with_cwe(cwe_ids.clone());
846
847            assert_eq!(query.cwe_id, Some(cwe_ids));
848        }
849
850        /// Property: Pagination parameters must not cause overflow
851        ///
852        /// Security concern: Integer overflow in page calculations
853        #[test]
854        fn prop_pagination_parameters_safe(
855            page in any::<u32>(),
856            size in any::<u32>()
857        ) {
858            let query = FindingsQuery::new("test-app")
859                .with_pagination(page, size);
860
861            assert_eq!(query.page, Some(page));
862            assert_eq!(query.size, Some(size));
863
864            // Verify values are stored correctly without overflow
865            if let (Some(p), Some(s)) = (query.page, query.size) {
866                assert_eq!(p, page);
867                assert_eq!(s, size);
868            }
869        }
870    }
871
872    // ========================================================================
873    // Test 3: String Truncation Logic Safety
874    // ========================================================================
875
876    proptest! {
877        #![proptest_config(ProptestConfig {
878            cases: if cfg!(miri) { 5 } else { 1000 },
879            failure_persistence: None,
880            .. ProptestConfig::default()
881        })]
882
883        /// Property: Character counting and truncation must handle UTF-8 correctly
884        ///
885        /// Security concern: UTF-8 boundary violations could cause panics or corruption
886        #[test]
887        fn prop_string_truncation_utf8_safe(
888            text in "\\PC{0,2000}"
889        ) {
890            let char_count = text.chars().count();
891
892            // This simulates the truncation logic in get_findings (line 442-449)
893            if char_count > 500 {
894                let truncated: String = text.chars().take(500).collect();
895                let remaining = char_count.saturating_sub(500);
896
897                // Properties to verify:
898                // 1. Truncated string is valid UTF-8
899                assert!(truncated.is_ascii() || std::str::from_utf8(truncated.as_bytes()).is_ok());
900
901                // 2. Truncated string has at most 500 characters
902                assert!(truncated.chars().count() <= 500);
903
904                // 3. Remaining count is correct
905                assert_eq!(remaining, char_count.saturating_sub(500));
906
907                // 4. No overflow occurred
908                assert!(remaining <= char_count);
909            }
910        }
911
912        /// Property: saturating_sub must prevent underflow in all cases
913        ///
914        /// Security concern: Integer underflow could cause incorrect behavior
915        #[test]
916        #[allow(clippy::arithmetic_side_effects)] // Testing saturating_sub against normal subtraction
917        fn prop_saturating_sub_prevents_underflow(
918            a in any::<u32>(),
919            b in any::<u32>()
920        ) {
921            let result = a.saturating_sub(b);
922
923            // Property 1: Result never overflows/underflows
924            assert!(result <= a);
925
926            // Property 2: If a >= b, result is a - b
927            if a >= b {
928                assert_eq!(result, a - b);
929            } else {
930                // Property 3: If a < b, result is 0
931                assert_eq!(result, 0);
932            }
933        }
934    }
935
936    // ========================================================================
937    // Test 4: Vector Operations and Memory Safety
938    // ========================================================================
939
940    proptest! {
941        #![proptest_config(ProptestConfig {
942            cases: if cfg!(miri) { 5 } else { 500 },
943            failure_persistence: None,
944            .. ProptestConfig::default()
945        })]
946
947        /// Property: Query parameter vector building must not cause allocation issues
948        ///
949        /// Security concern: Excessive allocations could cause OOM
950        #[test]
951        #[allow(clippy::cast_possible_truncation)] // Test data: severity_count < 100, fits in u32
952        fn prop_query_params_vector_safe(
953            severity_count in 0usize..100,
954            cwe_count in 0usize..100
955        ) {
956            let query = FindingsQuery::new("test-app")
957                .with_severity((0..severity_count).map(|i| i as u32).collect())
958                .with_cwe((0..cwe_count).map(|i| format!("CWE-{}", i)).collect());
959
960            // Verify vectors are correctly sized
961            if let Some(ref severity) = query.severity {
962                assert_eq!(severity.len(), severity_count);
963            }
964
965            if let Some(ref cwe_ids) = query.cwe_id {
966                assert_eq!(cwe_ids.len(), cwe_count);
967            }
968        }
969
970        /// Property: Findings response must handle empty and large finding lists
971        ///
972        /// Security concern: DoS via excessive findings or incorrect empty handling
973        #[test]
974        #[allow(clippy::cast_possible_truncation)] // Test data: finding_count < 1000, fits in u32
975        #[allow(clippy::arithmetic_side_effects)] // Test data: controlled small values
976        fn prop_findings_list_memory_safe(
977            finding_count in 0usize..1000
978        ) {
979            let findings: Vec<RestFinding> = (0..finding_count)
980                .map(|i| create_test_finding(i as u32))
981                .collect();
982
983            let response = FindingsResponse {
984                embedded: FindingsEmbedded { findings },
985                links: create_test_links(),
986                page: PageInfo {
987                    size: 100,
988                    total_elements: finding_count as u32,
989                    total_pages: (finding_count / 100) as u32 + 1,
990                    number: 0,
991                },
992            };
993
994            // Should not panic accessing findings
995            let findings_slice = response.findings();
996            assert_eq!(findings_slice.len(), finding_count);
997        }
998    }
999
1000    // ========================================================================
1001    // Test 5: Error Handling and Display
1002    // ========================================================================
1003
1004    proptest! {
1005        #![proptest_config(ProptestConfig {
1006            cases: if cfg!(miri) { 5 } else { 500 },
1007            failure_persistence: None,
1008            .. ProptestConfig::default()
1009        })]
1010
1011        /// Property: Error messages must not leak sensitive information
1012        ///
1013        /// Security concern: Information disclosure via error messages
1014        #[test]
1015        fn prop_error_display_safe(
1016            app_guid in "[a-zA-Z0-9\\-]{1,100}",
1017            sandbox_guid in "[a-zA-Z0-9\\-]{1,100}",
1018            page in any::<u32>(),
1019            size in any::<u32>()
1020        ) {
1021            // Test all error variants
1022            let err1 = FindingsError::ApplicationNotFound {
1023                app_guid: app_guid.clone()
1024            };
1025            let msg1 = format!("{}", err1);
1026            assert!(msg1.contains(&app_guid));
1027
1028            let err2 = FindingsError::SandboxNotFound {
1029                app_guid: app_guid.clone(),
1030                sandbox_guid: sandbox_guid.clone(),
1031            };
1032            let msg2 = format!("{}", err2);
1033            assert!(msg2.contains(&app_guid));
1034            assert!(msg2.contains(&sandbox_guid));
1035
1036            let err3 = FindingsError::InvalidPagination { page, size };
1037            let msg3 = format!("{}", err3);
1038            assert!(msg3.contains(&page.to_string()));
1039            assert!(msg3.contains(&size.to_string()));
1040
1041            let err4 = FindingsError::NoFindings;
1042            let msg4 = format!("{}", err4);
1043            assert!(!msg4.is_empty());
1044        }
1045    }
1046
1047    // ========================================================================
1048    // Test 6: Builder Pattern Immutability and Consistency
1049    // ========================================================================
1050
1051    proptest! {
1052        #![proptest_config(ProptestConfig {
1053            cases: if cfg!(miri) { 5 } else { 500 },
1054            failure_persistence: None,
1055            .. ProptestConfig::default()
1056        })]
1057
1058        /// Property: Builder methods must chain correctly without losing data
1059        ///
1060        /// Security concern: Data loss in builder chain could bypass filters
1061        #[test]
1062        fn prop_builder_chain_preserves_data(
1063            app_guid in "[a-zA-Z0-9\\-]{1,50}",
1064            sandbox_guid in "[a-zA-Z0-9\\-]{1,50}",
1065            page in 0u32..1000,
1066            size in 1u32..1000,
1067            scan_type in "[A-Z]{1,20}"
1068        ) {
1069            let query = FindingsQuery::new(&app_guid)
1070                .with_sandbox(&sandbox_guid)
1071                .with_pagination(page, size)
1072                .with_scan_type(&scan_type)
1073                .policy_violations_only();
1074
1075            // Verify all values are preserved
1076            assert_eq!(query.app_guid.as_ref(), &app_guid);
1077            assert_eq!(query.context.as_ref().map(|s| s.as_ref()), Some(sandbox_guid.as_str()));
1078            assert_eq!(query.page, Some(page));
1079            assert_eq!(query.size, Some(size));
1080            assert_eq!(query.scan_type.as_ref().map(|s| s.as_ref()), Some(scan_type.as_str()));
1081            assert_eq!(query.violates_policy, Some(true));
1082        }
1083
1084        /// Property: Clone must create independent copies
1085        ///
1086        /// Security concern: Shared mutable state could cause race conditions
1087        #[test]
1088        fn prop_query_clone_independence(
1089            app_guid in "[a-zA-Z0-9\\-]{1,50}"
1090        ) {
1091            let query1 = FindingsQuery::new(&app_guid)
1092                .with_pagination(0, 100);
1093
1094            let query2 = query1.clone();
1095
1096            // Both should have same values
1097            assert_eq!(query1.app_guid, query2.app_guid);
1098            assert_eq!(query1.page, query2.page);
1099
1100            // Modify one doesn't affect the other (ownership test)
1101            let query3 = query2.with_pagination(1, 200);
1102            assert_eq!(query3.page, Some(1));
1103            // query1 is unchanged (though we can't easily verify without moving it)
1104        }
1105    }
1106
1107    // ========================================================================
1108    // Test 7: Serialization/Deserialization Safety
1109    // ========================================================================
1110
1111    proptest! {
1112        #![proptest_config(ProptestConfig {
1113            cases: if cfg!(miri) { 5 } else { 500 },
1114            failure_persistence: None,
1115            .. ProptestConfig::default()
1116        })]
1117
1118        /// Property: Serde serialization must be safe for all valid structures
1119        ///
1120        /// Security concern: Malformed JSON or integer overflow in serialized data
1121        #[test]
1122        fn prop_serde_roundtrip_safe(
1123            issue_id in any::<u32>(),
1124            count in any::<u32>(),
1125            build_id in any::<u64>(),
1126            severity in 0u32..6,
1127            cwe_id in any::<u32>(),
1128            line_number in any::<u32>()
1129        ) {
1130            let finding = RestFinding {
1131                issue_id,
1132                scan_type: "STATIC".to_string(),
1133                description: "Test".to_string(),
1134                count,
1135                context_type: "POLICY".to_string(),
1136                context_guid: "guid".to_string(),
1137                violates_policy: true,
1138                finding_status: FindingStatus {
1139                    first_found_date: "2024-01-01".to_string(),
1140                    status: "OPEN".to_string(),
1141                    resolution: "UNRESOLVED".to_string(),
1142                    mitigation_review_status: "NONE".to_string(),
1143                    new: true,
1144                    resolution_status: "UNRESOLVED".to_string(),
1145                    last_seen_date: "2024-01-01".to_string(),
1146                },
1147                finding_details: FindingDetails {
1148                    severity,
1149                    cwe: CweInfo {
1150                        id: cwe_id,
1151                        name: "Test CWE".to_string(),
1152                        href: "https://example.com".to_string(),
1153                    },
1154                    file_path: "/test".to_string(),
1155                    file_name: "test.rs".to_string(),
1156                    module: "test".to_string(),
1157                    relative_location: 0,
1158                    finding_category: FindingCategory {
1159                        id: 1,
1160                        name: "Test".to_string(),
1161                        href: "https://example.com".to_string(),
1162                    },
1163                    procedure: "test".to_string(),
1164                    exploitability: 0,
1165                    attack_vector: "Remote".to_string(),
1166                    file_line_number: line_number,
1167                },
1168                build_id,
1169            };
1170
1171            // Should serialize without panic
1172            let json = serde_json::to_string(&finding).expect("serialization should succeed");
1173
1174            // Should deserialize back to same values
1175            let deserialized: RestFinding = serde_json::from_str(&json)
1176                .expect("deserialization should succeed");
1177
1178            // Verify critical fields match
1179            assert_eq!(deserialized.issue_id, issue_id);
1180            assert_eq!(deserialized.count, count);
1181            assert_eq!(deserialized.build_id, build_id);
1182            assert_eq!(deserialized.finding_details.severity, severity);
1183            assert_eq!(deserialized.finding_details.file_line_number, line_number);
1184        }
1185
1186        /// Property: PageInfo must handle edge cases without overflow
1187        ///
1188        /// Security concern: Pagination arithmetic could overflow
1189        #[test]
1190        fn prop_page_info_arithmetic_safe(
1191            size in any::<u32>(),
1192            total_elements in any::<u32>(),
1193            total_pages in any::<u32>(),
1194            number in any::<u32>()
1195        ) {
1196            let page = PageInfo {
1197                size,
1198                total_elements,
1199                total_pages,
1200                number,
1201            };
1202
1203            // Should serialize/deserialize without issues
1204            let json = serde_json::to_string(&page).expect("serialization should succeed");
1205            let deserialized: PageInfo = serde_json::from_str(&json)
1206                .expect("deserialization should succeed");
1207
1208            assert_eq!(deserialized.size, size);
1209            assert_eq!(deserialized.total_elements, total_elements);
1210            assert_eq!(deserialized.total_pages, total_pages);
1211            assert_eq!(deserialized.number, number);
1212        }
1213    }
1214
1215    // ========================================================================
1216    // Helper Functions for Tests
1217    // ========================================================================
1218
1219    fn create_test_response(current_page: u32, total_pages: u32) -> FindingsResponse {
1220        FindingsResponse {
1221            embedded: FindingsEmbedded { findings: vec![] },
1222            links: create_test_links(),
1223            page: PageInfo {
1224                size: 100,
1225                total_elements: 1000,
1226                total_pages,
1227                number: current_page,
1228            },
1229        }
1230    }
1231
1232    fn create_test_links() -> FindingsLinks {
1233        FindingsLinks {
1234            first: Some(HalLink {
1235                href: "first".to_string(),
1236                templated: None,
1237            }),
1238            self_link: HalLink {
1239                href: "self".to_string(),
1240                templated: None,
1241            },
1242            next: Some(HalLink {
1243                href: "next".to_string(),
1244                templated: None,
1245            }),
1246            last: Some(HalLink {
1247                href: "last".to_string(),
1248                templated: None,
1249            }),
1250            application: HalLink {
1251                href: "app".to_string(),
1252                templated: None,
1253            },
1254            sca: None,
1255            sandbox: None,
1256        }
1257    }
1258
1259    fn create_test_finding(id: u32) -> RestFinding {
1260        RestFinding {
1261            issue_id: id,
1262            scan_type: "STATIC".to_string(),
1263            description: format!("Test finding {}", id),
1264            count: 1,
1265            context_type: "POLICY".to_string(),
1266            context_guid: "test-guid".to_string(),
1267            violates_policy: true,
1268            finding_status: FindingStatus {
1269                first_found_date: "2024-01-01".to_string(),
1270                status: "OPEN".to_string(),
1271                resolution: "UNRESOLVED".to_string(),
1272                mitigation_review_status: "NONE".to_string(),
1273                new: true,
1274                resolution_status: "UNRESOLVED".to_string(),
1275                last_seen_date: "2024-01-01".to_string(),
1276            },
1277            finding_details: FindingDetails {
1278                severity: 3,
1279                cwe: CweInfo {
1280                    id: 79,
1281                    name: "Cross-site Scripting".to_string(),
1282                    href: "https://cwe.mitre.org/data/definitions/79.html".to_string(),
1283                },
1284                file_path: "/src/test.rs".to_string(),
1285                file_name: "test.rs".to_string(),
1286                module: "test".to_string(),
1287                relative_location: 10,
1288                finding_category: FindingCategory {
1289                    id: 1,
1290                    name: "Security".to_string(),
1291                    href: "https://example.com".to_string(),
1292                },
1293                procedure: "test_function".to_string(),
1294                exploitability: 3,
1295                attack_vector: "Remote".to_string(),
1296                file_line_number: 42,
1297            },
1298            build_id: 12345,
1299        }
1300    }
1301}