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 + 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)]
303pub enum FindingsError {
304 #[error("Application not found: {app_guid}")]
306 ApplicationNotFound { app_guid: String },
307
308 #[error("Sandbox not found: {sandbox_guid} in application {app_guid}")]
310 SandboxNotFound {
311 app_guid: String,
312 sandbox_guid: String,
313 },
314
315 #[error("Invalid pagination parameters: page={page}, size={size}")]
317 InvalidPagination { page: u32, size: u32 },
318
319 #[error("No findings available for the specified context")]
321 NoFindings,
322
323 #[error("Findings API request failed: {source}")]
325 RequestFailed {
326 #[from]
327 source: VeracodeError,
328 },
329}
330
331#[derive(Clone)]
333pub struct FindingsApi {
334 client: VeracodeClient,
335}
336
337impl FindingsApi {
338 #[must_use]
340 pub fn new(client: VeracodeClient) -> Self {
341 Self { client }
342 }
343
344 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 if let Some(context) = &query.context {
356 params.push(("context".to_string(), context.to_string()));
357 debug!("Using sandbox context: {context}");
358 }
359
360 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 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 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, ¶ms_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 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 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; 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 if response.is_last_page() {
495 debug!("Reached last page ({current_page}), stopping");
496 break;
497 }
498
499 current_page += 1;
500
501 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 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 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 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 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![], },
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}