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::{VeracodeClient, VeracodeError};
7use log::{debug, error, warn};
8use serde::{Deserialize, Serialize};
9use std::borrow::Cow;
10
11/// CWE (Common Weakness Enumeration) information
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CweInfo {
14    /// CWE ID number
15    pub id: u32,
16    /// CWE name/description
17    pub name: String,
18    /// API reference URL for this CWE
19    pub href: String,
20}
21
22/// Finding category information
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct FindingCategory {
25    /// Category ID
26    pub id: u32,
27    /// Category name
28    pub name: String,
29    /// API reference URL for this category
30    pub href: String,
31}
32
33/// Status information for a finding
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FindingStatus {
36    /// When this finding was first discovered
37    pub first_found_date: String,
38    /// Current status (OPEN, FIXED, etc.)
39    pub status: String,
40    /// Resolution status (RESOLVED, UNRESOLVED, etc.)
41    pub resolution: String,
42    /// Mitigation review status
43    pub mitigation_review_status: String,
44    /// Whether this is a new finding
45    pub new: bool,
46    /// Resolution status category
47    pub resolution_status: String,
48    /// When this finding was last seen
49    pub last_seen_date: String,
50}
51
52/// Detailed information about a finding
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct FindingDetails {
55    /// Severity level (0-5, where 5 is highest)
56    pub severity: u32,
57    /// CWE information
58    pub cwe: CweInfo,
59    /// File path where finding was located
60    pub file_path: String,
61    /// File name
62    pub file_name: String,
63    /// Module/library name
64    pub module: String,
65    /// Relative location within the file
66    pub relative_location: i32,
67    /// Finding category
68    pub finding_category: FindingCategory,
69    /// Procedure/method name where finding occurs
70    pub procedure: String,
71    /// Exploitability rating
72    pub exploitability: i32,
73    /// Attack vector description
74    pub attack_vector: String,
75    /// Line number in the file
76    pub file_line_number: u32,
77}
78
79/// A security finding from a Veracode scan
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct RestFinding {
82    /// Unique issue ID
83    pub issue_id: u32,
84    /// Type of scan that found this issue
85    pub scan_type: String,
86    /// Detailed description of the finding
87    pub description: String,
88    /// Number of occurrences
89    pub count: u32,
90    /// Context type (SANDBOX, POLICY, etc.)
91    pub context_type: String,
92    /// Context GUID (sandbox GUID for sandbox scans)
93    pub context_guid: String,
94    /// Whether this finding violates policy
95    pub violates_policy: bool,
96    /// Status information
97    pub finding_status: FindingStatus,
98    /// Detailed finding information
99    pub finding_details: FindingDetails,
100    /// Build ID where this finding was discovered
101    pub build_id: u64,
102}
103
104/// Embedded findings in HAL response
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct FindingsEmbedded {
107    /// Array of findings
108    pub findings: Vec<RestFinding>,
109}
110
111/// HAL link structure
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct HalLink {
114    /// URL for this link
115    pub href: String,
116    /// Whether this URL is a template
117    #[serde(default)]
118    pub templated: Option<bool>,
119}
120
121/// HAL links in findings response
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct FindingsLinks {
124    /// Link to first page (optional - may not be present for single page results)
125    pub first: Option<HalLink>,
126    /// Link to current page (self)
127    #[serde(rename = "self")]
128    pub self_link: HalLink,
129    /// Link to next page (optional)
130    pub next: Option<HalLink>,
131    /// Link to last page (optional - may not be present for single page results)
132    pub last: Option<HalLink>,
133    /// Link to application
134    pub application: HalLink,
135    /// Link to SCA findings (optional)
136    pub sca: Option<HalLink>,
137    /// Link to sandbox (optional for sandbox scans)
138    pub sandbox: Option<HalLink>,
139}
140
141/// Pagination information
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct PageInfo {
144    /// Number of items per page
145    pub size: u32,
146    /// Total number of findings across all pages
147    pub total_elements: u32,
148    /// Total number of pages
149    pub total_pages: u32,
150    /// Current page number (0-based)
151    pub number: u32,
152}
153
154/// Complete findings API response
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct FindingsResponse {
157    /// Embedded findings data
158    #[serde(rename = "_embedded")]
159    pub embedded: FindingsEmbedded,
160    /// HAL navigation links
161    #[serde(rename = "_links")]
162    pub links: FindingsLinks,
163    /// Pagination information
164    pub page: PageInfo,
165}
166
167impl FindingsResponse {
168    /// Get the findings from this response
169    #[must_use]
170    pub fn findings(&self) -> &[RestFinding] {
171        &self.embedded.findings
172    }
173
174    /// Check if there's a next page available
175    #[must_use]
176    pub fn has_next_page(&self) -> bool {
177        self.links.next.is_some()
178    }
179
180    /// Get current page number (0-based)
181    #[must_use]
182    pub fn current_page(&self) -> u32 {
183        self.page.number
184    }
185
186    /// Get total number of pages
187    #[must_use]
188    pub fn total_pages(&self) -> u32 {
189        self.page.total_pages
190    }
191
192    /// Check if this is the last page
193    #[must_use]
194    pub fn is_last_page(&self) -> bool {
195        self.page.number.saturating_add(1) >= self.page.total_pages
196    }
197
198    /// Get total number of findings across all pages
199    #[must_use]
200    pub fn total_elements(&self) -> u32 {
201        self.page.total_elements
202    }
203}
204
205/// Query parameters for findings API
206#[derive(Debug, Clone)]
207pub struct FindingsQuery<'a> {
208    /// Application GUID
209    pub app_guid: Cow<'a, str>,
210    /// Context (sandbox GUID for sandbox scans, None for policy scans)
211    pub context: Option<Cow<'a, str>>,
212    /// Page number (0-based)
213    pub page: Option<u32>,
214    /// Items per page
215    pub size: Option<u32>,
216    /// Filter by severity levels
217    pub severity: Option<Vec<u32>>,
218    /// Filter by CWE IDs
219    pub cwe_id: Option<Vec<String>>,
220    /// Filter by scan type
221    pub scan_type: Option<Cow<'a, str>>,
222    /// Filter by policy violations only
223    pub violates_policy: Option<bool>,
224}
225
226impl<'a> FindingsQuery<'a> {
227    /// Create new query for policy scan findings
228    #[must_use]
229    pub fn new(app_guid: &'a str) -> Self {
230        Self {
231            app_guid: Cow::Borrowed(app_guid),
232            context: None,
233            page: None,
234            size: None,
235            severity: None,
236            cwe_id: None,
237            scan_type: None,
238            violates_policy: None,
239        }
240    }
241
242    /// Create new query for sandbox scan findings
243    #[must_use]
244    pub fn for_sandbox(app_guid: &'a str, sandbox_guid: &'a str) -> Self {
245        Self {
246            app_guid: Cow::Borrowed(app_guid),
247            context: Some(Cow::Borrowed(sandbox_guid)),
248            page: None,
249            size: None,
250            severity: None,
251            cwe_id: None,
252            scan_type: None,
253            violates_policy: None,
254        }
255    }
256
257    /// Add sandbox context to existing query
258    #[must_use]
259    pub fn with_sandbox(mut self, sandbox_guid: &'a str) -> Self {
260        self.context = Some(Cow::Borrowed(sandbox_guid));
261        self
262    }
263
264    /// Add pagination parameters
265    #[must_use]
266    pub fn with_pagination(mut self, page: u32, size: u32) -> Self {
267        self.page = Some(page);
268        self.size = Some(size);
269        self
270    }
271
272    /// Filter by severity levels (0-5)
273    #[must_use]
274    pub fn with_severity(mut self, severity: Vec<u32>) -> Self {
275        self.severity = Some(severity);
276        self
277    }
278
279    /// Filter by CWE IDs
280    #[must_use]
281    pub fn with_cwe(mut self, cwe_ids: Vec<String>) -> Self {
282        self.cwe_id = Some(cwe_ids);
283        self
284    }
285
286    /// Filter by scan type
287    #[must_use]
288    pub fn with_scan_type(mut self, scan_type: &'a str) -> Self {
289        self.scan_type = Some(Cow::Borrowed(scan_type));
290        self
291    }
292
293    /// Filter to policy violations only
294    #[must_use]
295    pub fn policy_violations_only(mut self) -> Self {
296        self.violates_policy = Some(true);
297        self
298    }
299}
300
301/// Custom error types for findings API
302#[derive(Debug, thiserror::Error)]
303#[must_use = "Need to handle all error enum types."]
304pub enum FindingsError {
305    /// Application not found
306    #[error("Application not found: {app_guid}")]
307    ApplicationNotFound { app_guid: String },
308
309    /// Sandbox not found
310    #[error("Sandbox not found: {sandbox_guid} in application {app_guid}")]
311    SandboxNotFound {
312        app_guid: String,
313        sandbox_guid: String,
314    },
315
316    /// Invalid pagination parameters
317    #[error("Invalid pagination parameters: page={page}, size={size}")]
318    InvalidPagination { page: u32, size: u32 },
319
320    /// No findings available
321    #[error("No findings available for the specified context")]
322    NoFindings,
323
324    /// API request failed
325    #[error("Findings API request failed: {source}")]
326    RequestFailed {
327        #[from]
328        source: VeracodeError,
329    },
330}
331
332/// Findings API client
333#[derive(Clone)]
334pub struct FindingsApi {
335    client: VeracodeClient,
336}
337
338impl FindingsApi {
339    /// Create new findings API client
340    #[must_use]
341    pub fn new(client: VeracodeClient) -> Self {
342        Self { client }
343    }
344
345    /// Get findings with pagination
346    ///
347    /// # Errors
348    ///
349    /// Returns an error if the API request fails, the findings cannot be retrieved,
350    /// or authentication/authorization fails.
351    pub async fn get_findings(
352        &self,
353        query: &FindingsQuery<'_>,
354    ) -> Result<FindingsResponse, FindingsError> {
355        debug!("Getting findings for app: {}", query.app_guid);
356
357        let endpoint = format!("/appsec/v2/applications/{}/findings", query.app_guid);
358        let mut params = Vec::new();
359
360        // Add context for sandbox scans
361        if let Some(context) = &query.context {
362            params.push(("context".to_string(), context.to_string()));
363            debug!("Using sandbox context: {context}");
364        }
365
366        // Add pagination parameters
367        if let Some(page) = query.page {
368            params.push(("page".to_string(), page.to_string()));
369        }
370
371        if let Some(size) = query.size {
372            params.push(("size".to_string(), size.to_string()));
373        }
374
375        // Add filtering parameters
376        if let Some(severity) = &query.severity {
377            for sev in severity {
378                params.push(("severity".to_string(), sev.to_string()));
379            }
380        }
381
382        if let Some(cwe_ids) = &query.cwe_id {
383            for cwe in cwe_ids {
384                params.push(("cwe".to_string(), cwe.clone()));
385            }
386        }
387
388        if let Some(scan_type) = &query.scan_type {
389            params.push(("scan_type".to_string(), scan_type.to_string()));
390        }
391
392        if let Some(violates_policy) = query.violates_policy {
393            params.push(("violates_policy".to_string(), violates_policy.to_string()));
394        }
395
396        debug!(
397            "Calling findings endpoint: {} with {} parameters",
398            endpoint,
399            params.len()
400        );
401
402        // Convert Vec<(String, String)> to Vec<(&str, &str)>
403        let params_ref: Vec<(&str, &str)> = params
404            .iter()
405            .map(|(k, v)| (k.as_str(), v.as_str()))
406            .collect();
407
408        let response = self
409            .client
410            .get_with_query_params(&endpoint, &params_ref)
411            .await
412            .map_err(|e| match (&e, &query.context) {
413                (VeracodeError::NotFound { .. }, Some(context)) => FindingsError::SandboxNotFound {
414                    app_guid: query.app_guid.to_string(),
415                    sandbox_guid: context.to_string(),
416                },
417                (VeracodeError::NotFound { .. }, None) => FindingsError::ApplicationNotFound {
418                    app_guid: query.app_guid.to_string(),
419                },
420                (
421                    VeracodeError::Http(_)
422                    | VeracodeError::Serialization(_)
423                    | VeracodeError::Authentication(_)
424                    | VeracodeError::InvalidResponse(_)
425                    | VeracodeError::InvalidConfig(_)
426                    | VeracodeError::RetryExhausted(_)
427                    | VeracodeError::RateLimited { .. }
428                    | VeracodeError::Validation(_),
429                    _,
430                ) => FindingsError::RequestFailed { source: e },
431            })?;
432
433        // Get response text for debugging if parsing fails
434        let response_text = response
435            .text()
436            .await
437            .map_err(|e| FindingsError::RequestFailed {
438                source: VeracodeError::Http(e),
439            })?;
440
441        if response_text.chars().count() > 500 {
442            let truncated: String = response_text.chars().take(500).collect();
443            debug!(
444                "Raw API response (first 500 chars): {}... [truncated {} more characters]",
445                truncated,
446                response_text.chars().count().saturating_sub(500)
447            );
448        } else {
449            debug!("Raw API response: {response_text}");
450        }
451
452        let findings_response: FindingsResponse =
453            serde_json::from_str(&response_text).map_err(|e| {
454                error!("JSON parsing error: {e}");
455                debug!("Full response that failed to parse: {response_text}");
456                FindingsError::RequestFailed {
457                    source: VeracodeError::Serialization(e),
458                }
459            })?;
460
461        debug!(
462            "Retrieved {} findings on page {}/{}",
463            findings_response.findings().len(),
464            findings_response.current_page().saturating_add(1),
465            findings_response.total_pages()
466        );
467
468        Ok(findings_response)
469    }
470
471    /// Get all findings across all pages automatically
472    ///
473    /// # Errors
474    ///
475    /// Returns an error if the API request fails, the findings cannot be retrieved,
476    /// or authentication/authorization fails.
477    pub async fn get_all_findings(
478        &self,
479        query: &FindingsQuery<'_>,
480    ) -> Result<Vec<RestFinding>, FindingsError> {
481        debug!("Getting all findings for app: {}", query.app_guid);
482
483        let mut all_findings = Vec::new();
484        let mut current_page = 0;
485        let page_size = 500; // Use large page size for efficiency
486
487        loop {
488            let mut page_query = query.clone();
489            page_query.page = Some(current_page);
490            page_query.size = Some(page_size);
491
492            let response = self.get_findings(&page_query).await?;
493
494            if response.findings().is_empty() {
495                debug!("No more findings found on page {current_page}");
496                break;
497            }
498
499            let page_findings = response.findings().len();
500            all_findings.extend_from_slice(response.findings());
501
502            debug!(
503                "Added {} findings from page {}, total so far: {}",
504                page_findings,
505                current_page,
506                all_findings.len()
507            );
508
509            // Check if we've reached the last page
510            if response.is_last_page() {
511                debug!("Reached last page ({current_page}), stopping");
512                break;
513            }
514
515            current_page = current_page.saturating_add(1);
516
517            // Safety check to prevent infinite loops
518            if current_page > 1000 {
519                warn!(
520                    "Reached maximum page limit (1000) while fetching findings for app: {}",
521                    query.app_guid
522                );
523                break;
524            }
525        }
526
527        debug!(
528            "Retrieved total of {} findings across {} pages",
529            all_findings.len(),
530            current_page.saturating_add(1)
531        );
532        Ok(all_findings)
533    }
534
535    /// Get policy scan findings (convenience method)
536    ///
537    /// # Errors
538    ///
539    /// Returns an error if the API request fails, the findings cannot be retrieved,
540    /// or authentication/authorization fails.
541    pub async fn get_policy_findings(
542        &self,
543        app_guid: &str,
544    ) -> Result<FindingsResponse, FindingsError> {
545        self.get_findings(&FindingsQuery::new(app_guid)).await
546    }
547
548    /// Get sandbox findings (convenience method)
549    ///
550    /// # Errors
551    ///
552    /// Returns an error if the API request fails, the findings cannot be retrieved,
553    /// or authentication/authorization fails.
554    pub async fn get_sandbox_findings(
555        &self,
556        app_guid: &str,
557        sandbox_guid: &str,
558    ) -> Result<FindingsResponse, FindingsError> {
559        self.get_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
560            .await
561    }
562
563    /// Get all policy scan findings (convenience method)
564    ///
565    /// # Errors
566    ///
567    /// Returns an error if the API request fails, the findings cannot be retrieved,
568    /// or authentication/authorization fails.
569    pub async fn get_all_policy_findings(
570        &self,
571        app_guid: &str,
572    ) -> Result<Vec<RestFinding>, FindingsError> {
573        self.get_all_findings(&FindingsQuery::new(app_guid)).await
574    }
575
576    /// Get all sandbox findings (convenience method)
577    ///
578    /// # Errors
579    ///
580    /// Returns an error if the API request fails, the findings cannot be retrieved,
581    /// or authentication/authorization fails.
582    pub async fn get_all_sandbox_findings(
583        &self,
584        app_guid: &str,
585        sandbox_guid: &str,
586    ) -> Result<Vec<RestFinding>, FindingsError> {
587        self.get_all_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
588            .await
589    }
590}
591
592#[cfg(test)]
593#[allow(clippy::expect_used)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn test_findings_query_builder() {
599        let query = FindingsQuery::new("app-123")
600            .with_pagination(0, 50)
601            .with_severity(vec![3, 4, 5])
602            .policy_violations_only();
603
604        assert_eq!(query.app_guid, "app-123");
605        assert_eq!(query.page, Some(0));
606        assert_eq!(query.size, Some(50));
607        assert_eq!(query.severity, Some(vec![3, 4, 5]));
608        assert_eq!(query.violates_policy, Some(true));
609        assert!(query.context.is_none());
610    }
611
612    #[test]
613    fn test_sandbox_query_builder() {
614        let query = FindingsQuery::for_sandbox("app-123", "sandbox-456").with_pagination(1, 100);
615
616        assert_eq!(query.app_guid, "app-123");
617        assert_eq!(
618            query.context.as_ref().expect("should have context"),
619            "sandbox-456"
620        );
621        assert_eq!(query.page, Some(1));
622        assert_eq!(query.size, Some(100));
623    }
624
625    #[test]
626    fn test_findings_response_helpers() {
627        let response = FindingsResponse {
628            embedded: FindingsEmbedded {
629                findings: vec![], // Empty for test
630            },
631            links: FindingsLinks {
632                first: Some(HalLink {
633                    href: "first".to_string(),
634                    templated: None,
635                }),
636                self_link: HalLink {
637                    href: "self".to_string(),
638                    templated: None,
639                },
640                next: Some(HalLink {
641                    href: "next".to_string(),
642                    templated: None,
643                }),
644                last: Some(HalLink {
645                    href: "last".to_string(),
646                    templated: None,
647                }),
648                application: HalLink {
649                    href: "app".to_string(),
650                    templated: None,
651                },
652                sca: None,
653                sandbox: None,
654            },
655            page: PageInfo {
656                size: 20,
657                total_elements: 100,
658                total_pages: 5,
659                number: 2,
660            },
661        };
662
663        assert_eq!(response.current_page(), 2);
664        assert_eq!(response.total_pages(), 5);
665        assert_eq!(response.total_elements(), 100);
666        assert!(response.has_next_page());
667        assert!(!response.is_last_page());
668    }
669}