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)]
17#[must_use = "Need to handle all error enum types."]
18pub enum PipelineError {
19 #[error("Pipeline scan not found")]
20 ScanNotFound,
21 #[error("Permission denied: {0}")]
22 PermissionDenied(String),
23 #[error("Invalid request: {0}")]
24 InvalidRequest(String),
25 #[error("Scan timeout")]
26 ScanTimeout,
27 #[error("Scan findings not ready yet - try again later")]
28 FindingsNotReady,
29 #[error("Application not found: {0}")]
30 ApplicationNotFound(String),
31 #[error(
32 "Multiple applications found with name '{0}'. Please check the application name and ensure it uniquely identifies a single application."
33 )]
34 MultipleApplicationsFound(String),
35 #[error("API error: {0}")]
36 ApiError(#[from] VeracodeError),
37 #[error("HTTP error: {0}")]
38 Http(#[from] reqwest::Error),
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(rename_all = "UPPERCASE")]
44pub enum DevStage {
45 Development,
46 Testing,
47 Release,
48}
49
50impl std::fmt::Display for DevStage {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 DevStage::Development => write!(f, "DEVELOPMENT"),
54 DevStage::Testing => write!(f, "TESTING"),
55 DevStage::Release => write!(f, "RELEASE"),
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(rename_all = "UPPERCASE")]
63pub enum ScanStage {
64 Create,
65 Upload,
66 Start,
67 Details,
68 Findings,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
73#[serde(rename_all = "UPPERCASE")]
74pub enum ScanStatus {
75 Pending,
76 Uploading,
77 Started,
78 Success,
79 Failure,
80 Cancelled,
81 Timeout,
82 #[serde(rename = "USER_TIMEOUT")]
83 UserTimeout,
84}
85
86impl ScanStatus {
87 #[must_use]
89 pub fn is_successful(&self) -> bool {
90 matches!(self, ScanStatus::Success)
91 }
92
93 #[must_use]
95 pub fn is_failed(&self) -> bool {
96 matches!(
97 self,
98 ScanStatus::Failure
99 | ScanStatus::Cancelled
100 | ScanStatus::Timeout
101 | ScanStatus::UserTimeout
102 )
103 }
104
105 #[must_use]
107 pub fn is_in_progress(&self) -> bool {
108 matches!(
109 self,
110 ScanStatus::Pending | ScanStatus::Uploading | ScanStatus::Started
111 )
112 }
113}
114
115impl std::fmt::Display for ScanStatus {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 match self {
118 ScanStatus::Pending => write!(f, "PENDING"),
119 ScanStatus::Uploading => write!(f, "UPLOADING"),
120 ScanStatus::Started => write!(f, "STARTED"),
121 ScanStatus::Success => write!(f, "SUCCESS"),
122 ScanStatus::Failure => write!(f, "FAILURE"),
123 ScanStatus::Cancelled => write!(f, "CANCELLED"),
124 ScanStatus::Timeout => write!(f, "TIMEOUT"),
125 ScanStatus::UserTimeout => write!(f, "USER_TIMEOUT"),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub enum Severity {
133 #[serde(rename = "0")]
134 Informational,
135 #[serde(rename = "1")]
136 VeryLow,
137 #[serde(rename = "2")]
138 Low,
139 #[serde(rename = "3")]
140 Medium,
141 #[serde(rename = "4")]
142 High,
143 #[serde(rename = "5")]
144 VeryHigh,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct SourceFile {
150 pub file: String,
152 pub function_name: Option<String>,
154 pub function_prototype: String,
156 pub line: u32,
158 pub qualified_function_name: String,
160 pub scope: String,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct FindingFiles {
167 pub source_file: SourceFile,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct StackDumps {
174 pub stack_dump: Option<Vec<serde_json::Value>>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct Finding {
186 pub cwe_id: String,
188 pub display_text: String,
190 pub files: FindingFiles,
192 pub flaw_details_link: Option<String>,
194 pub gob: String,
196 pub issue_id: u32,
198 pub issue_type: String,
200 pub issue_type_id: String,
202 pub severity: u32,
204 pub stack_dumps: Option<StackDumps>,
206 pub title: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct FindingsResponse {
213 #[serde(rename = "_links")]
215 pub links: Option<serde_json::Value>,
216 pub scan_id: String,
218 pub scan_status: ScanStatus,
220 pub message: String,
222 pub modules: Vec<String>,
224 pub modules_count: u32,
226 pub findings: Vec<Finding>,
228 pub selected_modules: Vec<String>,
230 pub stack_dump: Option<serde_json::Value>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct LegacyFinding {
238 pub file: String,
240 pub line: u32,
242 pub issue_type: String,
244 pub severity: u32,
246 pub message: String,
248 pub cwe_id: u32,
250 pub details_link: Option<String>,
252 pub issue_id: Option<String>,
254 pub owasp_category: Option<String>,
256 pub sans_category: Option<String>,
258}
259
260impl Finding {
261 #[must_use]
263 pub fn to_legacy(&self) -> LegacyFinding {
264 LegacyFinding {
265 file: self.files.source_file.file.clone(),
266 line: self.files.source_file.line,
267 issue_type: self.issue_type.clone(),
268 severity: self.severity,
269 message: strip_html_tags(&self.display_text).into_owned(),
270 cwe_id: self.cwe_id.parse().unwrap_or(0),
271 details_link: None,
272 issue_id: Some(self.issue_id.to_string()),
273 owasp_category: None,
274 sans_category: None,
275 }
276 }
277}
278
279fn strip_html_tags(html: &str) -> Cow<'_, str> {
285 if !html.contains('<') {
287 return Cow::Borrowed(html);
288 }
289
290 let mut result = String::new();
292 let mut in_tag = false;
293 let mut in_script_or_style = false;
294 let mut tag_name = String::new();
295 let mut collecting_tag_name = false;
296 let mut just_closed_tag = false;
297
298 for ch in html.chars() {
299 match ch {
300 '<' => {
301 if !result.is_empty() && !result.ends_with(char::is_whitespace) {
303 result.push(' ');
304 }
305 in_tag = true;
306 collecting_tag_name = true;
307 just_closed_tag = false;
308 tag_name.clear();
309 }
310 '>' => {
311 in_tag = false;
312
313 let tag_lower = tag_name.trim().to_lowercase();
315 if tag_lower.starts_with("script") || tag_lower.starts_with("style") {
316 in_script_or_style = true;
317 } else if tag_lower.starts_with("/script") || tag_lower.starts_with("/style") {
318 in_script_or_style = false;
319 }
320 collecting_tag_name = false;
321 just_closed_tag = true;
322 tag_name.clear();
323 }
324 ' ' if in_tag && collecting_tag_name => {
325 collecting_tag_name = false;
327 }
328 _ if (ch == '/' || ch.is_alphanumeric()) && collecting_tag_name => {
329 tag_name.push(ch);
331 }
332 _ if in_tag || in_script_or_style => {
333 }
335 _ => {
336 if just_closed_tag && !result.is_empty() && !result.ends_with(char::is_whitespace) {
339 result.push(' ');
340 }
341 just_closed_tag = false;
342 result.push(ch);
343 }
344 }
345 }
346
347 let cleaned = result.split_whitespace().collect::<Vec<&str>>().join(" ");
349 Cow::Owned(cleaned)
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct CreateScanRequest {
355 #[serde(skip_serializing_if = "never_skip_string")]
357 pub binary_name: String,
358 #[serde(skip_serializing_if = "never_skip_u64")]
360 pub binary_size: u64,
361 #[serde(skip_serializing_if = "never_skip_string")]
363 pub binary_hash: String,
364 pub project_name: String,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 pub project_uri: Option<String>,
369 pub dev_stage: DevStage,
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub app_id: Option<String>,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub project_ref: Option<String>,
377 #[serde(skip_serializing_if = "Option::is_none")]
379 pub scan_timeout: Option<u32>,
380 #[serde(skip_serializing_if = "Option::is_none")]
382 pub plugin_version: Option<String>,
383 #[serde(skip_serializing_if = "Option::is_none")]
385 pub emit_stack_dump: Option<String>,
386 #[serde(skip_serializing_if = "Option::is_none")]
388 pub include_modules: Option<String>,
389}
390
391fn never_skip_string(_: &String) -> bool {
393 false
394}
395
396fn never_skip_u64(_: &u64) -> bool {
398 false
399}
400
401#[derive(Debug, Clone)]
403pub struct ScanCreationResult {
404 pub scan_id: String,
406 pub upload_uri: Option<String>,
408 pub details_uri: Option<String>,
410 pub start_uri: Option<String>,
412 pub cancel_uri: Option<String>,
414 pub expected_segments: Option<u32>,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct ScanConfig {
421 pub timeout: Option<u32>,
423 pub include_low_severity: Option<bool>,
425 pub max_findings: Option<u32>,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct Scan {
432 pub scan_id: String,
434 pub scan_status: ScanStatus,
441 pub api_version: f64,
443 pub app_id: Option<String>,
445 pub project_name: String,
447 pub project_uri: Option<String>,
449 pub project_ref: Option<String>,
451 pub commit_hash: Option<String>,
453 pub dev_stage: String,
455 pub binary_name: String,
457 pub binary_size: u64,
459 pub binary_hash: String,
461 pub binary_segments_expected: u32,
463 pub binary_segments_uploaded: u32,
465 pub scan_timeout: Option<u32>,
467 pub scan_duration: Option<f64>,
469 pub results_size: Option<f64>,
471 pub message: Option<String>,
473 pub created: String,
475 pub changed: String,
477 pub modules: Vec<serde_json::Value>,
479 pub selected_modules: Vec<serde_json::Value>,
481 pub display_modules: Vec<serde_json::Value>,
483 pub display_selected_modules: Vec<serde_json::Value>,
485 #[serde(rename = "_links")]
487 pub links: Option<serde_json::Value>,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct ScanResults {
493 pub scan: Scan,
495 pub findings: Vec<Finding>,
497 pub summary: FindingsSummary,
499 pub standards: SecurityStandards,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct FindingsSummary {
506 pub very_high: u32,
508 pub high: u32,
510 pub medium: u32,
512 pub low: u32,
514 pub very_low: u32,
516 pub informational: u32,
518 pub total: u32,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct SecurityStandards {
525 pub owasp: Option<StandardCompliance>,
527 pub sans: Option<StandardCompliance>,
529 pub pci: Option<StandardCompliance>,
531 pub cwe: Option<StandardCompliance>,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct StandardCompliance {
538 pub total_rules: u32,
540 pub violations: u32,
542 pub compliance_score: f64,
544 pub violated_rules: Vec<String>,
546}
547
548pub struct PipelineApi {
550 client: VeracodeClient,
551 base_url: String,
553}
554
555impl PipelineApi {
556 #[must_use]
558 pub fn new(client: VeracodeClient) -> Self {
559 let base_url = Self::compute_base_url(&client);
560 Self { client, base_url }
561 }
562
563 fn compute_base_url(client: &VeracodeClient) -> String {
565 if client.config().base_url.contains("api.veracode.com") {
566 "https://api.veracode.com/pipeline_scan/v1".to_string()
567 } else {
568 format!(
570 "{}/pipeline_scan/v1",
571 client.config().base_url.trim_end_matches('/')
572 )
573 }
574 }
575
576 fn get_pipeline_base_url(&self) -> &str {
578 &self.base_url
579 }
580
581 pub async fn lookup_app_id_by_name(&self, app_name: &str) -> Result<String, PipelineError> {
596 let applications = self.client.search_applications_by_name(app_name).await?;
597
598 match applications.len() {
599 0 => Err(PipelineError::ApplicationNotFound(app_name.to_owned())),
600 1 => Ok(applications
601 .first()
602 .ok_or_else(|| PipelineError::ApplicationNotFound(app_name.to_owned()))?
603 .id
604 .to_string()),
605 _ => {
606 error!(
608 "❌ Found {} applications matching '{}':",
609 applications.len(),
610 app_name
611 );
612 for (i, app) in applications.iter().enumerate() {
613 if let Some(ref profile) = app.profile {
614 error!(
615 " {}. ID: {} - Name: '{}'",
616 i.saturating_add(1),
617 app.id,
618 profile.name
619 );
620 } else {
621 error!(
622 " {}. ID: {} - GUID: {}",
623 i.saturating_add(1),
624 app.id,
625 app.guid
626 );
627 }
628 }
629 error!(
630 "💡 Please provide a more specific application name that matches exactly one application."
631 );
632 Err(PipelineError::MultipleApplicationsFound(
633 app_name.to_string(),
634 ))
635 }
636 }
637 }
638
639 pub async fn create_scan_with_app_lookup(
665 &self,
666 request: &mut CreateScanRequest,
667 app_name: Option<&str>,
668 ) -> Result<ScanCreationResult, PipelineError> {
669 if let Some(name) = app_name
671 && request.app_id.is_none()
672 {
673 let app_id = self.lookup_app_id_by_name(name).await?;
674 request.app_id = Some(app_id.clone());
675 info!("✅ Found application '{name}' with ID: {app_id}");
676 }
677
678 self.create_scan(request).await
679 }
680
681 pub async fn create_scan(
696 &self,
697 request: &mut CreateScanRequest,
698 ) -> Result<ScanCreationResult, PipelineError> {
699 if request.plugin_version.is_none() {
701 request.plugin_version = Some(PLUGIN_VERSION.to_string());
702 }
703
704 if request.scan_timeout.is_none() {
706 request.scan_timeout = Some(30);
707 }
708
709 let endpoint = "/pipeline_scan/v1/scans";
711 let _full_url = format!("{}{}", self.client.config().base_url, endpoint);
712
713 let response = self
714 .client
715 .post_with_response("/pipeline_scan/v1/scans", Some(request))
716 .await?;
717
718 let response_text = response.text().await?;
719
720 if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
722 let scan_id = json_value
724 .get("scan_id")
725 .and_then(|id| id.as_str())
726 .ok_or_else(|| {
727 PipelineError::InvalidRequest("Missing scan_id in response".to_string())
728 })?
729 .to_owned();
730
731 let links = json_value.get("_links");
733
734 let upload_uri = links
735 .and_then(|links| links.get("upload"))
736 .and_then(|upload| upload.get("href"))
737 .and_then(|href| href.as_str())
738 .map(str::to_owned);
739
740 let details_uri = links
741 .and_then(|links| links.get("details"))
742 .and_then(|details| details.get("href"))
743 .and_then(|href| href.as_str())
744 .map(str::to_owned);
745
746 let start_uri = links
747 .and_then(|links| links.get("start"))
748 .and_then(|start| start.get("href"))
749 .and_then(|href| href.as_str())
750 .map(str::to_owned);
751
752 let cancel_uri = links
753 .and_then(|links| links.get("cancel"))
754 .and_then(|cancel| cancel.get("href"))
755 .and_then(|href| href.as_str())
756 .map(str::to_owned);
757
758 #[allow(clippy::cast_possible_truncation)]
760 let expected_segments = json_value
761 .get("binary_segments_expected")
762 .and_then(|segments| segments.as_u64())
763 .map(|s| s as u32);
764
765 debug!("✅ Scan creation response parsed:");
766 debug!(" Scan ID: {scan_id}");
767 if let Some(ref uri) = upload_uri {
768 debug!(" Upload URI: {uri}");
769 }
770 if let Some(ref uri) = details_uri {
771 debug!(" Details URI: {uri}");
772 }
773 if let Some(ref uri) = start_uri {
774 debug!(" Start URI: {uri}");
775 }
776 if let Some(ref uri) = cancel_uri {
777 debug!(" Cancel URI: {uri}");
778 }
779 if let Some(segments) = expected_segments {
780 debug!(" Expected segments: {segments}");
781 }
782
783 return Ok(ScanCreationResult {
784 scan_id,
785 upload_uri,
786 details_uri,
787 start_uri,
788 cancel_uri,
789 expected_segments,
790 });
791 }
792
793 Err(PipelineError::InvalidRequest(
794 "Failed to parse scan creation response".to_string(),
795 ))
796 }
797
798 pub async fn upload_binary_segments(
827 &self,
828 initial_upload_uri: &str,
829 expected_segments: i32,
830 binary_data: &[u8],
831 file_name: &str,
832 ) -> Result<(), PipelineError> {
833 let total_size = binary_data.len();
834 #[allow(
835 clippy::cast_possible_truncation,
836 clippy::cast_sign_loss,
837 clippy::cast_precision_loss
838 )]
839 let segment_size = ((total_size as f64) / (expected_segments as f64)).ceil() as usize;
840
841 debug!("📤 Uploading binary in {expected_segments} segments ({total_size} bytes total)");
842 debug!(" Segment size: {segment_size} bytes each");
843
844 let mut current_upload_uri = initial_upload_uri.to_string();
845
846 for segment_num in 0..expected_segments {
847 #[allow(clippy::cast_sign_loss)]
848 let start_idx = (segment_num as usize).saturating_mul(segment_size);
849 let end_idx = std::cmp::min(start_idx.saturating_add(segment_size), total_size);
850 let segment_data = binary_data.get(start_idx..end_idx).ok_or_else(|| {
851 PipelineError::InvalidRequest(format!(
852 "Invalid segment range: {}..{}",
853 start_idx, end_idx
854 ))
855 })?;
856
857 debug!(
858 " Uploading segment {}/{} ({} bytes)...",
859 segment_num.saturating_add(1),
860 expected_segments,
861 segment_data.len()
862 );
863
864 match self
865 .upload_single_segment(¤t_upload_uri, segment_data, file_name)
866 .await
867 {
868 Ok(response_text) => {
869 debug!(
870 " ✅ Segment {} uploaded successfully",
871 segment_num.saturating_add(1)
872 );
873
874 if segment_num < expected_segments.saturating_sub(1) {
876 match self.extract_next_upload_uri(&response_text) {
877 Some(next_uri) => {
878 current_upload_uri = next_uri;
879 debug!(" 📍 Next segment URI: {current_upload_uri}");
880 }
881 None => {
882 warn!(" ⚠️ No next URI found in response, using current");
883 }
884 }
885 }
886 }
887 Err(e) => {
888 error!(
889 " ❌ Failed to upload segment {}: {}",
890 segment_num.saturating_add(1),
891 e
892 );
893 return Err(e);
894 }
895 }
896 }
897
898 debug!("✅ All {expected_segments} segments uploaded successfully");
899 Ok(())
900 }
901
902 pub async fn upload_binary(
909 &self,
910 scan_id: &str,
911 binary_data: &[u8],
912 ) -> Result<(), PipelineError> {
913 let upload_uri = format!("/pipeline_scan/scans/{scan_id}/segments/1");
915 let expected_segments = 1; let file_name = "binary.tar.gz";
917
918 self.upload_binary_segments(&upload_uri, expected_segments, binary_data, file_name)
919 .await
920 }
921
922 async fn upload_single_segment(
924 &self,
925 upload_uri: &str,
926 segment_data: &[u8],
927 file_name: &str,
928 ) -> Result<String, PipelineError> {
929 let url = if upload_uri.starts_with("http") {
931 upload_uri.to_string()
932 } else {
933 format!("{}{}", self.get_pipeline_base_url(), upload_uri)
934 };
935
936 let mut headers = std::collections::HashMap::new();
938 headers.insert("accept", "application/json");
939 headers.insert("PLUGIN-VERSION", PLUGIN_VERSION); let response = self
943 .client
944 .upload_file_multipart_put(
945 &url,
946 "file",
947 file_name,
948 segment_data.to_vec(),
949 Some(headers),
950 )
951 .await
952 .map_err(PipelineError::ApiError)?;
953
954 if response.status().is_success() {
955 let response_text = response.text().await?;
956 Ok(response_text)
957 } else {
958 let status = response.status();
959 let error_text = response
960 .text()
961 .await
962 .unwrap_or_else(|_| "Unknown error".to_string());
963 Err(PipelineError::InvalidRequest(format!(
964 "Segment upload failed with status {status}: {error_text}"
965 )))
966 }
967 }
968
969 fn extract_next_upload_uri(&self, response_text: &str) -> Option<String> {
971 if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response_text) {
973 if let Some(links) = json_value.get("_links")
975 && let Some(upload) = links.get("upload")
976 && let Some(href) = upload.get("href")
977 {
978 return href.as_str().map(str::to_owned);
979 }
980
981 if let Some(upload_url) = json_value.get("upload_url") {
983 return upload_url.as_str().map(str::to_owned);
984 }
985 }
986
987 None
988 }
989
990 pub async fn start_scan_with_uri(
1006 &self,
1007 start_uri: &str,
1008 config: Option<ScanConfig>,
1009 ) -> Result<(), PipelineError> {
1010 let mut payload = serde_json::json!({
1012 "scan_status": "STARTED"
1013 });
1014
1015 if let Some(config) = config {
1017 if let Some(timeout) = config.timeout
1018 && let Some(obj) = payload.as_object_mut()
1019 {
1020 obj.insert(
1021 "timeout".to_string(),
1022 serde_json::Value::Number(timeout.into()),
1023 );
1024 }
1025 if let Some(include_low_severity) = config.include_low_severity
1026 && let Some(obj) = payload.as_object_mut()
1027 {
1028 obj.insert(
1029 "include_low_severity".to_string(),
1030 serde_json::Value::Bool(include_low_severity),
1031 );
1032 }
1033 if let Some(max_findings) = config.max_findings
1034 && let Some(obj) = payload.as_object_mut()
1035 {
1036 obj.insert(
1037 "max_findings".to_string(),
1038 serde_json::Value::Number(max_findings.into()),
1039 );
1040 }
1041 }
1042
1043 let url = if start_uri.starts_with("http") {
1045 start_uri.to_string()
1046 } else {
1047 format!("{}{}", self.get_pipeline_base_url(), start_uri)
1048 };
1049
1050 let auth_header = self
1052 .client
1053 .generate_auth_header("PUT", &url)
1054 .map_err(PipelineError::ApiError)?;
1055
1056 let response = self
1057 .client
1058 .client()
1059 .put(&url)
1060 .header("Authorization", auth_header)
1061 .header("accept", "application/json")
1062 .header("content-type", "application/json")
1063 .json(&payload)
1064 .send()
1065 .await?;
1066
1067 if response.status().is_success() {
1068 Ok(())
1069 } else {
1070 let error_text = response
1071 .text()
1072 .await
1073 .unwrap_or_else(|_| "Unknown error".to_string());
1074 Err(PipelineError::InvalidRequest(format!(
1075 "Failed to start scan: {error_text}"
1076 )))
1077 }
1078 }
1079
1080 pub async fn start_scan(
1096 &self,
1097 scan_id: &str,
1098 config: Option<ScanConfig>,
1099 ) -> Result<(), PipelineError> {
1100 let endpoint = format!("/scans/{scan_id}");
1101 let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1102
1103 let mut payload = serde_json::json!({
1105 "scan_status": "STARTED"
1106 });
1107
1108 if let Some(config) = config {
1110 if let Some(timeout) = config.timeout
1111 && let Some(obj) = payload.as_object_mut()
1112 {
1113 obj.insert(
1114 "timeout".to_string(),
1115 serde_json::Value::Number(timeout.into()),
1116 );
1117 }
1118 if let Some(include_low_severity) = config.include_low_severity
1119 && let Some(obj) = payload.as_object_mut()
1120 {
1121 obj.insert(
1122 "include_low_severity".to_string(),
1123 serde_json::Value::Bool(include_low_severity),
1124 );
1125 }
1126 if let Some(max_findings) = config.max_findings
1127 && let Some(obj) = payload.as_object_mut()
1128 {
1129 obj.insert(
1130 "max_findings".to_string(),
1131 serde_json::Value::Number(max_findings.into()),
1132 );
1133 }
1134 }
1135
1136 let auth_header = self
1138 .client
1139 .generate_auth_header("PUT", &url)
1140 .map_err(PipelineError::ApiError)?;
1141
1142 let response = self
1143 .client
1144 .client()
1145 .put(&url)
1146 .header("Authorization", auth_header)
1147 .header("accept", "application/json")
1148 .header("content-type", "application/json")
1149 .json(&payload)
1150 .send()
1151 .await?;
1152
1153 if response.status().is_success() {
1154 Ok(())
1155 } else {
1156 let error_text = response
1157 .text()
1158 .await
1159 .unwrap_or_else(|_| "Unknown error".to_string());
1160 Err(PipelineError::InvalidRequest(format!(
1161 "Failed to start scan: {error_text}"
1162 )))
1163 }
1164 }
1165
1166 pub async fn get_scan_with_uri(&self, details_uri: &str) -> Result<Scan, PipelineError> {
1181 let url = if details_uri.starts_with("http") {
1183 details_uri.to_string()
1184 } else {
1185 format!("{}{}", self.get_pipeline_base_url(), details_uri)
1186 };
1187
1188 let auth_header = self
1190 .client
1191 .generate_auth_header("GET", &url)
1192 .map_err(PipelineError::ApiError)?;
1193
1194 let response = self
1195 .client
1196 .client()
1197 .get(&url)
1198 .header("Authorization", auth_header)
1199 .header("accept", "application/json")
1200 .send()
1201 .await?;
1202
1203 let response_text = response.text().await?;
1204
1205 serde_json::from_str::<Scan>(&response_text).map_err(|e| {
1206 PipelineError::InvalidRequest(format!("Failed to parse scan details: {e}"))
1207 })
1208 }
1209
1210 pub async fn get_scan(&self, scan_id: &str) -> Result<Scan, PipelineError> {
1225 let endpoint = format!("/scans/{scan_id}");
1226 let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1227
1228 let auth_header = self
1230 .client
1231 .generate_auth_header("GET", &url)
1232 .map_err(PipelineError::ApiError)?;
1233
1234 let response = self
1235 .client
1236 .client()
1237 .get(&url)
1238 .header("Authorization", auth_header)
1239 .header("accept", "application/json")
1240 .send()
1241 .await?;
1242
1243 let response_text = response.text().await?;
1244
1245 serde_json::from_str::<Scan>(&response_text).map_err(|e| {
1246 PipelineError::InvalidRequest(format!("Failed to parse scan details: {e}"))
1247 })
1248 }
1249
1250 pub async fn get_findings(&self, scan_id: &str) -> Result<Vec<Finding>, PipelineError> {
1275 let endpoint = format!("/scans/{scan_id}/findings");
1276 let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1277
1278 debug!("🔍 Debug - get_findings() calling: {url}");
1279
1280 let auth_header = self
1282 .client
1283 .generate_auth_header("GET", &url)
1284 .map_err(PipelineError::ApiError)?;
1285
1286 let response = self
1287 .client
1288 .client()
1289 .get(&url)
1290 .header("Authorization", auth_header)
1291 .header("accept", "application/json")
1292 .send()
1293 .await?;
1294
1295 let status = response.status();
1296 let response_text = response.text().await?;
1297
1298 debug!("🔍 Debug - Findings API Response:");
1300 debug!(" Status: {status}");
1301 debug!(" Response Length: {} bytes", response_text.len());
1302
1303 match status.as_u16() {
1304 200 => {
1305 match serde_json::from_str::<FindingsResponse>(&response_text) {
1307 Ok(findings_response) => {
1308 debug!("🔍 Debug - Successfully parsed findings response:");
1309 debug!(" Scan Status: {}", findings_response.scan_status);
1310 debug!(" Message: {}", findings_response.message);
1311 debug!(" Modules: [{}]", findings_response.modules.join(", "));
1312 debug!(" Findings Count: {}", findings_response.findings.len());
1313 Ok(findings_response.findings)
1314 }
1315 Err(e) => {
1316 debug!("❌ Debug - Failed to parse FindingsResponse: {e}");
1317 if let Ok(json_value) =
1319 serde_json::from_str::<serde_json::Value>(&response_text)
1320 && let Some(findings_array) =
1321 json_value.get("findings").and_then(|f| f.as_array())
1322 {
1323 debug!("🔍 Debug - Trying fallback parsing of findings array...");
1324 let findings: Result<Vec<Finding>, _> = findings_array
1325 .iter()
1326 .map(|f| serde_json::from_value(f.clone()))
1327 .collect();
1328 return findings.map_err(|e| {
1329 PipelineError::InvalidRequest(format!(
1330 "Failed to parse findings array: {e}"
1331 ))
1332 });
1333 }
1334 Err(PipelineError::InvalidRequest(format!(
1335 "Failed to parse findings response: {e}"
1336 )))
1337 }
1338 }
1339 }
1340 202 => {
1341 Err(PipelineError::FindingsNotReady)
1343 }
1344 _ => {
1345 Err(PipelineError::InvalidRequest(format!(
1347 "Failed to get findings - HTTP {status}: {response_text}"
1348 )))
1349 }
1350 }
1351 }
1352
1353 pub async fn get_results(&self, scan_id: &str) -> Result<ScanResults, PipelineError> {
1373 debug!("🔍 Debug - get_results() getting scan details for: {scan_id}");
1374 let scan = self.get_scan(scan_id).await?;
1375 debug!("🔍 Debug - get_results() scan status: {}", scan.scan_status);
1376 debug!("🔍 Debug - get_results() calling get_findings() for: {scan_id}");
1377 let findings = self.get_findings(scan_id).await?;
1378
1379 let summary = self.calculate_summary(&findings);
1381
1382 let standards = SecurityStandards {
1384 owasp: None,
1385 sans: None,
1386 pci: None,
1387 cwe: None,
1388 };
1389
1390 Ok(ScanResults {
1391 scan,
1392 findings,
1393 summary,
1394 standards,
1395 })
1396 }
1397
1398 pub async fn cancel_scan(&self, scan_id: &str) -> Result<(), PipelineError> {
1413 let endpoint = format!("/scans/{scan_id}/cancel");
1414
1415 let response = self.client.delete_with_response(&endpoint).await?;
1416
1417 if response.status().is_success() {
1418 Ok(())
1419 } else {
1420 let error_text = response
1421 .text()
1422 .await
1423 .unwrap_or_else(|_| "Unknown error".to_string());
1424 Err(PipelineError::InvalidRequest(format!(
1425 "Failed to cancel scan: {error_text}"
1426 )))
1427 }
1428 }
1429
1430 pub async fn wait_for_completion(
1447 &self,
1448 scan_id: &str,
1449 timeout_minutes: Option<u32>,
1450 poll_interval_seconds: Option<u32>,
1451 ) -> Result<Scan, PipelineError> {
1452 let timeout = timeout_minutes.unwrap_or(60);
1453 let interval = poll_interval_seconds.unwrap_or(10);
1454 let max_polls = timeout
1455 .saturating_mul(60)
1456 .checked_div(interval)
1457 .unwrap_or(u32::MAX);
1458
1459 for _ in 0..max_polls {
1460 let scan = self.get_scan(scan_id).await?;
1461
1462 if scan.scan_status.is_successful() || scan.scan_status.is_failed() {
1464 return Ok(scan);
1465 }
1466
1467 tokio::time::sleep(tokio::time::Duration::from_secs(interval as u64)).await;
1469 }
1470
1471 Err(PipelineError::ScanTimeout)
1472 }
1473
1474 fn calculate_summary(&self, findings: &[Finding]) -> FindingsSummary {
1476 #[allow(clippy::cast_possible_truncation)]
1477 let mut summary = FindingsSummary {
1478 very_high: 0,
1479 high: 0,
1480 medium: 0,
1481 low: 0,
1482 very_low: 0,
1483 informational: 0,
1484 total: findings.len() as u32,
1485 };
1486
1487 for finding in findings {
1488 match finding.severity {
1489 5 => summary.very_high = summary.very_high.saturating_add(1),
1490 4 => summary.high = summary.high.saturating_add(1),
1491 3 => summary.medium = summary.medium.saturating_add(1),
1492 2 => summary.low = summary.low.saturating_add(1),
1493 1 => summary.very_low = summary.very_low.saturating_add(1),
1494 0 => summary.informational = summary.informational.saturating_add(1),
1495 _ => {} }
1497 }
1498
1499 summary
1500 }
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505 use super::*;
1506
1507 #[test]
1508 fn test_strip_html_tags_no_tags() {
1509 let input = "This is plain text";
1510 let result = strip_html_tags(input);
1511 assert_eq!(result, "This is plain text");
1512 }
1513
1514 #[test]
1515 fn test_strip_html_tags_simple() {
1516 let input = "This is <b>bold</b> text";
1517 let result = strip_html_tags(input);
1518 assert_eq!(result, "This is bold text");
1519 }
1520
1521 #[test]
1522 fn test_strip_html_tags_removes_script_content() {
1523 let input = "<script>alert('XSS')</script>This is safe";
1525 let result = strip_html_tags(input);
1526 assert_eq!(result, "This is safe");
1527 }
1528
1529 #[test]
1530 fn test_strip_html_tags_removes_script_with_attributes() {
1531 let input = "<script type='text/javascript'>alert('XSS')</script>Safe text";
1532 let result = strip_html_tags(input);
1533 assert_eq!(result, "Safe text");
1534 }
1535
1536 #[test]
1537 fn test_strip_html_tags_removes_style_content() {
1538 let input = "<style>body { color: red; }</style>Visible text";
1539 let result = strip_html_tags(input);
1540 assert_eq!(result, "Visible text");
1541 }
1542
1543 #[test]
1544 fn test_strip_html_tags_multiple_scripts() {
1545 let input = "<script>evil1()</script>Good<script>evil2()</script>Text";
1546 let result = strip_html_tags(input);
1547 assert_eq!(result, "Good Text");
1548 }
1549
1550 #[test]
1551 fn test_strip_html_tags_nested_tags() {
1552 let input = "<div><p>Paragraph <b>bold</b> text</p></div>";
1553 let result = strip_html_tags(input);
1554 assert_eq!(result, "Paragraph bold text");
1555 }
1556
1557 #[test]
1558 fn test_strip_html_tags_mixed_content() {
1559 let input = "Before<script>bad()</script>Middle<b>bold</b>After";
1560 let result = strip_html_tags(input);
1561 assert_eq!(result, "Before Middle bold After");
1562 }
1563
1564 #[test]
1565 fn test_strip_html_tags_case_insensitive_script() {
1566 let input = "<SCRIPT>evil()</SCRIPT>Safe";
1567 let result = strip_html_tags(input);
1568 assert_eq!(result, "Safe");
1569 }
1570
1571 #[test]
1572 fn test_strip_html_tags_case_insensitive_style() {
1573 let input = "<STYLE>css</STYLE>Text";
1574 let result = strip_html_tags(input);
1575 assert_eq!(result, "Text");
1576 }
1577
1578 #[test]
1579 fn test_strip_html_tags_whitespace_cleanup() {
1580 let input = "Text <b>with</b> extra <i>spaces</i>";
1582 let result = strip_html_tags(input);
1583 assert_eq!(result, "Text with extra spaces");
1584 }
1585
1586 #[test]
1587 fn test_strip_html_tags_no_html_preserves_whitespace() {
1588 let input = "Text with extra spaces";
1590 let result = strip_html_tags(input);
1591 assert_eq!(result, "Text with extra spaces");
1592 }
1593
1594 #[test]
1595 fn test_strip_html_tags_preserves_normal_content_order() {
1596 let input = "First <p>Second</p> Third <b>Fourth</b> Fifth";
1597 let result = strip_html_tags(input);
1598 assert_eq!(result, "First Second Third Fourth Fifth");
1599 }
1600}