1use log::{debug, error, info, warn};
7use serde::{Deserialize, Serialize};
8use std::borrow::Cow;
9
10use crate::{VeracodeClient, VeracodeError};
11
12const PLUGIN_VERSION: &str = "25.2.0-0";
14
15#[derive(Debug, thiserror::Error)]
17pub enum PipelineError {
18 #[error("Pipeline scan not found")]
19 ScanNotFound,
20 #[error("Permission denied: {0}")]
21 PermissionDenied(String),
22 #[error("Invalid request: {0}")]
23 InvalidRequest(String),
24 #[error("Scan timeout")]
25 ScanTimeout,
26 #[error("Scan findings not ready yet - try again later")]
27 FindingsNotReady,
28 #[error("Application not found: {0}")]
29 ApplicationNotFound(String),
30 #[error(
31 "Multiple applications found with name '{0}'. Please check the application name and ensure it uniquely identifies a single application."
32 )]
33 MultipleApplicationsFound(String),
34 #[error("API error: {0}")]
35 ApiError(#[from] VeracodeError),
36 #[error("HTTP error: {0}")]
37 Http(#[from] reqwest::Error),
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "UPPERCASE")]
43pub enum DevStage {
44 Development,
45 Testing,
46 Release,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "UPPERCASE")]
52pub enum ScanStage {
53 Create,
54 Upload,
55 Start,
56 Details,
57 Findings,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
62#[serde(rename_all = "UPPERCASE")]
63pub enum ScanStatus {
64 Pending,
65 Uploading,
66 Started,
67 Success,
68 Failure,
69 Cancelled,
70 Timeout,
71 #[serde(rename = "USER_TIMEOUT")]
72 UserTimeout,
73}
74
75impl ScanStatus {
76 #[must_use]
78 pub fn is_successful(&self) -> bool {
79 matches!(self, ScanStatus::Success)
80 }
81
82 #[must_use]
84 pub fn is_failed(&self) -> bool {
85 matches!(
86 self,
87 ScanStatus::Failure
88 | ScanStatus::Cancelled
89 | ScanStatus::Timeout
90 | ScanStatus::UserTimeout
91 )
92 }
93
94 #[must_use]
96 pub fn is_in_progress(&self) -> bool {
97 matches!(
98 self,
99 ScanStatus::Pending | ScanStatus::Uploading | ScanStatus::Started
100 )
101 }
102}
103
104impl std::fmt::Display for ScanStatus {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 match self {
107 ScanStatus::Pending => write!(f, "PENDING"),
108 ScanStatus::Uploading => write!(f, "UPLOADING"),
109 ScanStatus::Started => write!(f, "STARTED"),
110 ScanStatus::Success => write!(f, "SUCCESS"),
111 ScanStatus::Failure => write!(f, "FAILURE"),
112 ScanStatus::Cancelled => write!(f, "CANCELLED"),
113 ScanStatus::Timeout => write!(f, "TIMEOUT"),
114 ScanStatus::UserTimeout => write!(f, "USER_TIMEOUT"),
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub enum Severity {
122 #[serde(rename = "0")]
123 Informational,
124 #[serde(rename = "1")]
125 VeryLow,
126 #[serde(rename = "2")]
127 Low,
128 #[serde(rename = "3")]
129 Medium,
130 #[serde(rename = "4")]
131 High,
132 #[serde(rename = "5")]
133 VeryHigh,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct SourceFile {
139 pub file: String,
141 pub function_name: Option<String>,
143 pub function_prototype: String,
145 pub line: u32,
147 pub qualified_function_name: String,
149 pub scope: String,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct FindingFiles {
156 pub source_file: SourceFile,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct StackDumps {
163 pub stack_dump: Option<Vec<serde_json::Value>>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct Finding {
170 pub cwe_id: String,
172 pub display_text: String,
174 pub files: FindingFiles,
176 pub flaw_details_link: Option<String>,
178 pub gob: String,
180 pub issue_id: u32,
182 pub issue_type: String,
184 pub issue_type_id: String,
186 pub severity: u32,
188 pub stack_dumps: Option<StackDumps>,
190 pub title: String,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct FindingsResponse {
197 #[serde(rename = "_links")]
199 pub links: Option<serde_json::Value>,
200 pub scan_id: String,
202 pub scan_status: ScanStatus,
204 pub message: String,
206 pub modules: Vec<String>,
208 pub modules_count: u32,
210 pub findings: Vec<Finding>,
212 pub selected_modules: Vec<String>,
214 pub stack_dump: Option<serde_json::Value>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct LegacyFinding {
222 pub file: String,
224 pub line: u32,
226 pub issue_type: String,
228 pub severity: u32,
230 pub message: String,
232 pub cwe_id: u32,
234 pub details_link: Option<String>,
236 pub issue_id: Option<String>,
238 pub owasp_category: Option<String>,
240 pub sans_category: Option<String>,
242}
243
244impl Finding {
245 #[must_use]
247 pub fn to_legacy(&self) -> LegacyFinding {
248 LegacyFinding {
249 file: self.files.source_file.file.clone(),
250 line: self.files.source_file.line,
251 issue_type: self.issue_type.clone(),
252 severity: self.severity,
253 message: strip_html_tags(&self.display_text).into_owned(),
254 cwe_id: self.cwe_id.parse().unwrap_or(0),
255 details_link: None,
256 issue_id: Some(self.issue_id.to_string()),
257 owasp_category: None,
258 sans_category: None,
259 }
260 }
261}
262
263fn strip_html_tags(html: &str) -> Cow<'_, str> {
265 if !html.contains('<') {
267 return Cow::Borrowed(html);
268 }
269
270 let mut result = String::new();
272 let mut in_tag = false;
273
274 for ch in html.chars() {
275 match ch {
276 '<' => in_tag = true,
277 '>' => in_tag = false,
278 _ if !in_tag => result.push(ch),
279 _ => {}
280 }
281 }
282
283 let cleaned = result.split_whitespace().collect::<Vec<&str>>().join(" ");
285 Cow::Owned(cleaned)
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct CreateScanRequest {
291 #[serde(skip_serializing_if = "never_skip_string")]
293 pub binary_name: String,
294 #[serde(skip_serializing_if = "never_skip_u64")]
296 pub binary_size: u64,
297 #[serde(skip_serializing_if = "never_skip_string")]
299 pub binary_hash: String,
300 pub project_name: String,
302 #[serde(skip_serializing_if = "Option::is_none")]
304 pub project_uri: Option<String>,
305 pub dev_stage: DevStage,
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub app_id: Option<String>,
310 #[serde(skip_serializing_if = "Option::is_none")]
312 pub project_ref: Option<String>,
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub scan_timeout: Option<u32>,
316 #[serde(skip_serializing_if = "Option::is_none")]
318 pub plugin_version: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
321 pub emit_stack_dump: Option<String>,
322 #[serde(skip_serializing_if = "Option::is_none")]
324 pub include_modules: Option<String>,
325}
326
327fn never_skip_string(_: &String) -> bool {
329 false
330}
331
332fn never_skip_u64(_: &u64) -> bool {
334 false
335}
336
337#[derive(Debug, Clone)]
339pub struct ScanCreationResult {
340 pub scan_id: String,
342 pub upload_uri: Option<String>,
344 pub details_uri: Option<String>,
346 pub start_uri: Option<String>,
348 pub cancel_uri: Option<String>,
350 pub expected_segments: Option<u32>,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct ScanConfig {
357 pub timeout: Option<u32>,
359 pub include_low_severity: Option<bool>,
361 pub max_findings: Option<u32>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct Scan {
368 pub scan_id: String,
370 pub scan_status: ScanStatus,
372 pub api_version: f64,
374 pub app_id: Option<String>,
376 pub project_name: String,
378 pub project_uri: Option<String>,
380 pub project_ref: Option<String>,
382 pub commit_hash: Option<String>,
384 pub dev_stage: String,
386 pub binary_name: String,
388 pub binary_size: u64,
390 pub binary_hash: String,
392 pub binary_segments_expected: u32,
394 pub binary_segments_uploaded: u32,
396 pub scan_timeout: Option<u32>,
398 pub scan_duration: Option<f64>,
400 pub results_size: Option<f64>,
402 pub message: Option<String>,
404 pub created: String,
406 pub changed: String,
408 pub modules: Vec<serde_json::Value>,
410 pub selected_modules: Vec<serde_json::Value>,
412 pub display_modules: Vec<serde_json::Value>,
414 pub display_selected_modules: Vec<serde_json::Value>,
416 #[serde(rename = "_links")]
418 pub links: Option<serde_json::Value>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct ScanResults {
424 pub scan: Scan,
426 pub findings: Vec<Finding>,
428 pub summary: FindingsSummary,
430 pub standards: SecurityStandards,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct FindingsSummary {
437 pub very_high: u32,
439 pub high: u32,
441 pub medium: u32,
443 pub low: u32,
445 pub very_low: u32,
447 pub informational: u32,
449 pub total: u32,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct SecurityStandards {
456 pub owasp: Option<StandardCompliance>,
458 pub sans: Option<StandardCompliance>,
460 pub pci: Option<StandardCompliance>,
462 pub cwe: Option<StandardCompliance>,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct StandardCompliance {
469 pub total_rules: u32,
471 pub violations: u32,
473 pub compliance_score: f64,
475 pub violated_rules: Vec<String>,
477}
478
479pub struct PipelineApi {
481 client: VeracodeClient,
482 base_url: String,
484}
485
486impl PipelineApi {
487 #[must_use]
489 pub fn new(client: VeracodeClient) -> Self {
490 let base_url = Self::compute_base_url(&client);
491 Self { client, base_url }
492 }
493
494 fn compute_base_url(client: &VeracodeClient) -> String {
496 if client.config().base_url.contains("api.veracode.com") {
497 "https://api.veracode.com/pipeline_scan/v1".to_string()
498 } else {
499 format!(
501 "{}/pipeline_scan/v1",
502 client.config().base_url.trim_end_matches('/')
503 )
504 }
505 }
506
507 fn get_pipeline_base_url(&self) -> &str {
509 &self.base_url
510 }
511
512 pub async fn lookup_app_id_by_name(&self, app_name: &str) -> Result<String, PipelineError> {
522 let applications = self.client.search_applications_by_name(app_name).await?;
523
524 match applications.len() {
525 0 => Err(PipelineError::ApplicationNotFound(app_name.to_owned())),
526 1 => Ok(applications[0].id.to_string()),
527 _ => {
528 error!(
530 "❌ Found {} applications matching '{}':",
531 applications.len(),
532 app_name
533 );
534 for (i, app) in applications.iter().enumerate() {
535 if let Some(ref profile) = app.profile {
536 error!(" {}. ID: {} - Name: '{}'", i + 1, app.id, profile.name);
537 } else {
538 error!(" {}. ID: {} - GUID: {}", i + 1, app.id, app.guid);
539 }
540 }
541 error!(
542 "💡 Please provide a more specific application name that matches exactly one application."
543 );
544 Err(PipelineError::MultipleApplicationsFound(
545 app_name.to_string(),
546 ))
547 }
548 }
549 }
550
551 pub async fn create_scan_with_app_lookup(
562 &self,
563 request: &mut CreateScanRequest,
564 app_name: Option<&str>,
565 ) -> Result<ScanCreationResult, PipelineError> {
566 if let Some(name) = app_name
568 && request.app_id.is_none()
569 {
570 let app_id = self.lookup_app_id_by_name(name).await?;
571 request.app_id = Some(app_id.clone());
572 info!("✅ Found application '{name}' with ID: {app_id}");
573 }
574
575 self.create_scan(request).await
576 }
577
578 pub async fn create_scan(
588 &self,
589 request: &mut CreateScanRequest,
590 ) -> Result<ScanCreationResult, PipelineError> {
591 if request.plugin_version.is_none() {
593 request.plugin_version = Some(PLUGIN_VERSION.to_string());
594 }
595
596 if request.scan_timeout.is_none() {
598 request.scan_timeout = Some(30);
599 }
600
601 let endpoint = "/pipeline_scan/v1/scans";
603 let _full_url = format!("{}{}", self.client.config().base_url, endpoint);
604
605 let response = self
606 .client
607 .post_with_response("/pipeline_scan/v1/scans", Some(request))
608 .await?;
609
610 let response_text = response.text().await?;
611
612 if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
614 let scan_id = json_value
616 .get("scan_id")
617 .and_then(|id| id.as_str())
618 .ok_or_else(|| {
619 PipelineError::InvalidRequest("Missing scan_id in response".to_string())
620 })?
621 .to_owned();
622
623 let links = json_value.get("_links");
625
626 let upload_uri = links
627 .and_then(|links| links.get("upload"))
628 .and_then(|upload| upload.get("href"))
629 .and_then(|href| href.as_str())
630 .map(str::to_owned);
631
632 let details_uri = links
633 .and_then(|links| links.get("details"))
634 .and_then(|details| details.get("href"))
635 .and_then(|href| href.as_str())
636 .map(str::to_owned);
637
638 let start_uri = links
639 .and_then(|links| links.get("start"))
640 .and_then(|start| start.get("href"))
641 .and_then(|href| href.as_str())
642 .map(str::to_owned);
643
644 let cancel_uri = links
645 .and_then(|links| links.get("cancel"))
646 .and_then(|cancel| cancel.get("href"))
647 .and_then(|href| href.as_str())
648 .map(str::to_owned);
649
650 let expected_segments = json_value
652 .get("binary_segments_expected")
653 .and_then(|segments| segments.as_u64())
654 .map(|s| s as u32);
655
656 debug!("✅ Scan creation response parsed:");
657 debug!(" Scan ID: {scan_id}");
658 if let Some(ref uri) = upload_uri {
659 debug!(" Upload URI: {uri}");
660 }
661 if let Some(ref uri) = details_uri {
662 debug!(" Details URI: {uri}");
663 }
664 if let Some(ref uri) = start_uri {
665 debug!(" Start URI: {uri}");
666 }
667 if let Some(ref uri) = cancel_uri {
668 debug!(" Cancel URI: {uri}");
669 }
670 if let Some(segments) = expected_segments {
671 debug!(" Expected segments: {segments}");
672 }
673
674 return Ok(ScanCreationResult {
675 scan_id,
676 upload_uri,
677 details_uri,
678 start_uri,
679 cancel_uri,
680 expected_segments,
681 });
682 }
683
684 Err(PipelineError::InvalidRequest(
685 "Failed to parse scan creation response".to_string(),
686 ))
687 }
688
689 pub async fn upload_binary_segments(
708 &self,
709 initial_upload_uri: &str,
710 expected_segments: i32,
711 binary_data: &[u8],
712 file_name: &str,
713 ) -> Result<(), PipelineError> {
714 let total_size = binary_data.len();
715 let segment_size = ((total_size as f64) / (expected_segments as f64)).ceil() as usize;
716
717 debug!("📤 Uploading binary in {expected_segments} segments ({total_size} bytes total)");
718 debug!(" Segment size: {segment_size} bytes each");
719
720 let mut current_upload_uri = initial_upload_uri.to_string();
721
722 for segment_num in 0..expected_segments {
723 let start_idx = (segment_num as usize) * segment_size;
724 let end_idx = std::cmp::min(start_idx + segment_size, total_size);
725 let segment_data = &binary_data[start_idx..end_idx];
726
727 debug!(
728 " Uploading segment {}/{} ({} bytes)...",
729 segment_num + 1,
730 expected_segments,
731 segment_data.len()
732 );
733
734 match self
735 .upload_single_segment(¤t_upload_uri, segment_data, file_name)
736 .await
737 {
738 Ok(response_text) => {
739 debug!(" ✅ Segment {} uploaded successfully", segment_num + 1);
740
741 if segment_num < expected_segments - 1 {
743 match self.extract_next_upload_uri(&response_text) {
744 Some(next_uri) => {
745 current_upload_uri = next_uri;
746 debug!(" 📍 Next segment URI: {current_upload_uri}");
747 }
748 None => {
749 warn!(" ⚠️ No next URI found in response, using current");
750 }
751 }
752 }
753 }
754 Err(e) => {
755 error!(" ❌ Failed to upload segment {}: {}", segment_num + 1, e);
756 return Err(e);
757 }
758 }
759 }
760
761 debug!("✅ All {expected_segments} segments uploaded successfully");
762 Ok(())
763 }
764
765 pub async fn upload_binary(
767 &self,
768 scan_id: &str,
769 binary_data: &[u8],
770 ) -> Result<(), PipelineError> {
771 let upload_uri = format!("/pipeline_scan/scans/{scan_id}/segments/1");
773 let expected_segments = 1; let file_name = "binary.tar.gz";
775
776 self.upload_binary_segments(&upload_uri, expected_segments, binary_data, file_name)
777 .await
778 }
779
780 async fn upload_single_segment(
782 &self,
783 upload_uri: &str,
784 segment_data: &[u8],
785 file_name: &str,
786 ) -> Result<String, PipelineError> {
787 let url = if upload_uri.starts_with("http") {
789 upload_uri.to_string()
790 } else {
791 format!("{}{}", self.get_pipeline_base_url(), upload_uri)
792 };
793
794 let mut headers = std::collections::HashMap::new();
796 headers.insert("accept", "application/json");
797 headers.insert("PLUGIN-VERSION", PLUGIN_VERSION); let response = self
801 .client
802 .upload_file_multipart_put(
803 &url,
804 "file",
805 file_name,
806 segment_data.to_vec(),
807 Some(headers),
808 )
809 .await
810 .map_err(PipelineError::ApiError)?;
811
812 if response.status().is_success() {
813 let response_text = response.text().await?;
814 Ok(response_text)
815 } else {
816 let status = response.status();
817 let error_text = response
818 .text()
819 .await
820 .unwrap_or_else(|_| "Unknown error".to_string());
821 Err(PipelineError::InvalidRequest(format!(
822 "Segment upload failed with status {status}: {error_text}"
823 )))
824 }
825 }
826
827 fn extract_next_upload_uri(&self, response_text: &str) -> Option<String> {
829 if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response_text) {
831 if let Some(links) = json_value.get("_links")
833 && let Some(upload) = links.get("upload")
834 && let Some(href) = upload.get("href")
835 {
836 return href.as_str().map(str::to_owned);
837 }
838
839 if let Some(upload_url) = json_value.get("upload_url") {
841 return upload_url.as_str().map(str::to_owned);
842 }
843 }
844
845 None
846 }
847
848 pub async fn start_scan_with_uri(
859 &self,
860 start_uri: &str,
861 config: Option<ScanConfig>,
862 ) -> Result<(), PipelineError> {
863 let mut payload = serde_json::json!({
865 "scan_status": "STARTED"
866 });
867
868 if let Some(config) = config {
870 if let Some(timeout) = config.timeout {
871 payload["timeout"] = serde_json::Value::Number(timeout.into());
872 }
873 if let Some(include_low_severity) = config.include_low_severity {
874 payload["include_low_severity"] = serde_json::Value::Bool(include_low_severity);
875 }
876 if let Some(max_findings) = config.max_findings {
877 payload["max_findings"] = serde_json::Value::Number(max_findings.into());
878 }
879 }
880
881 let url = if start_uri.starts_with("http") {
883 start_uri.to_string()
884 } else {
885 format!("{}{}", self.get_pipeline_base_url(), start_uri)
886 };
887
888 let auth_header = self
890 .client
891 .generate_auth_header("PUT", &url)
892 .map_err(PipelineError::ApiError)?;
893
894 let response = self
895 .client
896 .client()
897 .put(&url)
898 .header("Authorization", auth_header)
899 .header("accept", "application/json")
900 .header("content-type", "application/json")
901 .json(&payload)
902 .send()
903 .await?;
904
905 if response.status().is_success() {
906 Ok(())
907 } else {
908 let error_text = response
909 .text()
910 .await
911 .unwrap_or_else(|_| "Unknown error".to_string());
912 Err(PipelineError::InvalidRequest(format!(
913 "Failed to start scan: {error_text}"
914 )))
915 }
916 }
917
918 pub async fn start_scan(
929 &self,
930 scan_id: &str,
931 config: Option<ScanConfig>,
932 ) -> Result<(), PipelineError> {
933 let endpoint = format!("/scans/{scan_id}");
934 let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
935
936 let mut payload = serde_json::json!({
938 "scan_status": "STARTED"
939 });
940
941 if let Some(config) = config {
943 if let Some(timeout) = config.timeout {
944 payload["timeout"] = serde_json::Value::Number(timeout.into());
945 }
946 if let Some(include_low_severity) = config.include_low_severity {
947 payload["include_low_severity"] = serde_json::Value::Bool(include_low_severity);
948 }
949 if let Some(max_findings) = config.max_findings {
950 payload["max_findings"] = serde_json::Value::Number(max_findings.into());
951 }
952 }
953
954 let auth_header = self
956 .client
957 .generate_auth_header("PUT", &url)
958 .map_err(PipelineError::ApiError)?;
959
960 let response = self
961 .client
962 .client()
963 .put(&url)
964 .header("Authorization", auth_header)
965 .header("accept", "application/json")
966 .header("content-type", "application/json")
967 .json(&payload)
968 .send()
969 .await?;
970
971 if response.status().is_success() {
972 Ok(())
973 } else {
974 let error_text = response
975 .text()
976 .await
977 .unwrap_or_else(|_| "Unknown error".to_string());
978 Err(PipelineError::InvalidRequest(format!(
979 "Failed to start scan: {error_text}"
980 )))
981 }
982 }
983
984 pub async fn get_scan_with_uri(&self, details_uri: &str) -> Result<Scan, PipelineError> {
994 let url = if details_uri.starts_with("http") {
996 details_uri.to_string()
997 } else {
998 format!("{}{}", self.get_pipeline_base_url(), details_uri)
999 };
1000
1001 let auth_header = self
1003 .client
1004 .generate_auth_header("GET", &url)
1005 .map_err(PipelineError::ApiError)?;
1006
1007 let response = self
1008 .client
1009 .client()
1010 .get(&url)
1011 .header("Authorization", auth_header)
1012 .header("accept", "application/json")
1013 .send()
1014 .await?;
1015
1016 let response_text = response.text().await?;
1017
1018 serde_json::from_str::<Scan>(&response_text).map_err(|e| {
1019 PipelineError::InvalidRequest(format!("Failed to parse scan details: {e}"))
1020 })
1021 }
1022
1023 pub async fn get_scan(&self, scan_id: &str) -> Result<Scan, PipelineError> {
1033 let endpoint = format!("/scans/{scan_id}");
1034 let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1035
1036 let auth_header = self
1038 .client
1039 .generate_auth_header("GET", &url)
1040 .map_err(PipelineError::ApiError)?;
1041
1042 let response = self
1043 .client
1044 .client()
1045 .get(&url)
1046 .header("Authorization", auth_header)
1047 .header("accept", "application/json")
1048 .send()
1049 .await?;
1050
1051 let response_text = response.text().await?;
1052
1053 serde_json::from_str::<Scan>(&response_text).map_err(|e| {
1054 PipelineError::InvalidRequest(format!("Failed to parse scan details: {e}"))
1055 })
1056 }
1057
1058 pub async fn get_findings(&self, scan_id: &str) -> Result<Vec<Finding>, PipelineError> {
1073 let endpoint = format!("/scans/{scan_id}/findings");
1074 let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1075
1076 debug!("🔍 Debug - get_findings() calling: {url}");
1077
1078 let auth_header = self
1080 .client
1081 .generate_auth_header("GET", &url)
1082 .map_err(PipelineError::ApiError)?;
1083
1084 let response = self
1085 .client
1086 .client()
1087 .get(&url)
1088 .header("Authorization", auth_header)
1089 .header("accept", "application/json")
1090 .send()
1091 .await?;
1092
1093 let status = response.status();
1094 let response_text = response.text().await?;
1095
1096 debug!("🔍 Debug - Findings API Response:");
1098 debug!(" Status: {status}");
1099 debug!(" Response Length: {} bytes", response_text.len());
1100
1101 match status.as_u16() {
1102 200 => {
1103 match serde_json::from_str::<FindingsResponse>(&response_text) {
1105 Ok(findings_response) => {
1106 debug!("🔍 Debug - Successfully parsed findings response:");
1107 debug!(" Scan Status: {}", findings_response.scan_status);
1108 debug!(" Message: {}", findings_response.message);
1109 debug!(" Modules: {:?}", findings_response.modules);
1110 debug!(" Findings Count: {}", findings_response.findings.len());
1111 Ok(findings_response.findings)
1112 }
1113 Err(e) => {
1114 debug!("❌ Debug - Failed to parse FindingsResponse: {e}");
1115 if let Ok(json_value) =
1117 serde_json::from_str::<serde_json::Value>(&response_text)
1118 && let Some(findings_array) =
1119 json_value.get("findings").and_then(|f| f.as_array())
1120 {
1121 debug!("🔍 Debug - Trying fallback parsing of findings array...");
1122 let findings: Result<Vec<Finding>, _> = findings_array
1123 .iter()
1124 .map(|f| serde_json::from_value(f.clone()))
1125 .collect();
1126 return findings.map_err(|e| {
1127 PipelineError::InvalidRequest(format!(
1128 "Failed to parse findings array: {e}"
1129 ))
1130 });
1131 }
1132 Err(PipelineError::InvalidRequest(format!(
1133 "Failed to parse findings response: {e}"
1134 )))
1135 }
1136 }
1137 }
1138 202 => {
1139 Err(PipelineError::FindingsNotReady)
1141 }
1142 _ => {
1143 Err(PipelineError::InvalidRequest(format!(
1145 "Failed to get findings - HTTP {status}: {response_text}"
1146 )))
1147 }
1148 }
1149 }
1150
1151 pub async fn get_results(&self, scan_id: &str) -> Result<ScanResults, PipelineError> {
1166 debug!("🔍 Debug - get_results() getting scan details for: {scan_id}");
1167 let scan = self.get_scan(scan_id).await?;
1168 debug!("🔍 Debug - get_results() scan status: {}", scan.scan_status);
1169 debug!("🔍 Debug - get_results() calling get_findings() for: {scan_id}");
1170 let findings = self.get_findings(scan_id).await?;
1171
1172 let summary = self.calculate_summary(&findings);
1174
1175 let standards = SecurityStandards {
1177 owasp: None,
1178 sans: None,
1179 pci: None,
1180 cwe: None,
1181 };
1182
1183 Ok(ScanResults {
1184 scan,
1185 findings,
1186 summary,
1187 standards,
1188 })
1189 }
1190
1191 pub async fn cancel_scan(&self, scan_id: &str) -> Result<(), PipelineError> {
1201 let endpoint = format!("/scans/{scan_id}/cancel");
1202
1203 let response = self.client.delete_with_response(&endpoint).await?;
1204
1205 if response.status().is_success() {
1206 Ok(())
1207 } else {
1208 let error_text = response
1209 .text()
1210 .await
1211 .unwrap_or_else(|_| "Unknown error".to_string());
1212 Err(PipelineError::InvalidRequest(format!(
1213 "Failed to cancel scan: {error_text}"
1214 )))
1215 }
1216 }
1217
1218 pub async fn wait_for_completion(
1230 &self,
1231 scan_id: &str,
1232 timeout_minutes: Option<u32>,
1233 poll_interval_seconds: Option<u32>,
1234 ) -> Result<Scan, PipelineError> {
1235 let timeout = timeout_minutes.unwrap_or(60);
1236 let interval = poll_interval_seconds.unwrap_or(10);
1237 let max_polls = (timeout * 60) / interval;
1238
1239 for _ in 0..max_polls {
1240 let scan = self.get_scan(scan_id).await?;
1241
1242 if scan.scan_status.is_successful() || scan.scan_status.is_failed() {
1244 return Ok(scan);
1245 }
1246
1247 tokio::time::sleep(tokio::time::Duration::from_secs(interval as u64)).await;
1249 }
1250
1251 Err(PipelineError::ScanTimeout)
1252 }
1253
1254 fn calculate_summary(&self, findings: &[Finding]) -> FindingsSummary {
1256 let mut summary = FindingsSummary {
1257 very_high: 0,
1258 high: 0,
1259 medium: 0,
1260 low: 0,
1261 very_low: 0,
1262 informational: 0,
1263 total: findings.len() as u32,
1264 };
1265
1266 for finding in findings {
1267 match finding.severity {
1268 5 => summary.very_high += 1,
1269 4 => summary.high += 1,
1270 3 => summary.medium += 1,
1271 2 => summary.low += 1,
1272 1 => summary.very_low += 1,
1273 0 => summary.informational += 1,
1274 _ => {} }
1276 }
1277
1278 summary
1279 }
1280}