1use crate::{VeracodeClient, VeracodeError};
7use log::{debug, error, warn};
8use serde::{Deserialize, Serialize};
9use std::borrow::Cow;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CweInfo {
14 pub id: u32,
16 pub name: String,
18 pub href: String,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct FindingCategory {
25 pub id: u32,
27 pub name: String,
29 pub href: String,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FindingStatus {
36 pub first_found_date: String,
38 pub status: String,
40 pub resolution: String,
42 pub mitigation_review_status: String,
44 pub new: bool,
46 pub resolution_status: String,
48 pub last_seen_date: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct FindingDetails {
55 pub severity: u32,
57 pub cwe: CweInfo,
59 pub file_path: String,
61 pub file_name: String,
63 pub module: String,
65 pub relative_location: i32,
67 pub finding_category: FindingCategory,
69 pub procedure: String,
71 pub exploitability: i32,
73 pub attack_vector: String,
75 pub file_line_number: u32,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct RestFinding {
82 pub issue_id: u32,
84 pub scan_type: String,
86 pub description: String,
88 pub count: u32,
90 pub context_type: String,
92 pub context_guid: String,
94 pub violates_policy: bool,
96 pub finding_status: FindingStatus,
98 pub finding_details: FindingDetails,
100 pub build_id: u64,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct FindingsEmbedded {
107 pub findings: Vec<RestFinding>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct HalLink {
114 pub href: String,
116 #[serde(default)]
118 pub templated: Option<bool>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct FindingsLinks {
124 pub first: Option<HalLink>,
126 #[serde(rename = "self")]
128 pub self_link: HalLink,
129 pub next: Option<HalLink>,
131 pub last: Option<HalLink>,
133 pub application: HalLink,
135 pub sca: Option<HalLink>,
137 pub sandbox: Option<HalLink>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct PageInfo {
144 pub size: u32,
146 pub total_elements: u32,
148 pub total_pages: u32,
150 pub number: u32,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct FindingsResponse {
157 #[serde(rename = "_embedded")]
159 pub embedded: FindingsEmbedded,
160 #[serde(rename = "_links")]
162 pub links: FindingsLinks,
163 pub page: PageInfo,
165}
166
167impl FindingsResponse {
168 #[must_use]
170 pub fn findings(&self) -> &[RestFinding] {
171 &self.embedded.findings
172 }
173
174 #[must_use]
176 pub fn has_next_page(&self) -> bool {
177 self.links.next.is_some()
178 }
179
180 #[must_use]
182 pub fn current_page(&self) -> u32 {
183 self.page.number
184 }
185
186 #[must_use]
188 pub fn total_pages(&self) -> u32 {
189 self.page.total_pages
190 }
191
192 #[must_use]
194 pub fn is_last_page(&self) -> bool {
195 self.page.number.saturating_add(1) >= self.page.total_pages
196 }
197
198 #[must_use]
200 pub fn total_elements(&self) -> u32 {
201 self.page.total_elements
202 }
203}
204
205#[derive(Debug, Clone)]
207pub struct FindingsQuery<'a> {
208 pub app_guid: Cow<'a, str>,
210 pub context: Option<Cow<'a, str>>,
212 pub page: Option<u32>,
214 pub size: Option<u32>,
216 pub severity: Option<Vec<u32>>,
218 pub cwe_id: Option<Vec<String>>,
220 pub scan_type: Option<Cow<'a, str>>,
222 pub violates_policy: Option<bool>,
224}
225
226impl<'a> FindingsQuery<'a> {
227 #[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 #[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 #[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 #[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 #[must_use]
274 pub fn with_severity(mut self, severity: Vec<u32>) -> Self {
275 self.severity = Some(severity);
276 self
277 }
278
279 #[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 #[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 #[must_use]
295 pub fn policy_violations_only(mut self) -> Self {
296 self.violates_policy = Some(true);
297 self
298 }
299}
300
301#[derive(Debug, thiserror::Error)]
303#[must_use = "Need to handle all error enum types."]
304pub enum FindingsError {
305 #[error("Application not found: {app_guid}")]
307 ApplicationNotFound { app_guid: String },
308
309 #[error("Sandbox not found: {sandbox_guid} in application {app_guid}")]
311 SandboxNotFound {
312 app_guid: String,
313 sandbox_guid: String,
314 },
315
316 #[error("Invalid pagination parameters: page={page}, size={size}")]
318 InvalidPagination { page: u32, size: u32 },
319
320 #[error("No findings available for the specified context")]
322 NoFindings,
323
324 #[error("Findings API request failed: {source}")]
326 RequestFailed {
327 #[from]
328 source: VeracodeError,
329 },
330}
331
332#[derive(Clone)]
334pub struct FindingsApi {
335 client: VeracodeClient,
336}
337
338impl FindingsApi {
339 #[must_use]
341 pub fn new(client: VeracodeClient) -> Self {
342 Self { client }
343 }
344
345 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 if let Some(context) = &query.context {
362 params.push(("context".to_string(), context.to_string()));
363 debug!("Using sandbox context: {context}");
364 }
365
366 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 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 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, ¶ms_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 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 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; 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 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 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 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 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 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 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![], },
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}