1use log::{debug, error, info, warn};
7use serde::{Deserialize, Serialize};
8use std::borrow::Cow;
9
10use crate::json_validator::{MAX_JSON_DEPTH, validate_json_depth};
11use crate::validation::{validate_scan_id, validate_veracode_url};
12use crate::{VeracodeClient, VeracodeError};
13
14const PLUGIN_VERSION: &str = "25.2.0-0";
16
17#[derive(Debug, thiserror::Error)]
19#[must_use = "Need to handle all error enum types."]
20pub enum PipelineError {
21 #[error("Pipeline scan not found")]
22 ScanNotFound,
23 #[error("Permission denied: {0}")]
24 PermissionDenied(String),
25 #[error("Invalid request: {0}")]
26 InvalidRequest(String),
27 #[error("Scan timeout")]
28 ScanTimeout,
29 #[error("Scan findings not ready yet - try again later")]
30 FindingsNotReady,
31 #[error("Application not found: {0}")]
32 ApplicationNotFound(String),
33 #[error(
34 "Multiple applications found with name '{0}'. Please check the application name and ensure it uniquely identifies a single application."
35 )]
36 MultipleApplicationsFound(String),
37 #[error("API error: {0}")]
38 ApiError(#[from] VeracodeError),
39 #[error("HTTP error: {0}")]
40 Http(#[from] reqwest::Error),
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(rename_all = "UPPERCASE")]
46pub enum DevStage {
47 Development,
48 Testing,
49 Release,
50}
51
52impl std::fmt::Display for DevStage {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 match self {
55 DevStage::Development => write!(f, "DEVELOPMENT"),
56 DevStage::Testing => write!(f, "TESTING"),
57 DevStage::Release => write!(f, "RELEASE"),
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "UPPERCASE")]
65pub enum ScanStage {
66 Create,
67 Upload,
68 Start,
69 Details,
70 Findings,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
75#[serde(rename_all = "UPPERCASE")]
76pub enum ScanStatus {
77 Pending,
78 Uploading,
79 Started,
80 Success,
81 Failure,
82 Cancelled,
83 Timeout,
84 #[serde(rename = "USER_TIMEOUT")]
85 UserTimeout,
86}
87
88impl ScanStatus {
89 #[must_use]
91 pub fn is_successful(&self) -> bool {
92 matches!(self, ScanStatus::Success)
93 }
94
95 #[must_use]
97 pub fn is_failed(&self) -> bool {
98 matches!(
99 self,
100 ScanStatus::Failure
101 | ScanStatus::Cancelled
102 | ScanStatus::Timeout
103 | ScanStatus::UserTimeout
104 )
105 }
106
107 #[must_use]
109 pub fn is_in_progress(&self) -> bool {
110 matches!(
111 self,
112 ScanStatus::Pending | ScanStatus::Uploading | ScanStatus::Started
113 )
114 }
115}
116
117impl std::fmt::Display for ScanStatus {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 match self {
120 ScanStatus::Pending => write!(f, "PENDING"),
121 ScanStatus::Uploading => write!(f, "UPLOADING"),
122 ScanStatus::Started => write!(f, "STARTED"),
123 ScanStatus::Success => write!(f, "SUCCESS"),
124 ScanStatus::Failure => write!(f, "FAILURE"),
125 ScanStatus::Cancelled => write!(f, "CANCELLED"),
126 ScanStatus::Timeout => write!(f, "TIMEOUT"),
127 ScanStatus::UserTimeout => write!(f, "USER_TIMEOUT"),
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub enum Severity {
135 #[serde(rename = "0")]
136 Informational,
137 #[serde(rename = "1")]
138 VeryLow,
139 #[serde(rename = "2")]
140 Low,
141 #[serde(rename = "3")]
142 Medium,
143 #[serde(rename = "4")]
144 High,
145 #[serde(rename = "5")]
146 VeryHigh,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SourceFile {
152 pub file: String,
154 pub function_name: Option<String>,
156 pub function_prototype: String,
158 pub line: u32,
160 pub qualified_function_name: String,
162 pub scope: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct FindingFiles {
169 pub source_file: SourceFile,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct StackDumps {
176 pub stack_dump: Option<Vec<serde_json::Value>>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Finding {
188 pub cwe_id: String,
190 pub display_text: String,
192 pub files: FindingFiles,
194 pub flaw_details_link: Option<String>,
196 pub gob: String,
198 pub issue_id: u32,
200 pub issue_type: String,
202 pub issue_type_id: String,
204 pub severity: u32,
206 pub stack_dumps: Option<StackDumps>,
208 pub title: String,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct FindingsResponse {
215 #[serde(rename = "_links")]
217 pub links: Option<serde_json::Value>,
218 pub scan_id: String,
220 pub scan_status: ScanStatus,
222 pub message: String,
224 pub modules: Vec<String>,
226 pub modules_count: u32,
228 pub findings: Vec<Finding>,
230 pub selected_modules: Vec<String>,
232 pub stack_dump: Option<serde_json::Value>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct LegacyFinding {
240 pub file: String,
242 pub line: u32,
244 pub issue_type: String,
246 pub severity: u32,
248 pub message: String,
250 pub cwe_id: u32,
252 pub details_link: Option<String>,
254 pub issue_id: Option<String>,
256 pub owasp_category: Option<String>,
258 pub sans_category: Option<String>,
260}
261
262impl Finding {
263 #[must_use]
265 pub fn to_legacy(&self) -> LegacyFinding {
266 LegacyFinding {
267 file: self.files.source_file.file.clone(),
268 line: self.files.source_file.line,
269 issue_type: self.issue_type.clone(),
270 severity: self.severity,
271 message: strip_html_tags(&self.display_text).into_owned(),
272 cwe_id: self.cwe_id.parse().unwrap_or(0),
273 details_link: None,
274 issue_id: Some(self.issue_id.to_string()),
275 owasp_category: None,
276 sans_category: None,
277 }
278 }
279}
280
281fn strip_html_tags(html: &str) -> Cow<'_, str> {
287 if !html.contains('<') {
289 return Cow::Borrowed(html);
290 }
291
292 let mut result = String::new();
294 let mut in_tag = false;
295 let mut in_script_or_style = false;
296 let mut tag_name = String::new();
297 let mut collecting_tag_name = false;
298 let mut just_closed_tag = false;
299
300 for ch in html.chars() {
301 match ch {
302 '<' => {
303 if !result.is_empty() && !result.ends_with(char::is_whitespace) {
305 result.push(' ');
306 }
307 in_tag = true;
308 collecting_tag_name = true;
309 just_closed_tag = false;
310 tag_name.clear();
311 }
312 '>' => {
313 in_tag = false;
314
315 let tag_lower = tag_name.trim().to_lowercase();
317 if tag_lower.starts_with("script") || tag_lower.starts_with("style") {
318 in_script_or_style = true;
319 } else if tag_lower.starts_with("/script") || tag_lower.starts_with("/style") {
320 in_script_or_style = false;
321 }
322 collecting_tag_name = false;
323 just_closed_tag = true;
324 tag_name.clear();
325 }
326 ' ' if in_tag && collecting_tag_name => {
327 collecting_tag_name = false;
329 }
330 _ if (ch == '/' || ch.is_alphanumeric()) && collecting_tag_name => {
331 tag_name.push(ch);
333 }
334 _ if in_tag || in_script_or_style => {
335 }
337 _ => {
338 if just_closed_tag && !result.is_empty() && !result.ends_with(char::is_whitespace) {
341 result.push(' ');
342 }
343 just_closed_tag = false;
344 result.push(ch);
345 }
346 }
347 }
348
349 let cleaned = result.split_whitespace().collect::<Vec<&str>>().join(" ");
351 Cow::Owned(cleaned)
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct CreateScanRequest {
357 #[serde(skip_serializing_if = "never_skip_string")]
359 pub binary_name: String,
360 #[serde(skip_serializing_if = "never_skip_u64")]
362 pub binary_size: u64,
363 #[serde(skip_serializing_if = "never_skip_string")]
365 pub binary_hash: String,
366 pub project_name: String,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub project_uri: Option<String>,
371 pub dev_stage: DevStage,
373 #[serde(skip_serializing_if = "Option::is_none")]
375 pub app_id: Option<String>,
376 #[serde(skip_serializing_if = "Option::is_none")]
378 pub project_ref: Option<String>,
379 #[serde(skip_serializing_if = "Option::is_none")]
381 pub scan_timeout: Option<u32>,
382 #[serde(skip_serializing_if = "Option::is_none")]
384 pub plugin_version: Option<String>,
385 #[serde(skip_serializing_if = "Option::is_none")]
387 pub emit_stack_dump: Option<String>,
388 #[serde(skip_serializing_if = "Option::is_none")]
390 pub include_modules: Option<String>,
391}
392
393fn never_skip_string(_: &String) -> bool {
395 false
396}
397
398fn never_skip_u64(_: &u64) -> bool {
400 false
401}
402
403#[derive(Debug, Clone)]
405pub struct ScanCreationResult {
406 pub scan_id: String,
408 pub upload_uri: Option<String>,
410 pub details_uri: Option<String>,
412 pub start_uri: Option<String>,
414 pub cancel_uri: Option<String>,
416 pub expected_segments: Option<u32>,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct ScanConfig {
423 pub timeout: Option<u32>,
425 pub include_low_severity: Option<bool>,
427 pub max_findings: Option<u32>,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct Scan {
434 pub scan_id: String,
436 pub scan_status: ScanStatus,
443 pub api_version: f64,
445 pub app_id: Option<String>,
447 pub project_name: String,
449 pub project_uri: Option<String>,
451 pub project_ref: Option<String>,
453 pub commit_hash: Option<String>,
455 pub dev_stage: String,
457 pub binary_name: String,
459 pub binary_size: u64,
461 pub binary_hash: String,
463 pub binary_segments_expected: u32,
465 pub binary_segments_uploaded: u32,
467 pub scan_timeout: Option<u32>,
469 pub scan_duration: Option<f64>,
471 pub results_size: Option<f64>,
473 pub message: Option<String>,
475 pub created: String,
477 pub changed: String,
479 pub modules: Vec<serde_json::Value>,
481 pub selected_modules: Vec<serde_json::Value>,
483 pub display_modules: Vec<serde_json::Value>,
485 pub display_selected_modules: Vec<serde_json::Value>,
487 #[serde(rename = "_links")]
489 pub links: Option<serde_json::Value>,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct ScanResults {
495 pub scan: Scan,
497 pub findings: Vec<Finding>,
499 pub summary: FindingsSummary,
501 pub standards: SecurityStandards,
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct FindingsSummary {
508 pub very_high: u32,
510 pub high: u32,
512 pub medium: u32,
514 pub low: u32,
516 pub very_low: u32,
518 pub informational: u32,
520 pub total: u32,
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct SecurityStandards {
527 pub owasp: Option<StandardCompliance>,
529 pub sans: Option<StandardCompliance>,
531 pub pci: Option<StandardCompliance>,
533 pub cwe: Option<StandardCompliance>,
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct StandardCompliance {
540 pub total_rules: u32,
542 pub violations: u32,
544 pub compliance_score: f64,
546 pub violated_rules: Vec<String>,
548}
549
550pub struct PipelineApi {
552 client: VeracodeClient,
553 base_url: String,
555}
556
557impl PipelineApi {
558 #[must_use]
560 pub fn new(client: VeracodeClient) -> Self {
561 let base_url = Self::compute_base_url(&client);
562 Self { client, base_url }
563 }
564
565 fn compute_base_url(client: &VeracodeClient) -> String {
567 if client.config().base_url.contains("api.veracode.com") {
568 "https://api.veracode.com/pipeline_scan/v1".to_string()
569 } else {
570 format!(
572 "{}/pipeline_scan/v1",
573 client.config().base_url.trim_end_matches('/')
574 )
575 }
576 }
577
578 fn get_pipeline_base_url(&self) -> &str {
580 &self.base_url
581 }
582
583 pub async fn lookup_app_id_by_name(&self, app_name: &str) -> Result<String, PipelineError> {
598 let applications = self.client.search_applications_by_name(app_name).await?;
599
600 match applications.len() {
601 0 => Err(PipelineError::ApplicationNotFound(app_name.to_owned())),
602 1 => Ok(applications
603 .first()
604 .ok_or_else(|| PipelineError::ApplicationNotFound(app_name.to_owned()))?
605 .id
606 .to_string()),
607 _ => {
608 error!(
610 "❌ Found {} applications matching '{}':",
611 applications.len(),
612 app_name
613 );
614 for (i, app) in applications.iter().enumerate() {
615 if let Some(ref profile) = app.profile {
616 error!(
617 " {}. ID: {} - Name: '{}'",
618 i.saturating_add(1),
619 app.id,
620 profile.name
621 );
622 } else {
623 error!(
624 " {}. ID: {} - GUID: {}",
625 i.saturating_add(1),
626 app.id,
627 app.guid
628 );
629 }
630 }
631 error!(
632 "💡 Please provide a more specific application name that matches exactly one application."
633 );
634 Err(PipelineError::MultipleApplicationsFound(
635 app_name.to_string(),
636 ))
637 }
638 }
639 }
640
641 pub async fn create_scan_with_app_lookup(
667 &self,
668 request: &mut CreateScanRequest,
669 app_name: Option<&str>,
670 ) -> Result<ScanCreationResult, PipelineError> {
671 if let Some(name) = app_name
673 && request.app_id.is_none()
674 {
675 let app_id = self.lookup_app_id_by_name(name).await?;
676 request.app_id = Some(app_id.clone());
677 info!("✅ Found application '{name}' with ID: {app_id}");
678 }
679
680 self.create_scan(request).await
681 }
682
683 pub async fn create_scan(
698 &self,
699 request: &mut CreateScanRequest,
700 ) -> Result<ScanCreationResult, PipelineError> {
701 if request.plugin_version.is_none() {
703 request.plugin_version = Some(PLUGIN_VERSION.to_string());
704 }
705
706 if request.scan_timeout.is_none() {
708 request.scan_timeout = Some(30);
709 }
710
711 let endpoint = "/pipeline_scan/v1/scans";
713 let _full_url = format!("{}{}", self.client.config().base_url, endpoint);
714
715 let response = self
716 .client
717 .post_with_response("/pipeline_scan/v1/scans", Some(request))
718 .await?;
719
720 let response_text = response.text().await?;
721
722 validate_json_depth(&response_text, MAX_JSON_DEPTH)
724 .map_err(|e| PipelineError::InvalidRequest(format!("JSON validation failed: {}", e)))?;
725
726 if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
728 let scan_id = json_value
730 .get("scan_id")
731 .and_then(|id| id.as_str())
732 .ok_or_else(|| {
733 PipelineError::InvalidRequest("Missing scan_id in response".to_string())
734 })?
735 .to_owned();
736
737 let links = json_value.get("_links");
739
740 let upload_uri = links
741 .and_then(|links| links.get("upload"))
742 .and_then(|upload| upload.get("href"))
743 .and_then(|href| href.as_str())
744 .map(str::to_owned);
745
746 let details_uri = links
747 .and_then(|links| links.get("details"))
748 .and_then(|details| details.get("href"))
749 .and_then(|href| href.as_str())
750 .map(str::to_owned);
751
752 let start_uri = links
753 .and_then(|links| links.get("start"))
754 .and_then(|start| start.get("href"))
755 .and_then(|href| href.as_str())
756 .map(str::to_owned);
757
758 let cancel_uri = links
759 .and_then(|links| links.get("cancel"))
760 .and_then(|cancel| cancel.get("href"))
761 .and_then(|href| href.as_str())
762 .map(str::to_owned);
763
764 #[allow(clippy::cast_possible_truncation)]
766 let expected_segments = json_value
767 .get("binary_segments_expected")
768 .and_then(|segments| segments.as_u64())
769 .map(|s| s as u32);
770
771 debug!("✅ Scan creation response parsed:");
772 debug!(" Scan ID: {scan_id}");
773 if let Some(ref uri) = upload_uri {
774 debug!(" Upload URI: {uri}");
775 }
776 if let Some(ref uri) = details_uri {
777 debug!(" Details URI: {uri}");
778 }
779 if let Some(ref uri) = start_uri {
780 debug!(" Start URI: {uri}");
781 }
782 if let Some(ref uri) = cancel_uri {
783 debug!(" Cancel URI: {uri}");
784 }
785 if let Some(segments) = expected_segments {
786 debug!(" Expected segments: {segments}");
787 }
788
789 return Ok(ScanCreationResult {
790 scan_id,
791 upload_uri,
792 details_uri,
793 start_uri,
794 cancel_uri,
795 expected_segments,
796 });
797 }
798
799 Err(PipelineError::InvalidRequest(
800 "Failed to parse scan creation response".to_string(),
801 ))
802 }
803
804 pub async fn upload_binary_segments(
833 &self,
834 initial_upload_uri: &str,
835 expected_segments: i32,
836 binary_data: &[u8],
837 file_name: &str,
838 ) -> Result<(), PipelineError> {
839 if expected_segments <= 0 {
841 return Err(PipelineError::InvalidRequest(format!(
842 "Invalid segment count: {}. Must be a positive number.",
843 expected_segments
844 )));
845 }
846
847 let total_size = binary_data.len();
848 #[allow(
849 clippy::cast_possible_truncation,
850 clippy::cast_sign_loss,
851 clippy::cast_precision_loss
852 )]
853 let segment_size = ((total_size as f64) / (expected_segments as f64)).ceil() as usize;
854
855 debug!("📤 Uploading binary in {expected_segments} segments ({total_size} bytes total)");
856 debug!(" Segment size: {segment_size} bytes each");
857
858 let mut current_upload_uri = initial_upload_uri.to_string();
859
860 for segment_num in 0..expected_segments {
861 #[allow(clippy::cast_sign_loss)]
862 let start_idx = (segment_num as usize).saturating_mul(segment_size);
863 let end_idx = std::cmp::min(start_idx.saturating_add(segment_size), total_size);
864 let segment_data = binary_data.get(start_idx..end_idx).ok_or_else(|| {
865 PipelineError::InvalidRequest(format!(
866 "Invalid segment range: {}..{}",
867 start_idx, end_idx
868 ))
869 })?;
870
871 debug!(
872 " Uploading segment {}/{} ({} bytes)...",
873 segment_num.saturating_add(1),
874 expected_segments,
875 segment_data.len()
876 );
877
878 match self
879 .upload_single_segment(¤t_upload_uri, segment_data, file_name)
880 .await
881 {
882 Ok(response_text) => {
883 debug!(
884 " ✅ Segment {} uploaded successfully",
885 segment_num.saturating_add(1)
886 );
887
888 if segment_num < expected_segments.saturating_sub(1) {
890 match self.extract_next_upload_uri(&response_text) {
891 Some(next_uri) => {
892 current_upload_uri = next_uri;
893 debug!(" 📍 Next segment URI: {current_upload_uri}");
894 }
895 None => {
896 warn!(" ⚠️ No next URI found in response, using current");
897 }
898 }
899 }
900 }
901 Err(e) => {
902 error!(
903 " ❌ Failed to upload segment {}: {}",
904 segment_num.saturating_add(1),
905 e
906 );
907 return Err(e);
908 }
909 }
910 }
911
912 debug!("✅ All {expected_segments} segments uploaded successfully");
913 Ok(())
914 }
915
916 pub async fn upload_binary(
923 &self,
924 scan_id: &str,
925 binary_data: &[u8],
926 ) -> Result<(), PipelineError> {
927 validate_scan_id(scan_id)
929 .map_err(|e| PipelineError::InvalidRequest(format!("Invalid scan_id: {}", e)))?;
930
931 let upload_uri = format!("/pipeline_scan/scans/{scan_id}/segments/1");
933 let expected_segments = 1; let file_name = "binary.tar.gz";
935
936 self.upload_binary_segments(&upload_uri, expected_segments, binary_data, file_name)
937 .await
938 }
939
940 async fn upload_single_segment(
942 &self,
943 upload_uri: &str,
944 segment_data: &[u8],
945 file_name: &str,
946 ) -> Result<String, PipelineError> {
947 let url = if upload_uri.starts_with("http") {
949 validate_veracode_url(upload_uri)
951 .map_err(|e| PipelineError::InvalidRequest(format!("Invalid upload URI: {}", e)))?;
952 upload_uri.to_string()
953 } else {
954 format!("{}{}", self.get_pipeline_base_url(), upload_uri)
955 };
956
957 let mut headers = std::collections::HashMap::new();
959 headers.insert("accept", "application/json");
960 headers.insert("PLUGIN-VERSION", PLUGIN_VERSION); let response = self
964 .client
965 .upload_file_multipart_put(
966 &url,
967 "file",
968 file_name,
969 segment_data.to_vec(),
970 Some(headers),
971 )
972 .await
973 .map_err(PipelineError::ApiError)?;
974
975 if response.status().is_success() {
976 let response_text = response.text().await?;
977 Ok(response_text)
978 } else {
979 let status = response.status();
980 let error_text = response
981 .text()
982 .await
983 .unwrap_or_else(|_| "Unknown error".to_string());
984 Err(PipelineError::InvalidRequest(format!(
985 "Segment upload failed with status {status}: {error_text}"
986 )))
987 }
988 }
989
990 fn extract_next_upload_uri(&self, response_text: &str) -> Option<String> {
992 if validate_json_depth(response_text, MAX_JSON_DEPTH).is_err() {
994 warn!("JSON validation failed in extract_next_upload_uri");
995 return None;
996 }
997
998 if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response_text) {
1000 if let Some(links) = json_value.get("_links")
1002 && let Some(upload) = links.get("upload")
1003 && let Some(href) = upload.get("href")
1004 {
1005 return href.as_str().map(str::to_owned);
1006 }
1007
1008 if let Some(upload_url) = json_value.get("upload_url") {
1010 return upload_url.as_str().map(str::to_owned);
1011 }
1012 }
1013
1014 None
1015 }
1016
1017 pub async fn start_scan_with_uri(
1033 &self,
1034 start_uri: &str,
1035 config: Option<ScanConfig>,
1036 ) -> Result<(), PipelineError> {
1037 let mut payload = serde_json::json!({
1039 "scan_status": "STARTED"
1040 });
1041
1042 if let Some(config) = config {
1044 if let Some(timeout) = config.timeout
1045 && let Some(obj) = payload.as_object_mut()
1046 {
1047 obj.insert(
1048 "timeout".to_string(),
1049 serde_json::Value::Number(timeout.into()),
1050 );
1051 }
1052 if let Some(include_low_severity) = config.include_low_severity
1053 && let Some(obj) = payload.as_object_mut()
1054 {
1055 obj.insert(
1056 "include_low_severity".to_string(),
1057 serde_json::Value::Bool(include_low_severity),
1058 );
1059 }
1060 if let Some(max_findings) = config.max_findings
1061 && let Some(obj) = payload.as_object_mut()
1062 {
1063 obj.insert(
1064 "max_findings".to_string(),
1065 serde_json::Value::Number(max_findings.into()),
1066 );
1067 }
1068 }
1069
1070 let url = if start_uri.starts_with("http") {
1072 validate_veracode_url(start_uri)
1074 .map_err(|e| PipelineError::InvalidRequest(format!("Invalid start URI: {}", e)))?;
1075 start_uri.to_string()
1076 } else {
1077 format!("{}{}", self.get_pipeline_base_url(), start_uri)
1078 };
1079
1080 let auth_header = self
1082 .client
1083 .generate_auth_header("PUT", &url)
1084 .map_err(PipelineError::ApiError)?;
1085
1086 let response = self
1087 .client
1088 .client()
1089 .put(&url)
1090 .header("Authorization", auth_header)
1091 .header("accept", "application/json")
1092 .header("content-type", "application/json")
1093 .json(&payload)
1094 .send()
1095 .await?;
1096
1097 if response.status().is_success() {
1098 Ok(())
1099 } else {
1100 let error_text = response
1101 .text()
1102 .await
1103 .unwrap_or_else(|_| "Unknown error".to_string());
1104 Err(PipelineError::InvalidRequest(format!(
1105 "Failed to start scan: {error_text}"
1106 )))
1107 }
1108 }
1109
1110 pub async fn start_scan(
1126 &self,
1127 scan_id: &str,
1128 config: Option<ScanConfig>,
1129 ) -> Result<(), PipelineError> {
1130 validate_scan_id(scan_id)
1132 .map_err(|e| PipelineError::InvalidRequest(format!("Invalid scan_id: {}", e)))?;
1133
1134 let endpoint = format!("/scans/{scan_id}");
1135 let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1136
1137 let mut payload = serde_json::json!({
1139 "scan_status": "STARTED"
1140 });
1141
1142 if let Some(config) = config {
1144 if let Some(timeout) = config.timeout
1145 && let Some(obj) = payload.as_object_mut()
1146 {
1147 obj.insert(
1148 "timeout".to_string(),
1149 serde_json::Value::Number(timeout.into()),
1150 );
1151 }
1152 if let Some(include_low_severity) = config.include_low_severity
1153 && let Some(obj) = payload.as_object_mut()
1154 {
1155 obj.insert(
1156 "include_low_severity".to_string(),
1157 serde_json::Value::Bool(include_low_severity),
1158 );
1159 }
1160 if let Some(max_findings) = config.max_findings
1161 && let Some(obj) = payload.as_object_mut()
1162 {
1163 obj.insert(
1164 "max_findings".to_string(),
1165 serde_json::Value::Number(max_findings.into()),
1166 );
1167 }
1168 }
1169
1170 let auth_header = self
1172 .client
1173 .generate_auth_header("PUT", &url)
1174 .map_err(PipelineError::ApiError)?;
1175
1176 let response = self
1177 .client
1178 .client()
1179 .put(&url)
1180 .header("Authorization", auth_header)
1181 .header("accept", "application/json")
1182 .header("content-type", "application/json")
1183 .json(&payload)
1184 .send()
1185 .await?;
1186
1187 if response.status().is_success() {
1188 Ok(())
1189 } else {
1190 let error_text = response
1191 .text()
1192 .await
1193 .unwrap_or_else(|_| "Unknown error".to_string());
1194 Err(PipelineError::InvalidRequest(format!(
1195 "Failed to start scan: {error_text}"
1196 )))
1197 }
1198 }
1199
1200 pub async fn get_scan_with_uri(&self, details_uri: &str) -> Result<Scan, PipelineError> {
1215 let url = if details_uri.starts_with("http") {
1217 validate_veracode_url(details_uri).map_err(|e| {
1219 PipelineError::InvalidRequest(format!("Invalid details URI: {}", e))
1220 })?;
1221 details_uri.to_string()
1222 } else {
1223 format!("{}{}", self.get_pipeline_base_url(), details_uri)
1224 };
1225
1226 let auth_header = self
1228 .client
1229 .generate_auth_header("GET", &url)
1230 .map_err(PipelineError::ApiError)?;
1231
1232 let response = self
1233 .client
1234 .client()
1235 .get(&url)
1236 .header("Authorization", auth_header)
1237 .header("accept", "application/json")
1238 .send()
1239 .await?;
1240
1241 let response_text = response.text().await?;
1242
1243 validate_json_depth(&response_text, MAX_JSON_DEPTH)
1245 .map_err(|e| PipelineError::InvalidRequest(format!("JSON validation failed: {}", e)))?;
1246
1247 serde_json::from_str::<Scan>(&response_text).map_err(|e| {
1248 PipelineError::InvalidRequest(format!("Failed to parse scan details: {e}"))
1249 })
1250 }
1251
1252 pub async fn get_scan(&self, scan_id: &str) -> Result<Scan, PipelineError> {
1267 validate_scan_id(scan_id)
1269 .map_err(|e| PipelineError::InvalidRequest(format!("Invalid scan_id: {}", e)))?;
1270
1271 let endpoint = format!("/scans/{scan_id}");
1272 let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1273
1274 let auth_header = self
1276 .client
1277 .generate_auth_header("GET", &url)
1278 .map_err(PipelineError::ApiError)?;
1279
1280 let response = self
1281 .client
1282 .client()
1283 .get(&url)
1284 .header("Authorization", auth_header)
1285 .header("accept", "application/json")
1286 .send()
1287 .await?;
1288
1289 let response_text = response.text().await?;
1290
1291 validate_json_depth(&response_text, MAX_JSON_DEPTH)
1293 .map_err(|e| PipelineError::InvalidRequest(format!("JSON validation failed: {}", e)))?;
1294
1295 serde_json::from_str::<Scan>(&response_text).map_err(|e| {
1296 PipelineError::InvalidRequest(format!("Failed to parse scan details: {e}"))
1297 })
1298 }
1299
1300 pub async fn get_findings(&self, scan_id: &str) -> Result<Vec<Finding>, PipelineError> {
1325 validate_scan_id(scan_id)
1327 .map_err(|e| PipelineError::InvalidRequest(format!("Invalid scan_id: {}", e)))?;
1328
1329 let endpoint = format!("/scans/{scan_id}/findings");
1330 let url = format!("{}{}", self.get_pipeline_base_url(), endpoint);
1331
1332 debug!("🔍 Debug - get_findings() calling: {url}");
1333
1334 let auth_header = self
1336 .client
1337 .generate_auth_header("GET", &url)
1338 .map_err(PipelineError::ApiError)?;
1339
1340 let response = self
1341 .client
1342 .client()
1343 .get(&url)
1344 .header("Authorization", auth_header)
1345 .header("accept", "application/json")
1346 .send()
1347 .await?;
1348
1349 let status = response.status();
1350 let response_text = response.text().await?;
1351
1352 debug!("🔍 Debug - Findings API Response:");
1354 debug!(" Status: {status}");
1355 debug!(" Response Length: {} bytes", response_text.len());
1356
1357 match status.as_u16() {
1358 200 => {
1359 validate_json_depth(&response_text, MAX_JSON_DEPTH).map_err(|e| {
1361 PipelineError::InvalidRequest(format!("JSON validation failed: {}", e))
1362 })?;
1363
1364 match serde_json::from_str::<FindingsResponse>(&response_text) {
1366 Ok(findings_response) => {
1367 debug!("🔍 Debug - Successfully parsed findings response:");
1368 debug!(" Scan Status: {}", findings_response.scan_status);
1369 debug!(" Message: {}", findings_response.message);
1370 debug!(" Modules: [{}]", findings_response.modules.join(", "));
1371 debug!(" Findings Count: {}", findings_response.findings.len());
1372 Ok(findings_response.findings)
1373 }
1374 Err(e) => {
1375 debug!("❌ Debug - Failed to parse FindingsResponse: {e}");
1376 if let Ok(json_value) =
1378 serde_json::from_str::<serde_json::Value>(&response_text)
1379 && let Some(findings_array) =
1380 json_value.get("findings").and_then(|f| f.as_array())
1381 {
1382 debug!("🔍 Debug - Trying fallback parsing of findings array...");
1383 let findings: Result<Vec<Finding>, _> = findings_array
1384 .iter()
1385 .map(|f| serde_json::from_value(f.clone()))
1386 .collect();
1387 return findings.map_err(|e| {
1388 PipelineError::InvalidRequest(format!(
1389 "Failed to parse findings array: {e}"
1390 ))
1391 });
1392 }
1393 Err(PipelineError::InvalidRequest(format!(
1394 "Failed to parse findings response: {e}"
1395 )))
1396 }
1397 }
1398 }
1399 202 => {
1400 Err(PipelineError::FindingsNotReady)
1402 }
1403 _ => {
1404 Err(PipelineError::InvalidRequest(format!(
1406 "Failed to get findings - HTTP {status}: {response_text}"
1407 )))
1408 }
1409 }
1410 }
1411
1412 pub async fn get_results(&self, scan_id: &str) -> Result<ScanResults, PipelineError> {
1432 debug!("🔍 Debug - get_results() getting scan details for: {scan_id}");
1433 let scan = self.get_scan(scan_id).await?;
1434 debug!("🔍 Debug - get_results() scan status: {}", scan.scan_status);
1435 debug!("🔍 Debug - get_results() calling get_findings() for: {scan_id}");
1436 let findings = self.get_findings(scan_id).await?;
1437
1438 let summary = self.calculate_summary(&findings);
1440
1441 let standards = SecurityStandards {
1443 owasp: None,
1444 sans: None,
1445 pci: None,
1446 cwe: None,
1447 };
1448
1449 Ok(ScanResults {
1450 scan,
1451 findings,
1452 summary,
1453 standards,
1454 })
1455 }
1456
1457 pub async fn cancel_scan(&self, scan_id: &str) -> Result<(), PipelineError> {
1472 validate_scan_id(scan_id)
1474 .map_err(|e| PipelineError::InvalidRequest(format!("Invalid scan_id: {}", e)))?;
1475
1476 let endpoint = format!("/scans/{scan_id}/cancel");
1477
1478 let response = self.client.delete_with_response(&endpoint).await?;
1479
1480 if response.status().is_success() {
1481 Ok(())
1482 } else {
1483 let error_text = response
1484 .text()
1485 .await
1486 .unwrap_or_else(|_| "Unknown error".to_string());
1487 Err(PipelineError::InvalidRequest(format!(
1488 "Failed to cancel scan: {error_text}"
1489 )))
1490 }
1491 }
1492
1493 pub async fn wait_for_completion(
1510 &self,
1511 scan_id: &str,
1512 timeout_minutes: Option<u32>,
1513 poll_interval_seconds: Option<u32>,
1514 ) -> Result<Scan, PipelineError> {
1515 let timeout = timeout_minutes.unwrap_or(60);
1516 let interval = poll_interval_seconds.unwrap_or(10);
1517 let max_polls = timeout
1518 .saturating_mul(60)
1519 .checked_div(interval)
1520 .unwrap_or(u32::MAX);
1521
1522 for _ in 0..max_polls {
1523 let scan = self.get_scan(scan_id).await?;
1524
1525 if scan.scan_status.is_successful() || scan.scan_status.is_failed() {
1527 return Ok(scan);
1528 }
1529
1530 tokio::time::sleep(tokio::time::Duration::from_secs(interval as u64)).await;
1532 }
1533
1534 Err(PipelineError::ScanTimeout)
1535 }
1536
1537 fn calculate_summary(&self, findings: &[Finding]) -> FindingsSummary {
1539 #[allow(clippy::cast_possible_truncation)]
1540 let mut summary = FindingsSummary {
1541 very_high: 0,
1542 high: 0,
1543 medium: 0,
1544 low: 0,
1545 very_low: 0,
1546 informational: 0,
1547 total: findings.len() as u32,
1548 };
1549
1550 for finding in findings {
1551 match finding.severity {
1552 5 => summary.very_high = summary.very_high.saturating_add(1),
1553 4 => summary.high = summary.high.saturating_add(1),
1554 3 => summary.medium = summary.medium.saturating_add(1),
1555 2 => summary.low = summary.low.saturating_add(1),
1556 1 => summary.very_low = summary.very_low.saturating_add(1),
1557 0 => summary.informational = summary.informational.saturating_add(1),
1558 _ => {} }
1560 }
1561
1562 summary
1563 }
1564}
1565
1566#[cfg(test)]
1567mod tests {
1568 use super::*;
1569
1570 #[test]
1571 fn test_strip_html_tags_no_tags() {
1572 let input = "This is plain text";
1573 let result = strip_html_tags(input);
1574 assert_eq!(result, "This is plain text");
1575 }
1576
1577 #[test]
1578 fn test_strip_html_tags_simple() {
1579 let input = "This is <b>bold</b> text";
1580 let result = strip_html_tags(input);
1581 assert_eq!(result, "This is bold text");
1582 }
1583
1584 #[test]
1585 fn test_strip_html_tags_removes_script_content() {
1586 let input = "<script>alert('XSS')</script>This is safe";
1588 let result = strip_html_tags(input);
1589 assert_eq!(result, "This is safe");
1590 }
1591
1592 #[test]
1593 fn test_strip_html_tags_removes_script_with_attributes() {
1594 let input = "<script type='text/javascript'>alert('XSS')</script>Safe text";
1595 let result = strip_html_tags(input);
1596 assert_eq!(result, "Safe text");
1597 }
1598
1599 #[test]
1600 fn test_strip_html_tags_removes_style_content() {
1601 let input = "<style>body { color: red; }</style>Visible text";
1602 let result = strip_html_tags(input);
1603 assert_eq!(result, "Visible text");
1604 }
1605
1606 #[test]
1607 fn test_strip_html_tags_multiple_scripts() {
1608 let input = "<script>evil1()</script>Good<script>evil2()</script>Text";
1609 let result = strip_html_tags(input);
1610 assert_eq!(result, "Good Text");
1611 }
1612
1613 #[test]
1614 fn test_strip_html_tags_nested_tags() {
1615 let input = "<div><p>Paragraph <b>bold</b> text</p></div>";
1616 let result = strip_html_tags(input);
1617 assert_eq!(result, "Paragraph bold text");
1618 }
1619
1620 #[test]
1621 fn test_strip_html_tags_mixed_content() {
1622 let input = "Before<script>bad()</script>Middle<b>bold</b>After";
1623 let result = strip_html_tags(input);
1624 assert_eq!(result, "Before Middle bold After");
1625 }
1626
1627 #[test]
1628 fn test_strip_html_tags_case_insensitive_script() {
1629 let input = "<SCRIPT>evil()</SCRIPT>Safe";
1630 let result = strip_html_tags(input);
1631 assert_eq!(result, "Safe");
1632 }
1633
1634 #[test]
1635 fn test_strip_html_tags_case_insensitive_style() {
1636 let input = "<STYLE>css</STYLE>Text";
1637 let result = strip_html_tags(input);
1638 assert_eq!(result, "Text");
1639 }
1640
1641 #[test]
1642 fn test_strip_html_tags_whitespace_cleanup() {
1643 let input = "Text <b>with</b> extra <i>spaces</i>";
1645 let result = strip_html_tags(input);
1646 assert_eq!(result, "Text with extra spaces");
1647 }
1648
1649 #[test]
1650 fn test_strip_html_tags_no_html_preserves_whitespace() {
1651 let input = "Text with extra spaces";
1653 let result = strip_html_tags(input);
1654 assert_eq!(result, "Text with extra spaces");
1655 }
1656
1657 #[test]
1658 fn test_strip_html_tags_preserves_normal_content_order() {
1659 let input = "First <p>Second</p> Third <b>Fourth</b> Fifth";
1660 let result = strip_html_tags(input);
1661 assert_eq!(result, "First Second Third Fourth Fifth");
1662 }
1663
1664 #[test]
1665 fn test_scan_status_classifications() {
1666 assert!(ScanStatus::Success.is_successful());
1668 assert!(!ScanStatus::Success.is_failed());
1669 assert!(!ScanStatus::Success.is_in_progress());
1670
1671 assert!(ScanStatus::Failure.is_failed());
1673 assert!(ScanStatus::Cancelled.is_failed());
1674 assert!(ScanStatus::Timeout.is_failed());
1675 assert!(ScanStatus::UserTimeout.is_failed());
1676
1677 assert!(ScanStatus::Pending.is_in_progress());
1679 assert!(ScanStatus::Uploading.is_in_progress());
1680 assert!(ScanStatus::Started.is_in_progress());
1681 }
1682
1683 #[test]
1684 fn test_finding_to_legacy_conversion() {
1685 let finding = Finding {
1686 cwe_id: "89".to_string(),
1687 display_text: "<b>SQL Injection</b> vulnerability found".to_string(),
1688 files: FindingFiles {
1689 source_file: SourceFile {
1690 file: "app.rs".to_string(),
1691 function_name: Some("query".to_string()),
1692 function_prototype: "fn query()".to_string(),
1693 line: 42,
1694 qualified_function_name: "app::query".to_string(),
1695 scope: "local".to_string(),
1696 },
1697 },
1698 flaw_details_link: Some("https://veracode.com/details".to_string()),
1699 gob: "A".to_string(),
1700 issue_id: 12345,
1701 issue_type: "SQL Injection".to_string(),
1702 issue_type_id: "89".to_string(),
1703 severity: 5,
1704 stack_dumps: None,
1705 title: "SQL Injection Flaw".to_string(),
1706 };
1707
1708 let legacy = finding.to_legacy();
1709
1710 assert_eq!(legacy.file, "app.rs");
1712 assert_eq!(legacy.line, 42);
1713 assert_eq!(legacy.severity, 5);
1714 assert_eq!(legacy.cwe_id, 89);
1715 assert_eq!(legacy.issue_id, Some("12345".to_string()));
1716 assert_eq!(legacy.message, "SQL Injection vulnerability found");
1718 }
1719
1720 #[test]
1721 fn test_finding_to_legacy_with_invalid_cwe() {
1722 let finding = Finding {
1723 cwe_id: "not-a-number".to_string(),
1724 display_text: "Test".to_string(),
1725 files: FindingFiles {
1726 source_file: SourceFile {
1727 file: "app.rs".to_string(),
1728 function_name: None,
1729 function_prototype: "fn test()".to_string(),
1730 line: 1,
1731 qualified_function_name: "app::test".to_string(),
1732 scope: "local".to_string(),
1733 },
1734 },
1735 flaw_details_link: None,
1736 gob: "B".to_string(),
1737 issue_id: 1,
1738 issue_type: "Test".to_string(),
1739 issue_type_id: "0".to_string(),
1740 severity: 1,
1741 stack_dumps: None,
1742 title: "Test".to_string(),
1743 };
1744
1745 let legacy = finding.to_legacy();
1746 assert_eq!(legacy.cwe_id, 0);
1748 }
1749}
1750
1751#[cfg(test)]
1759#[allow(clippy::expect_used)]
1760mod proptest_security {
1761 use super::*;
1762 use proptest::prelude::*;
1763
1764 proptest! {
1769 #![proptest_config(ProptestConfig {
1770 cases: if cfg!(miri) { 5 } else { 1000 },
1771 failure_persistence: None,
1772 .. ProptestConfig::default()
1773 })]
1774
1775 #[test]
1778 fn proptest_html_script_content_removed(
1779 script_content in "[a-zA-Z0-9()';,.]{3,100}",
1780 prefix in "[a-zA-Z0-9]{3,50}",
1781 suffix in "[a-zA-Z0-9]{0,50}",
1782 ) {
1783 let input = format!("{}<script>{}</script>{}", prefix, script_content, suffix);
1784 let result = strip_html_tags(&input);
1785
1786 prop_assert!(
1790 !result.contains(&script_content),
1791 "Script content '{}' must not appear in sanitized output: '{}'",
1792 script_content,
1793 result
1794 );
1795
1796 prop_assert!(result.contains(&prefix));
1798 if !suffix.is_empty() {
1799 prop_assert!(result.contains(&suffix));
1800 }
1801 }
1802
1803 #[test]
1806 fn proptest_html_style_content_removed(
1807 style_content in "[a-zA-Z0-9:;{}]{3,100}",
1808 text_content in "[a-zA-Z0-9]{3,50}",
1809 ) {
1810 let input = format!("<style>{}</style>{}", style_content, text_content);
1811 let result = strip_html_tags(&input);
1812
1813 prop_assert!(
1816 !result.contains(&style_content),
1817 "Style content '{}' must not appear in sanitized output: '{}'",
1818 style_content,
1819 result
1820 );
1821
1822 prop_assert!(result.contains(&text_content));
1824 }
1825
1826 #[test]
1829 fn proptest_html_no_tags_unchanged(
1830 plain_text in "[a-zA-Z0-9 ,.!?]{1,200}",
1831 ) {
1832 let plain_text = plain_text.replace(['<', '>'], "");
1834 if plain_text.is_empty() {
1835 return Ok(());
1836 }
1837
1838 let result = strip_html_tags(&plain_text);
1839
1840 prop_assert_eq!(&result, &plain_text);
1842
1843 match result {
1845 std::borrow::Cow::Borrowed(s) => {
1846 prop_assert_eq!(s, plain_text.as_str());
1847 }
1848 std::borrow::Cow::Owned(_) => {
1849 prop_assert!(false, "Should use Cow::Borrowed for plain text");
1850 }
1851 }
1852 }
1853
1854 #[test]
1857 fn proptest_html_nested_tags_removed(
1858 depth in 1usize..=10,
1859 content in "[a-zA-Z0-9]{1,20}",
1860 ) {
1861 let mut input = String::new();
1863 for _ in 0..depth {
1864 input.push_str("<div>");
1865 }
1866 input.push_str(&content);
1867 for _ in 0..depth {
1868 input.push_str("</div>");
1869 }
1870
1871 let result = strip_html_tags(&input);
1872
1873 prop_assert!(!result.contains("<div>"));
1875 prop_assert!(!result.contains("</div>"));
1876 let content_trimmed = content.trim();
1878 if !content_trimmed.is_empty() {
1879 prop_assert!(result.contains(content_trimmed));
1880 }
1881 }
1882
1883 #[test]
1886 fn proptest_html_case_insensitive_script(
1887 uppercase_ratio in 0.0..=1.0,
1888 ) {
1889 let script_word = randomize_case("script", uppercase_ratio);
1891 let input = format!("<{}>alert('xss')</{}>Safe", script_word, script_word);
1892 let result = strip_html_tags(&input);
1893
1894 prop_assert!(!result.contains("alert"));
1896 prop_assert!(!result.contains("xss"));
1897 prop_assert!(result.contains("Safe"));
1898 }
1899
1900 #[test]
1903 fn proptest_html_preserves_word_boundaries(
1904 word1 in "[a-zA-Z]{1,20}",
1905 word2 in "[a-zA-Z]{1,20}",
1906 word3 in "[a-zA-Z]{1,20}",
1907 ) {
1908 let input = format!("{}<b>{}</b>{}", word1, word2, word3);
1909 let result = strip_html_tags(&input);
1910
1911 prop_assert!(result.contains(&word1));
1913 prop_assert!(result.contains(&word2));
1914 prop_assert!(result.contains(&word3));
1915
1916 let words: Vec<&str> = result.split_whitespace().collect();
1918 prop_assert!(words.contains(&word1.as_str()));
1919 prop_assert!(words.contains(&word2.as_str()));
1920 prop_assert!(words.contains(&word3.as_str()));
1921 }
1922 }
1923
1924 proptest! {
1929 #![proptest_config(ProptestConfig {
1930 cases: if cfg!(miri) { 5 } else { 1000 },
1931 failure_persistence: None,
1932 .. ProptestConfig::default()
1933 })]
1934
1935 #[test]
1938 fn proptest_segment_count_validation_rejects_invalid(
1939 invalid_count in -1000i32..=0i32,
1940 ) {
1941 let is_valid = invalid_count > 0;
1946 prop_assert!(!is_valid, "Segment count {} should be rejected", invalid_count);
1947 }
1948
1949 #[test]
1952 fn proptest_segment_size_calculation_safe(
1953 file_size in 0usize..=10_000_000,
1954 segment_count in 1i32..=1000,
1955 ) {
1956 #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1958 let segment_size = ((file_size as f64) / (segment_count as f64)).ceil() as usize;
1959
1960 prop_assert!(segment_size <= file_size.saturating_add(1));
1962
1963 prop_assert!(segment_size < usize::MAX);
1965
1966 let total_bytes = segment_size.saturating_mul(segment_count.try_into().unwrap_or(0));
1968 prop_assert!(total_bytes >= file_size);
1969 }
1970
1971 #[test]
1974 fn proptest_segment_boundaries_safe(
1975 file_size in 1usize..=1_000_000,
1976 segment_num in 0i32..=100,
1977 segment_count in 1i32..=100,
1978 ) {
1979 if segment_num >= segment_count {
1981 return Ok(());
1982 }
1983
1984 #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1985 let segment_size = ((file_size as f64) / (segment_count as f64)).ceil() as usize;
1986
1987 #[allow(clippy::cast_sign_loss)]
1989 let start_idx = (segment_num as usize).saturating_mul(segment_size);
1990 let end_idx = std::cmp::min(start_idx.saturating_add(segment_size), file_size);
1991
1992 prop_assert!(start_idx <= end_idx);
1994
1995 prop_assert!(end_idx <= file_size);
1997
1998 let segment_len = end_idx.saturating_sub(start_idx);
2000 prop_assert!(segment_len <= file_size);
2001 }
2002 }
2003
2004 proptest! {
2009 #![proptest_config(ProptestConfig {
2010 cases: if cfg!(miri) { 5 } else { 1000 },
2011 failure_persistence: None,
2012 .. ProptestConfig::default()
2013 })]
2014
2015 #[test]
2018 fn proptest_summary_calculation_no_overflow(
2019 severity_0_count in 0usize..=10000,
2020 severity_1_count in 0usize..=10000,
2021 severity_2_count in 0usize..=10000,
2022 severity_3_count in 0usize..=10000,
2023 severity_4_count in 0usize..=10000,
2024 severity_5_count in 0usize..=10000,
2025 ) {
2026 let mut findings = Vec::new();
2028
2029 for _ in 0..severity_0_count {
2030 findings.push(create_test_finding(0));
2031 }
2032 for _ in 0..severity_1_count {
2033 findings.push(create_test_finding(1));
2034 }
2035 for _ in 0..severity_2_count {
2036 findings.push(create_test_finding(2));
2037 }
2038 for _ in 0..severity_3_count {
2039 findings.push(create_test_finding(3));
2040 }
2041 for _ in 0..severity_4_count {
2042 findings.push(create_test_finding(4));
2043 }
2044 for _ in 0..severity_5_count {
2045 findings.push(create_test_finding(5));
2046 }
2047
2048 let config = create_test_config();
2050 let client = VeracodeClient::new(config).expect("valid test config");
2051 let api = PipelineApi::new(client);
2052
2053 let summary = api.calculate_summary(&findings);
2054
2055 let expected_total = findings.len();
2057 #[allow(clippy::cast_possible_truncation)]
2058 let expected_total_u32 = expected_total as u32;
2059 prop_assert_eq!(summary.total, expected_total_u32);
2060
2061 #[allow(clippy::cast_possible_truncation)]
2063 {
2064 let sev0 = severity_0_count as u32;
2065 let sev1 = severity_1_count as u32;
2066 let sev2 = severity_2_count as u32;
2067 let sev3 = severity_3_count as u32;
2068 let sev4 = severity_4_count as u32;
2069 let sev5 = severity_5_count as u32;
2070 prop_assert_eq!(summary.informational, sev0);
2071 prop_assert_eq!(summary.very_low, sev1);
2072 prop_assert_eq!(summary.low, sev2);
2073 prop_assert_eq!(summary.medium, sev3);
2074 prop_assert_eq!(summary.high, sev4);
2075 prop_assert_eq!(summary.very_high, sev5);
2076 }
2077
2078 let sum = summary.informational
2080 .saturating_add(summary.very_low)
2081 .saturating_add(summary.low)
2082 .saturating_add(summary.medium)
2083 .saturating_add(summary.high)
2084 .saturating_add(summary.very_high);
2085 prop_assert_eq!(sum, summary.total);
2086 }
2087
2088 #[test]
2091 fn proptest_summary_handles_unknown_severity(
2092 valid_count in 0usize..=100,
2093 unknown_severity in 6u32..=255,
2094 ) {
2095 let mut findings = Vec::new();
2096
2097 for _ in 0..valid_count {
2099 findings.push(create_test_finding(3));
2100 }
2101
2102 findings.push(create_test_finding(unknown_severity));
2104
2105 let config = create_test_config();
2106 let client = VeracodeClient::new(config).expect("valid test config");
2107 let api = PipelineApi::new(client);
2108
2109 let summary = api.calculate_summary(&findings);
2110
2111 #[allow(clippy::cast_possible_truncation)]
2113 let expected_total = findings.len() as u32;
2114 #[allow(clippy::cast_possible_truncation)]
2115 let expected_medium = valid_count as u32;
2116 prop_assert_eq!(summary.total, expected_total);
2117
2118 prop_assert_eq!(summary.medium, expected_medium);
2120 }
2121 }
2122
2123 fn randomize_case(s: &str, uppercase_ratio: f64) -> String {
2129 s.chars()
2130 .map(|c| {
2131 if c.is_alphabetic() {
2132 let char_hash = (c as u32 as f64) / 256.0;
2134 if char_hash < uppercase_ratio {
2135 c.to_uppercase().to_string()
2136 } else {
2137 c.to_lowercase().to_string()
2138 }
2139 } else {
2140 c.to_string()
2141 }
2142 })
2143 .collect()
2144 }
2145
2146 fn create_test_finding(severity: u32) -> Finding {
2148 Finding {
2149 cwe_id: "89".to_string(),
2150 display_text: "Test finding".to_string(),
2151 files: FindingFiles {
2152 source_file: SourceFile {
2153 file: "test.rs".to_string(),
2154 function_name: Some("test".to_string()),
2155 function_prototype: "fn test()".to_string(),
2156 line: 1,
2157 qualified_function_name: "test::test".to_string(),
2158 scope: "local".to_string(),
2159 },
2160 },
2161 flaw_details_link: None,
2162 gob: "C".to_string(),
2163 issue_id: 1,
2164 issue_type: "Test".to_string(),
2165 issue_type_id: "0".to_string(),
2166 severity,
2167 stack_dumps: None,
2168 title: "Test".to_string(),
2169 }
2170 }
2171
2172 fn create_test_config() -> crate::VeracodeConfig {
2174 use crate::{VeracodeCredentials, VeracodeRegion};
2175
2176 crate::VeracodeConfig {
2177 credentials: VeracodeCredentials::new(
2178 "test_api_id".to_string(),
2179 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
2180 ),
2181 base_url: "https://api.veracode.com".to_string(),
2182 rest_base_url: "https://api.veracode.com".to_string(),
2183 xml_base_url: "https://analysiscenter.veracode.com".to_string(),
2184 region: VeracodeRegion::Commercial,
2185 validate_certificates: true,
2186 connect_timeout: 30,
2187 request_timeout: 300,
2188 proxy_url: None,
2189 proxy_username: None,
2190 proxy_password: None,
2191 retry_config: Default::default(),
2192 }
2193 }
2194}
2195
2196#[cfg(test)]
2208mod miri_tests {
2209 use super::*;
2210
2211 #[test]
2214 fn test_miri_html_sanitization_memory_safety() {
2215 let angle_brackets_left = "<".repeat(1000);
2217 let angle_brackets_right = ">".repeat(1000);
2218 let repeated_tags = "<a>".repeat(100);
2219
2220 let test_cases = vec![
2221 "",
2222 "x",
2223 "<",
2224 ">",
2225 "<>",
2226 "<script>",
2227 "</script>",
2228 "<script></script>",
2229 "<SCRIPT>alert('XSS')</SCRIPT>Safe",
2230 "Text<b>bold</b>more",
2231 angle_brackets_left.as_str(),
2232 angle_brackets_right.as_str(),
2233 repeated_tags.as_str(),
2234 ];
2235
2236 for input in test_cases {
2237 let result = strip_html_tags(input);
2238 assert!(result.len() <= input.len() + input.len()); }
2241 }
2242
2243 #[test]
2246 fn test_miri_finding_conversion_memory_safety() {
2247 let finding = Finding {
2248 cwe_id: "123".to_string(),
2249 display_text: "<b>Test</b>".to_string(),
2250 files: FindingFiles {
2251 source_file: SourceFile {
2252 file: "test.rs".to_string(),
2253 function_name: None,
2254 function_prototype: "fn test()".to_string(),
2255 line: 1,
2256 qualified_function_name: "test".to_string(),
2257 scope: "local".to_string(),
2258 },
2259 },
2260 flaw_details_link: None,
2261 gob: "A".to_string(),
2262 issue_id: 1,
2263 issue_type: "Test".to_string(),
2264 issue_type_id: "0".to_string(),
2265 severity: 3,
2266 stack_dumps: None,
2267 title: "Test".to_string(),
2268 };
2269
2270 let legacy = finding.to_legacy();
2272 assert_eq!(legacy.cwe_id, 123);
2273 assert_eq!(legacy.message, "Test");
2274 }
2275
2276 #[test]
2279 fn test_miri_scan_status_state_machine() {
2280 let statuses = vec![
2281 ScanStatus::Pending,
2282 ScanStatus::Uploading,
2283 ScanStatus::Started,
2284 ScanStatus::Success,
2285 ScanStatus::Failure,
2286 ScanStatus::Cancelled,
2287 ScanStatus::Timeout,
2288 ScanStatus::UserTimeout,
2289 ];
2290
2291 for status in statuses {
2292 let _ = status.is_successful();
2294 let _ = status.is_failed();
2295 let _ = status.is_in_progress();
2296 let _ = status.to_string();
2297
2298 let states = [
2300 status.is_successful(),
2301 status.is_failed(),
2302 status.is_in_progress(),
2303 ];
2304 let true_count = states.iter().filter(|&&x| x).count();
2305 assert_eq!(
2306 true_count, 1,
2307 "Status {:?} must be in exactly one state",
2308 status
2309 );
2310 }
2311 }
2312}