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 + 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)]
303pub enum FindingsError {
304    /// Application not found
305    #[error("Application not found: {app_guid}")]
306    ApplicationNotFound { app_guid: String },
307
308    /// Sandbox not found
309    #[error("Sandbox not found: {sandbox_guid} in application {app_guid}")]
310    SandboxNotFound {
311        app_guid: String,
312        sandbox_guid: String,
313    },
314
315    /// Invalid pagination parameters
316    #[error("Invalid pagination parameters: page={page}, size={size}")]
317    InvalidPagination { page: u32, size: u32 },
318
319    /// No findings available
320    #[error("No findings available for the specified context")]
321    NoFindings,
322
323    /// API request failed
324    #[error("Findings API request failed: {source}")]
325    RequestFailed {
326        #[from]
327        source: VeracodeError,
328    },
329}
330
331/// Findings API client
332#[derive(Clone)]
333pub struct FindingsApi {
334    client: VeracodeClient,
335}
336
337impl FindingsApi {
338    /// Create new findings API client
339    #[must_use]
340    pub fn new(client: VeracodeClient) -> Self {
341        Self { client }
342    }
343
344    /// Get findings with pagination
345    pub async fn get_findings(
346        &self,
347        query: &FindingsQuery<'_>,
348    ) -> Result<FindingsResponse, FindingsError> {
349        debug!("Getting findings for app: {}", query.app_guid);
350
351        let endpoint = format!("/appsec/v2/applications/{}/findings", query.app_guid);
352        let mut params = Vec::new();
353
354        // Add context for sandbox scans
355        if let Some(context) = &query.context {
356            params.push(("context".to_string(), context.to_string()));
357            debug!("Using sandbox context: {context}");
358        }
359
360        // Add pagination parameters
361        if let Some(page) = query.page {
362            params.push(("page".to_string(), page.to_string()));
363        }
364
365        if let Some(size) = query.size {
366            params.push(("size".to_string(), size.to_string()));
367        }
368
369        // Add filtering parameters
370        if let Some(severity) = &query.severity {
371            for sev in severity {
372                params.push(("severity".to_string(), sev.to_string()));
373            }
374        }
375
376        if let Some(cwe_ids) = &query.cwe_id {
377            for cwe in cwe_ids {
378                params.push(("cwe".to_string(), cwe.clone()));
379            }
380        }
381
382        if let Some(scan_type) = &query.scan_type {
383            params.push(("scan_type".to_string(), scan_type.to_string()));
384        }
385
386        if let Some(violates_policy) = query.violates_policy {
387            params.push(("violates_policy".to_string(), violates_policy.to_string()));
388        }
389
390        debug!(
391            "Calling findings endpoint: {} with {} parameters",
392            endpoint,
393            params.len()
394        );
395
396        // Convert Vec<(String, String)> to Vec<(&str, &str)>
397        let params_ref: Vec<(&str, &str)> = params
398            .iter()
399            .map(|(k, v)| (k.as_str(), v.as_str()))
400            .collect();
401
402        let response = self
403            .client
404            .get_with_query_params(&endpoint, &params_ref)
405            .await
406            .map_err(|e| match &e {
407                VeracodeError::NotFound { .. } if query.context.is_some() => {
408                    FindingsError::SandboxNotFound {
409                        app_guid: query.app_guid.to_string(),
410                        sandbox_guid: query
411                            .context
412                            .as_ref()
413                            .expect("context is_some() was checked")
414                            .to_string(),
415                    }
416                }
417                VeracodeError::NotFound { .. } => FindingsError::ApplicationNotFound {
418                    app_guid: query.app_guid.to_string(),
419                },
420                _ => FindingsError::RequestFailed { source: e },
421            })?;
422
423        // Get response text for debugging if parsing fails
424        let response_text = response
425            .text()
426            .await
427            .map_err(|e| FindingsError::RequestFailed {
428                source: VeracodeError::Http(e),
429            })?;
430
431        if response_text.len() > 500 {
432            debug!(
433                "Raw API response (first 500 chars): {}... [truncated {} more characters]",
434                &response_text[..500],
435                response_text.len() - 500
436            );
437        } else {
438            debug!("Raw API response: {response_text}");
439        }
440
441        let findings_response: FindingsResponse =
442            serde_json::from_str(&response_text).map_err(|e| {
443                error!("JSON parsing error: {e}");
444                debug!("Full response that failed to parse: {response_text}");
445                FindingsError::RequestFailed {
446                    source: VeracodeError::Serialization(e),
447                }
448            })?;
449
450        debug!(
451            "Retrieved {} findings on page {}/{}",
452            findings_response.findings().len(),
453            findings_response.current_page() + 1,
454            findings_response.total_pages()
455        );
456
457        Ok(findings_response)
458    }
459
460    /// Get all findings across all pages automatically
461    pub async fn get_all_findings(
462        &self,
463        query: &FindingsQuery<'_>,
464    ) -> Result<Vec<RestFinding>, FindingsError> {
465        debug!("Getting all findings for app: {}", query.app_guid);
466
467        let mut all_findings = Vec::new();
468        let mut current_page = 0;
469        let page_size = 500; // Use large page size for efficiency
470
471        loop {
472            let mut page_query = query.clone();
473            page_query.page = Some(current_page);
474            page_query.size = Some(page_size);
475
476            let response = self.get_findings(&page_query).await?;
477
478            if response.findings().is_empty() {
479                debug!("No more findings found on page {current_page}");
480                break;
481            }
482
483            let page_findings = response.findings().len();
484            all_findings.extend_from_slice(response.findings());
485
486            debug!(
487                "Added {} findings from page {}, total so far: {}",
488                page_findings,
489                current_page,
490                all_findings.len()
491            );
492
493            // Check if we've reached the last page
494            if response.is_last_page() {
495                debug!("Reached last page ({current_page}), stopping");
496                break;
497            }
498
499            current_page += 1;
500
501            // Safety check to prevent infinite loops
502            if current_page > 1000 {
503                warn!(
504                    "Reached maximum page limit (1000) while fetching findings for app: {}",
505                    query.app_guid
506                );
507                break;
508            }
509        }
510
511        debug!(
512            "Retrieved total of {} findings across {} pages",
513            all_findings.len(),
514            current_page + 1
515        );
516        Ok(all_findings)
517    }
518
519    /// Get policy scan findings (convenience method)
520    pub async fn get_policy_findings(
521        &self,
522        app_guid: &str,
523    ) -> Result<FindingsResponse, FindingsError> {
524        self.get_findings(&FindingsQuery::new(app_guid)).await
525    }
526
527    /// Get sandbox findings (convenience method)
528    pub async fn get_sandbox_findings(
529        &self,
530        app_guid: &str,
531        sandbox_guid: &str,
532    ) -> Result<FindingsResponse, FindingsError> {
533        self.get_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
534            .await
535    }
536
537    /// Get all policy scan findings (convenience method)
538    pub async fn get_all_policy_findings(
539        &self,
540        app_guid: &str,
541    ) -> Result<Vec<RestFinding>, FindingsError> {
542        self.get_all_findings(&FindingsQuery::new(app_guid)).await
543    }
544
545    /// Get all sandbox findings (convenience method)
546    pub async fn get_all_sandbox_findings(
547        &self,
548        app_guid: &str,
549        sandbox_guid: &str,
550    ) -> Result<Vec<RestFinding>, FindingsError> {
551        self.get_all_findings(&FindingsQuery::for_sandbox(app_guid, sandbox_guid))
552            .await
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_findings_query_builder() {
562        let query = FindingsQuery::new("app-123")
563            .with_pagination(0, 50)
564            .with_severity(vec![3, 4, 5])
565            .policy_violations_only();
566
567        assert_eq!(query.app_guid, "app-123");
568        assert_eq!(query.page, Some(0));
569        assert_eq!(query.size, Some(50));
570        assert_eq!(query.severity, Some(vec![3, 4, 5]));
571        assert_eq!(query.violates_policy, Some(true));
572        assert!(query.context.is_none());
573    }
574
575    #[test]
576    fn test_sandbox_query_builder() {
577        let query = FindingsQuery::for_sandbox("app-123", "sandbox-456").with_pagination(1, 100);
578
579        assert_eq!(query.app_guid, "app-123");
580        assert_eq!(query.context.as_ref().unwrap(), "sandbox-456");
581        assert_eq!(query.page, Some(1));
582        assert_eq!(query.size, Some(100));
583    }
584
585    #[test]
586    fn test_findings_response_helpers() {
587        let response = FindingsResponse {
588            embedded: FindingsEmbedded {
589                findings: vec![], // Empty for test
590            },
591            links: FindingsLinks {
592                first: Some(HalLink {
593                    href: "first".to_string(),
594                    templated: None,
595                }),
596                self_link: HalLink {
597                    href: "self".to_string(),
598                    templated: None,
599                },
600                next: Some(HalLink {
601                    href: "next".to_string(),
602                    templated: None,
603                }),
604                last: Some(HalLink {
605                    href: "last".to_string(),
606                    templated: None,
607                }),
608                application: HalLink {
609                    href: "app".to_string(),
610                    templated: None,
611                },
612                sca: None,
613                sandbox: None,
614            },
615            page: PageInfo {
616                size: 20,
617                total_elements: 100,
618                total_pages: 5,
619                number: 2,
620            },
621        };
622
623        assert_eq!(response.current_page(), 2);
624        assert_eq!(response.total_pages(), 5);
625        assert_eq!(response.total_elements(), 100);
626        assert!(response.has_next_page());
627        assert!(!response.is_last_page());
628    }
629}