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::InvalidConfig(_)
427                    | VeracodeError::RetryExhausted(_)
428                    | VeracodeError::RateLimited { .. }
429                    | VeracodeError::Validation(_),
430                    _,
431                ) => FindingsError::RequestFailed { source: e },
432            })?;
433
434        // Get response text for debugging if parsing fails
435        let response_text = response
436            .text()
437            .await
438            .map_err(|e| FindingsError::RequestFailed {
439                source: VeracodeError::Http(e),
440            })?;
441
442        let char_count = response_text.chars().count();
443        if char_count > 500 {
444            let truncated: String = response_text.chars().take(500).collect();
445            debug!(
446                "Raw API response (first 500 chars): {}... [truncated {} more characters]",
447                truncated,
448                char_count.saturating_sub(500)
449            );
450        } else {
451            debug!("Raw API response: {response_text}");
452        }
453
454        // Validate JSON depth before parsing to prevent DoS attacks
455        validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
456            error!("JSON validation failed: {e}");
457            FindingsError::RequestFailed {
458                source: VeracodeError::InvalidResponse(format!("JSON validation failed: {}", e)),
459            }
460        })?;
461
462        let findings_response: FindingsResponse =
463            serde_json::from_str(&response_text).map_err(|e| {
464                error!("JSON parsing error: {e}");
465                debug!("Full response that failed to parse: {response_text}");
466                FindingsError::RequestFailed {
467                    source: VeracodeError::Serialization(e),
468                }
469            })?;
470
471        debug!(
472            "Retrieved {} findings on page {}/{}",
473            findings_response.findings().len(),
474            findings_response.current_page().saturating_add(1),
475            findings_response.total_pages()
476        );
477
478        Ok(findings_response)
479    }
480
481    /// Get all findings across all pages automatically
482    ///
483    /// # Errors
484    ///
485    /// Returns an error if the API request fails, the findings cannot be retrieved,
486    /// or authentication/authorization fails.
487    pub async fn get_all_findings(
488        &self,
489        query: &FindingsQuery<'_>,
490    ) -> Result<Vec<RestFinding>, FindingsError> {
491        debug!("Getting all findings for app: {}", query.app_guid);
492
493        let mut all_findings = Vec::new();
494        let mut current_page = 0;
495        let page_size = 500; // Use large page size for efficiency
496
497        loop {
498            let mut page_query = query.clone();
499            page_query.page = Some(current_page);
500            page_query.size = Some(page_size);
501
502            let response = self.get_findings(&page_query).await?;
503
504            if response.findings().is_empty() {
505                debug!("No more findings found on page {current_page}");
506                break;
507            }
508
509            let page_findings = response.findings().len();
510            all_findings.extend_from_slice(response.findings());
511
512            debug!(
513                "Added {} findings from page {}, total so far: {}",
514                page_findings,
515                current_page,
516                all_findings.len()
517            );
518
519            // Check if we've reached the last page
520            if response.is_last_page() {
521                debug!("Reached last page ({current_page}), stopping");
522                break;
523            }
524
525            current_page = current_page.saturating_add(1);
526
527            // Safety check to prevent infinite loops
528            if current_page > 1000 {
529                warn!(
530                    "Reached maximum page limit (1000) while fetching findings for app: {}",
531                    query.app_guid
532                );
533                break;
534            }
535        }
536
537        debug!(
538            "Retrieved total of {} findings across {} pages",
539            all_findings.len(),
540            current_page.saturating_add(1)
541        );
542        Ok(all_findings)
543    }
544
545    /// Get policy scan findings (convenience method)
546    ///
547    /// # Errors
548    ///
549    /// Returns an error if the API request fails, the findings cannot be retrieved,
550    /// or authentication/authorization fails.
551    pub async fn get_policy_findings(
552        &self,
553        app_guid: &str,
554    ) -> Result<FindingsResponse, FindingsError> {
555        self.get_findings(&FindingsQuery::new(app_guid)).await
556    }
557
558    /// Get sandbox findings (convenience method)
559    ///
560    /// # Errors
561    ///
562    /// Returns an error if the API request fails, the findings cannot be retrieved,
563    /// or authentication/authorization fails.
564    pub async fn get_sandbox_findings(
565        &self,
566        app_guid: &str,
567        sandbox_guid: &str,
568    ) -> Result<FindingsResponse, FindingsError> {
569        self.get_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
570            .await
571    }
572
573    /// Get all policy scan findings (convenience method)
574    ///
575    /// # Errors
576    ///
577    /// Returns an error if the API request fails, the findings cannot be retrieved,
578    /// or authentication/authorization fails.
579    pub async fn get_all_policy_findings(
580        &self,
581        app_guid: &str,
582    ) -> Result<Vec<RestFinding>, FindingsError> {
583        self.get_all_findings(&FindingsQuery::new(app_guid)).await
584    }
585
586    /// Get all sandbox findings (convenience method)
587    ///
588    /// # Errors
589    ///
590    /// Returns an error if the API request fails, the findings cannot be retrieved,
591    /// or authentication/authorization fails.
592    pub async fn get_all_sandbox_findings(
593        &self,
594        app_guid: &str,
595        sandbox_guid: &str,
596    ) -> Result<Vec<RestFinding>, FindingsError> {
597        self.get_all_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
598            .await
599    }
600}
601
602#[cfg(test)]
603#[allow(clippy::expect_used)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn test_findings_query_builder() {
609        let query = FindingsQuery::new("app-123")
610            .with_pagination(0, 50)
611            .with_severity(vec![3, 4, 5])
612            .policy_violations_only();
613
614        assert_eq!(query.app_guid, "app-123");
615        assert_eq!(query.page, Some(0));
616        assert_eq!(query.size, Some(50));
617        assert_eq!(query.severity, Some(vec![3, 4, 5]));
618        assert_eq!(query.violates_policy, Some(true));
619        assert!(query.context.is_none());
620    }
621
622    #[test]
623    fn test_sandbox_query_builder() {
624        let query = FindingsQuery::for_sandbox("app-123", "sandbox-456").with_pagination(1, 100);
625
626        assert_eq!(query.app_guid, "app-123");
627        assert_eq!(
628            query.context.as_ref().expect("should have context"),
629            "sandbox-456"
630        );
631        assert_eq!(query.page, Some(1));
632        assert_eq!(query.size, Some(100));
633    }
634
635    #[test]
636    fn test_findings_response_helpers() {
637        let response = FindingsResponse {
638            embedded: FindingsEmbedded {
639                findings: vec![], // Empty for test
640            },
641            links: FindingsLinks {
642                first: Some(HalLink {
643                    href: "first".to_string(),
644                    templated: None,
645                }),
646                self_link: HalLink {
647                    href: "self".to_string(),
648                    templated: None,
649                },
650                next: Some(HalLink {
651                    href: "next".to_string(),
652                    templated: None,
653                }),
654                last: Some(HalLink {
655                    href: "last".to_string(),
656                    templated: None,
657                }),
658                application: HalLink {
659                    href: "app".to_string(),
660                    templated: None,
661                },
662                sca: None,
663                sandbox: None,
664            },
665            page: PageInfo {
666                size: 20,
667                total_elements: 100,
668                total_pages: 5,
669                number: 2,
670            },
671        };
672
673        assert_eq!(response.current_page(), 2);
674        assert_eq!(response.total_pages(), 5);
675        assert_eq!(response.total_elements(), 100);
676        assert!(response.has_next_page());
677        assert!(!response.is_last_page());
678    }
679}
680
681// ============================================================================
682// SECURITY TESTS: Property-Based Testing with Proptest
683// ============================================================================
684
685#[cfg(test)]
686#[allow(clippy::expect_used)]
687mod proptest_security {
688    use super::*;
689    use proptest::prelude::*;
690
691    // ========================================================================
692    // Test 1: Pagination Boundary Conditions
693    // ========================================================================
694
695    proptest! {
696        #![proptest_config(ProptestConfig {
697            cases: if cfg!(miri) { 5 } else { 1000 },
698            failure_persistence: None,
699            .. ProptestConfig::default()
700        })]
701
702        /// Property: is_last_page() must correctly handle edge cases and overflow
703        ///
704        /// Security concern: Off-by-one errors in pagination could cause:
705        /// - Infinite loops fetching data
706        /// - Missing the last page of findings
707        /// - Integer overflow in page calculations
708        #[test]
709        fn prop_is_last_page_handles_overflow(
710            current_page in any::<u32>(),
711            total_pages in any::<u32>()
712        ) {
713            let response = create_test_response(current_page, total_pages);
714
715            // Property 1: Should never panic regardless of input values
716            let is_last = response.is_last_page();
717
718            // Property 2: Overflow-safe comparison logic
719            if current_page == u32::MAX {
720                // At max value, saturating_add returns u32::MAX
721                // u32::MAX >= any total_pages should be true
722                assert!(is_last);
723            } else if total_pages == 0 {
724                // If total_pages is 0, any page >= 0 is "last"
725                assert!(is_last);
726            } else {
727                // Normal case: current_page + 1 >= total_pages
728                let expected = current_page.saturating_add(1) >= total_pages;
729                assert_eq!(is_last, expected);
730            }
731        }
732
733        /// Property: has_next_page() must be consistent with next link presence
734        ///
735        /// Security concern: Inconsistent state could cause data loss or infinite loops
736        #[test]
737        fn prop_has_next_page_consistency(
738            current_page in 0u32..1000u32,
739            total_pages in 1u32..1001u32
740        ) {
741            let mut response = create_test_response(current_page, total_pages);
742
743            // If we're on the last page, remove the next link (realistic API behavior)
744            if response.is_last_page() {
745                response.links.next = None;
746            }
747
748            let has_next = response.has_next_page();
749            let is_last = response.is_last_page();
750
751            // Property 1: has_next_page depends only on the next link
752            if response.links.next.is_some() {
753                assert!(has_next);
754            } else {
755                assert!(!has_next);
756            }
757
758            // Property 2: If not on last page and has next link, has_next should be true
759            if !is_last && response.links.next.is_some() {
760                assert!(has_next);
761            }
762
763            // Test with no next link
764            response.links.next = None;
765            assert!(!response.has_next_page());
766        }
767
768        /// Property: Page number accessors must never panic
769        ///
770        /// Security concern: Panics in public API functions could cause DoS
771        #[test]
772        fn prop_page_accessors_never_panic(
773            current in any::<u32>(),
774            total in any::<u32>(),
775            elements in any::<u32>()
776        ) {
777            let mut response = create_test_response(current, total);
778            response.page.total_elements = elements;
779
780            // All these should work without panic
781            let _ = response.current_page();
782            let _ = response.total_pages();
783            let _ = response.total_elements();
784            let _ = response.is_last_page();
785            let _ = response.has_next_page();
786        }
787    }
788
789    // ========================================================================
790    // Test 2: FindingsQuery Builder Input Validation
791    // ========================================================================
792
793    proptest! {
794        #![proptest_config(ProptestConfig {
795            cases: if cfg!(miri) { 5 } else { 1000 },
796            failure_persistence: None,
797            .. ProptestConfig::default()
798        })]
799
800        /// Property: Query builder must handle arbitrary string inputs safely
801        ///
802        /// Security concern: SQL injection, command injection, or buffer overflow
803        /// via malicious GUID strings
804        #[test]
805        fn prop_query_builder_handles_malicious_strings(
806            app_guid in "\\PC*",
807            sandbox_guid in "\\PC*",
808            scan_type in "\\PC*"
809        ) {
810            // Should not panic with any string input
811            let query = FindingsQuery::new(&app_guid);
812            assert_eq!(query.app_guid.as_ref(), &app_guid);
813
814            let query = FindingsQuery::for_sandbox(&app_guid, &sandbox_guid);
815            assert_eq!(query.app_guid.as_ref(), &app_guid);
816            assert_eq!(query.context.as_ref().map(|s| s.as_ref()), Some(sandbox_guid.as_str()));
817
818            let query = query.with_scan_type(&scan_type);
819            assert_eq!(query.scan_type.as_ref().map(|s| s.as_ref()), Some(scan_type.as_str()));
820        }
821
822        /// Property: Severity filter must only accept valid severity levels (0-5)
823        ///
824        /// Security concern: Invalid severity values could bypass filters
825        #[test]
826        fn prop_severity_filter_accepts_any_u32(
827            severity_values in prop::collection::vec(any::<u32>(), 0..10)
828        ) {
829            let query = FindingsQuery::new("test-app")
830                .with_severity(severity_values.clone());
831
832            // Query should store the values even if invalid (API will validate)
833            assert_eq!(query.severity, Some(severity_values));
834        }
835
836        /// Property: CWE ID filter must handle arbitrary strings
837        ///
838        /// Security concern: Injection attacks via CWE IDs
839        #[test]
840        fn prop_cwe_filter_handles_arbitrary_strings(
841            cwe_ids in prop::collection::vec("\\PC*", 0..20)
842        ) {
843            let query = FindingsQuery::new("test-app")
844                .with_cwe(cwe_ids.clone());
845
846            assert_eq!(query.cwe_id, Some(cwe_ids));
847        }
848
849        /// Property: Pagination parameters must not cause overflow
850        ///
851        /// Security concern: Integer overflow in page calculations
852        #[test]
853        fn prop_pagination_parameters_safe(
854            page in any::<u32>(),
855            size in any::<u32>()
856        ) {
857            let query = FindingsQuery::new("test-app")
858                .with_pagination(page, size);
859
860            assert_eq!(query.page, Some(page));
861            assert_eq!(query.size, Some(size));
862
863            // Verify values are stored correctly without overflow
864            if let (Some(p), Some(s)) = (query.page, query.size) {
865                assert_eq!(p, page);
866                assert_eq!(s, size);
867            }
868        }
869    }
870
871    // ========================================================================
872    // Test 3: String Truncation Logic Safety
873    // ========================================================================
874
875    proptest! {
876        #![proptest_config(ProptestConfig {
877            cases: if cfg!(miri) { 5 } else { 1000 },
878            failure_persistence: None,
879            .. ProptestConfig::default()
880        })]
881
882        /// Property: Character counting and truncation must handle UTF-8 correctly
883        ///
884        /// Security concern: UTF-8 boundary violations could cause panics or corruption
885        #[test]
886        fn prop_string_truncation_utf8_safe(
887            text in "\\PC{0,2000}"
888        ) {
889            let char_count = text.chars().count();
890
891            // This simulates the truncation logic in get_findings (line 442-449)
892            if char_count > 500 {
893                let truncated: String = text.chars().take(500).collect();
894                let remaining = char_count.saturating_sub(500);
895
896                // Properties to verify:
897                // 1. Truncated string is valid UTF-8
898                assert!(truncated.is_ascii() || std::str::from_utf8(truncated.as_bytes()).is_ok());
899
900                // 2. Truncated string has at most 500 characters
901                assert!(truncated.chars().count() <= 500);
902
903                // 3. Remaining count is correct
904                assert_eq!(remaining, char_count.saturating_sub(500));
905
906                // 4. No overflow occurred
907                assert!(remaining <= char_count);
908            }
909        }
910
911        /// Property: saturating_sub must prevent underflow in all cases
912        ///
913        /// Security concern: Integer underflow could cause incorrect behavior
914        #[test]
915        #[allow(clippy::arithmetic_side_effects)] // Testing saturating_sub against normal subtraction
916        fn prop_saturating_sub_prevents_underflow(
917            a in any::<u32>(),
918            b in any::<u32>()
919        ) {
920            let result = a.saturating_sub(b);
921
922            // Property 1: Result never overflows/underflows
923            assert!(result <= a);
924
925            // Property 2: If a >= b, result is a - b
926            if a >= b {
927                assert_eq!(result, a - b);
928            } else {
929                // Property 3: If a < b, result is 0
930                assert_eq!(result, 0);
931            }
932        }
933    }
934
935    // ========================================================================
936    // Test 4: Vector Operations and Memory Safety
937    // ========================================================================
938
939    proptest! {
940        #![proptest_config(ProptestConfig {
941            cases: if cfg!(miri) { 5 } else { 500 },
942            failure_persistence: None,
943            .. ProptestConfig::default()
944        })]
945
946        /// Property: Query parameter vector building must not cause allocation issues
947        ///
948        /// Security concern: Excessive allocations could cause OOM
949        #[test]
950        #[allow(clippy::cast_possible_truncation)] // Test data: severity_count < 100, fits in u32
951        fn prop_query_params_vector_safe(
952            severity_count in 0usize..100,
953            cwe_count in 0usize..100
954        ) {
955            let query = FindingsQuery::new("test-app")
956                .with_severity((0..severity_count).map(|i| i as u32).collect())
957                .with_cwe((0..cwe_count).map(|i| format!("CWE-{}", i)).collect());
958
959            // Verify vectors are correctly sized
960            if let Some(ref severity) = query.severity {
961                assert_eq!(severity.len(), severity_count);
962            }
963
964            if let Some(ref cwe_ids) = query.cwe_id {
965                assert_eq!(cwe_ids.len(), cwe_count);
966            }
967        }
968
969        /// Property: Findings response must handle empty and large finding lists
970        ///
971        /// Security concern: DoS via excessive findings or incorrect empty handling
972        #[test]
973        #[allow(clippy::cast_possible_truncation)] // Test data: finding_count < 1000, fits in u32
974        #[allow(clippy::arithmetic_side_effects)] // Test data: controlled small values
975        fn prop_findings_list_memory_safe(
976            finding_count in 0usize..1000
977        ) {
978            let findings: Vec<RestFinding> = (0..finding_count)
979                .map(|i| create_test_finding(i as u32))
980                .collect();
981
982            let response = FindingsResponse {
983                embedded: FindingsEmbedded { findings },
984                links: create_test_links(),
985                page: PageInfo {
986                    size: 100,
987                    total_elements: finding_count as u32,
988                    total_pages: (finding_count / 100) as u32 + 1,
989                    number: 0,
990                },
991            };
992
993            // Should not panic accessing findings
994            let findings_slice = response.findings();
995            assert_eq!(findings_slice.len(), finding_count);
996        }
997    }
998
999    // ========================================================================
1000    // Test 5: Error Handling and Display
1001    // ========================================================================
1002
1003    proptest! {
1004        #![proptest_config(ProptestConfig {
1005            cases: if cfg!(miri) { 5 } else { 500 },
1006            failure_persistence: None,
1007            .. ProptestConfig::default()
1008        })]
1009
1010        /// Property: Error messages must not leak sensitive information
1011        ///
1012        /// Security concern: Information disclosure via error messages
1013        #[test]
1014        fn prop_error_display_safe(
1015            app_guid in "[a-zA-Z0-9\\-]{1,100}",
1016            sandbox_guid in "[a-zA-Z0-9\\-]{1,100}",
1017            page in any::<u32>(),
1018            size in any::<u32>()
1019        ) {
1020            // Test all error variants
1021            let err1 = FindingsError::ApplicationNotFound {
1022                app_guid: app_guid.clone()
1023            };
1024            let msg1 = format!("{}", err1);
1025            assert!(msg1.contains(&app_guid));
1026
1027            let err2 = FindingsError::SandboxNotFound {
1028                app_guid: app_guid.clone(),
1029                sandbox_guid: sandbox_guid.clone(),
1030            };
1031            let msg2 = format!("{}", err2);
1032            assert!(msg2.contains(&app_guid));
1033            assert!(msg2.contains(&sandbox_guid));
1034
1035            let err3 = FindingsError::InvalidPagination { page, size };
1036            let msg3 = format!("{}", err3);
1037            assert!(msg3.contains(&page.to_string()));
1038            assert!(msg3.contains(&size.to_string()));
1039
1040            let err4 = FindingsError::NoFindings;
1041            let msg4 = format!("{}", err4);
1042            assert!(!msg4.is_empty());
1043        }
1044    }
1045
1046    // ========================================================================
1047    // Test 6: Builder Pattern Immutability and Consistency
1048    // ========================================================================
1049
1050    proptest! {
1051        #![proptest_config(ProptestConfig {
1052            cases: if cfg!(miri) { 5 } else { 500 },
1053            failure_persistence: None,
1054            .. ProptestConfig::default()
1055        })]
1056
1057        /// Property: Builder methods must chain correctly without losing data
1058        ///
1059        /// Security concern: Data loss in builder chain could bypass filters
1060        #[test]
1061        fn prop_builder_chain_preserves_data(
1062            app_guid in "[a-zA-Z0-9\\-]{1,50}",
1063            sandbox_guid in "[a-zA-Z0-9\\-]{1,50}",
1064            page in 0u32..1000,
1065            size in 1u32..1000,
1066            scan_type in "[A-Z]{1,20}"
1067        ) {
1068            let query = FindingsQuery::new(&app_guid)
1069                .with_sandbox(&sandbox_guid)
1070                .with_pagination(page, size)
1071                .with_scan_type(&scan_type)
1072                .policy_violations_only();
1073
1074            // Verify all values are preserved
1075            assert_eq!(query.app_guid.as_ref(), &app_guid);
1076            assert_eq!(query.context.as_ref().map(|s| s.as_ref()), Some(sandbox_guid.as_str()));
1077            assert_eq!(query.page, Some(page));
1078            assert_eq!(query.size, Some(size));
1079            assert_eq!(query.scan_type.as_ref().map(|s| s.as_ref()), Some(scan_type.as_str()));
1080            assert_eq!(query.violates_policy, Some(true));
1081        }
1082
1083        /// Property: Clone must create independent copies
1084        ///
1085        /// Security concern: Shared mutable state could cause race conditions
1086        #[test]
1087        fn prop_query_clone_independence(
1088            app_guid in "[a-zA-Z0-9\\-]{1,50}"
1089        ) {
1090            let query1 = FindingsQuery::new(&app_guid)
1091                .with_pagination(0, 100);
1092
1093            let query2 = query1.clone();
1094
1095            // Both should have same values
1096            assert_eq!(query1.app_guid, query2.app_guid);
1097            assert_eq!(query1.page, query2.page);
1098
1099            // Modify one doesn't affect the other (ownership test)
1100            let query3 = query2.with_pagination(1, 200);
1101            assert_eq!(query3.page, Some(1));
1102            // query1 is unchanged (though we can't easily verify without moving it)
1103        }
1104    }
1105
1106    // ========================================================================
1107    // Test 7: Serialization/Deserialization Safety
1108    // ========================================================================
1109
1110    proptest! {
1111        #![proptest_config(ProptestConfig {
1112            cases: if cfg!(miri) { 5 } else { 500 },
1113            failure_persistence: None,
1114            .. ProptestConfig::default()
1115        })]
1116
1117        /// Property: Serde serialization must be safe for all valid structures
1118        ///
1119        /// Security concern: Malformed JSON or integer overflow in serialized data
1120        #[test]
1121        fn prop_serde_roundtrip_safe(
1122            issue_id in any::<u32>(),
1123            count in any::<u32>(),
1124            build_id in any::<u64>(),
1125            severity in 0u32..6,
1126            cwe_id in any::<u32>(),
1127            line_number in any::<u32>()
1128        ) {
1129            let finding = RestFinding {
1130                issue_id,
1131                scan_type: "STATIC".to_string(),
1132                description: "Test".to_string(),
1133                count,
1134                context_type: "POLICY".to_string(),
1135                context_guid: "guid".to_string(),
1136                violates_policy: true,
1137                finding_status: FindingStatus {
1138                    first_found_date: "2024-01-01".to_string(),
1139                    status: "OPEN".to_string(),
1140                    resolution: "UNRESOLVED".to_string(),
1141                    mitigation_review_status: "NONE".to_string(),
1142                    new: true,
1143                    resolution_status: "UNRESOLVED".to_string(),
1144                    last_seen_date: "2024-01-01".to_string(),
1145                },
1146                finding_details: FindingDetails {
1147                    severity,
1148                    cwe: CweInfo {
1149                        id: cwe_id,
1150                        name: "Test CWE".to_string(),
1151                        href: "https://example.com".to_string(),
1152                    },
1153                    file_path: "/test".to_string(),
1154                    file_name: "test.rs".to_string(),
1155                    module: "test".to_string(),
1156                    relative_location: 0,
1157                    finding_category: FindingCategory {
1158                        id: 1,
1159                        name: "Test".to_string(),
1160                        href: "https://example.com".to_string(),
1161                    },
1162                    procedure: "test".to_string(),
1163                    exploitability: 0,
1164                    attack_vector: "Remote".to_string(),
1165                    file_line_number: line_number,
1166                },
1167                build_id,
1168            };
1169
1170            // Should serialize without panic
1171            let json = serde_json::to_string(&finding).expect("serialization should succeed");
1172
1173            // Should deserialize back to same values
1174            let deserialized: RestFinding = serde_json::from_str(&json)
1175                .expect("deserialization should succeed");
1176
1177            // Verify critical fields match
1178            assert_eq!(deserialized.issue_id, issue_id);
1179            assert_eq!(deserialized.count, count);
1180            assert_eq!(deserialized.build_id, build_id);
1181            assert_eq!(deserialized.finding_details.severity, severity);
1182            assert_eq!(deserialized.finding_details.file_line_number, line_number);
1183        }
1184
1185        /// Property: PageInfo must handle edge cases without overflow
1186        ///
1187        /// Security concern: Pagination arithmetic could overflow
1188        #[test]
1189        fn prop_page_info_arithmetic_safe(
1190            size in any::<u32>(),
1191            total_elements in any::<u32>(),
1192            total_pages in any::<u32>(),
1193            number in any::<u32>()
1194        ) {
1195            let page = PageInfo {
1196                size,
1197                total_elements,
1198                total_pages,
1199                number,
1200            };
1201
1202            // Should serialize/deserialize without issues
1203            let json = serde_json::to_string(&page).expect("serialization should succeed");
1204            let deserialized: PageInfo = serde_json::from_str(&json)
1205                .expect("deserialization should succeed");
1206
1207            assert_eq!(deserialized.size, size);
1208            assert_eq!(deserialized.total_elements, total_elements);
1209            assert_eq!(deserialized.total_pages, total_pages);
1210            assert_eq!(deserialized.number, number);
1211        }
1212    }
1213
1214    // ========================================================================
1215    // Helper Functions for Tests
1216    // ========================================================================
1217
1218    fn create_test_response(current_page: u32, total_pages: u32) -> FindingsResponse {
1219        FindingsResponse {
1220            embedded: FindingsEmbedded { findings: vec![] },
1221            links: create_test_links(),
1222            page: PageInfo {
1223                size: 100,
1224                total_elements: 1000,
1225                total_pages,
1226                number: current_page,
1227            },
1228        }
1229    }
1230
1231    fn create_test_links() -> FindingsLinks {
1232        FindingsLinks {
1233            first: Some(HalLink {
1234                href: "first".to_string(),
1235                templated: None,
1236            }),
1237            self_link: HalLink {
1238                href: "self".to_string(),
1239                templated: None,
1240            },
1241            next: Some(HalLink {
1242                href: "next".to_string(),
1243                templated: None,
1244            }),
1245            last: Some(HalLink {
1246                href: "last".to_string(),
1247                templated: None,
1248            }),
1249            application: HalLink {
1250                href: "app".to_string(),
1251                templated: None,
1252            },
1253            sca: None,
1254            sandbox: None,
1255        }
1256    }
1257
1258    fn create_test_finding(id: u32) -> RestFinding {
1259        RestFinding {
1260            issue_id: id,
1261            scan_type: "STATIC".to_string(),
1262            description: format!("Test finding {}", id),
1263            count: 1,
1264            context_type: "POLICY".to_string(),
1265            context_guid: "test-guid".to_string(),
1266            violates_policy: true,
1267            finding_status: FindingStatus {
1268                first_found_date: "2024-01-01".to_string(),
1269                status: "OPEN".to_string(),
1270                resolution: "UNRESOLVED".to_string(),
1271                mitigation_review_status: "NONE".to_string(),
1272                new: true,
1273                resolution_status: "UNRESOLVED".to_string(),
1274                last_seen_date: "2024-01-01".to_string(),
1275            },
1276            finding_details: FindingDetails {
1277                severity: 3,
1278                cwe: CweInfo {
1279                    id: 79,
1280                    name: "Cross-site Scripting".to_string(),
1281                    href: "https://cwe.mitre.org/data/definitions/79.html".to_string(),
1282                },
1283                file_path: "/src/test.rs".to_string(),
1284                file_name: "test.rs".to_string(),
1285                module: "test".to_string(),
1286                relative_location: 10,
1287                finding_category: FindingCategory {
1288                    id: 1,
1289                    name: "Security".to_string(),
1290                    href: "https://example.com".to_string(),
1291                },
1292                procedure: "test_function".to_string(),
1293                exploitability: 3,
1294                attack_vector: "Remote".to_string(),
1295                file_line_number: 42,
1296            },
1297            build_id: 12345,
1298        }
1299    }
1300}