1use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::sync::OnceLock;
9use thiserror::Error;
10use url::Url;
11use urlencoding::encode;
12
13pub const MAX_APP_NAME_LEN: usize = 255;
15
16pub const MAX_DESCRIPTION_LEN: usize = 4096;
18
19pub const MAX_BUSINESS_UNIT_NAME_LEN: usize = 255;
21
22pub const MAX_TEAMS_COUNT: usize = 100;
24
25pub const MAX_CUSTOM_FIELDS_COUNT: usize = 50;
27
28pub const MAX_TAG_VALUE_LEN: usize = 128;
30
31pub const MAX_GUID_LEN: usize = 128;
33
34pub const MAX_SCAN_ID_LEN: usize = 128;
36
37pub const DEFAULT_PAGE_SIZE: u32 = 50;
39
40pub const MAX_PAGE_SIZE: u32 = 500;
42
43pub const MAX_PAGE_NUMBER: u32 = 10000;
45
46#[derive(Debug, Error)]
48#[must_use = "Need to handle all error enum types."]
49pub enum ValidationError {
50 #[error("Application GUID cannot be empty")]
51 EmptyGuid,
52
53 #[error("Application GUID too long: {actual} chars (max: {max})")]
54 GuidTooLong { actual: usize, max: usize },
55
56 #[error("Invalid GUID format: {0}")]
57 InvalidGuidFormat(String),
58
59 #[error("Invalid characters in GUID (possible path traversal)")]
60 InvalidCharactersInGuid,
61
62 #[error("Application name cannot be empty")]
63 EmptyApplicationName,
64
65 #[error("Application name too long: {actual} chars (max: {max})")]
66 ApplicationNameTooLong { actual: usize, max: usize },
67
68 #[error("Invalid characters in application name")]
69 InvalidCharactersInName,
70
71 #[error("Suspicious pattern in application name (possible path traversal)")]
72 SuspiciousNamePattern,
73
74 #[error("Description too long: {actual} chars (max: {max})")]
75 DescriptionTooLong { actual: usize, max: usize },
76
77 #[error("Description contains null byte")]
78 NullByteInDescription,
79
80 #[error("Too many teams: {actual} (max: {max})")]
81 TooManyTeams { actual: usize, max: usize },
82
83 #[error("Too many custom fields: {actual} (max: {max})")]
84 TooManyCustomFields { actual: usize, max: usize },
85
86 #[error("Invalid page size: {0} (must be 1-{MAX_PAGE_SIZE})")]
87 InvalidPageSize(u32),
88
89 #[error("Page size too large: {0} (max: {MAX_PAGE_SIZE})")]
90 PageSizeTooLarge(u32),
91
92 #[error("Page number too large: {0} (max: {MAX_PAGE_NUMBER})")]
93 PageNumberTooLarge(u32),
94
95 #[error("Empty URL segment not allowed")]
96 EmptySegment,
97
98 #[error("URL segment too long: {actual} chars (max: {max})")]
99 SegmentTooLong { actual: usize, max: usize },
100
101 #[error("Invalid path characters (possible path traversal)")]
102 InvalidPathCharacters,
103
104 #[error("Control characters not allowed")]
105 ControlCharactersNotAllowed,
106
107 #[error("Query encoding failed: {0}")]
108 QueryEncodingFailed(String),
109
110 #[error("Invalid URL: {0}")]
111 InvalidUrl(String),
112
113 #[error("URL must be from veracode.com, veracode.eu, or veracode.us domain, got: {0}")]
114 InvalidDomain(String),
115
116 #[error("Only HTTPS URLs are allowed, got scheme: {0}")]
117 InsecureScheme(String),
118
119 #[error("Scan ID cannot be empty")]
120 EmptyScanId,
121
122 #[error("Scan ID too long: {actual} chars (max: {max})")]
123 ScanIdTooLong { actual: usize, max: usize },
124
125 #[error("Invalid characters in scan ID (only alphanumeric, hyphens, and underscores allowed)")]
126 InvalidScanIdCharacters,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
131#[serde(transparent)]
132pub struct AppGuid(String);
133
134impl AppGuid {
135 const VALID_GUID_PATTERN: &'static str =
137 r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
138
139 fn uuid_regex() -> &'static regex::Regex {
144 static UUID_REGEX: OnceLock<regex::Regex> = OnceLock::new();
145 #[allow(clippy::expect_used)] UUID_REGEX.get_or_init(|| {
147 regex::Regex::new(Self::VALID_GUID_PATTERN)
148 .expect("VALID_GUID_PATTERN is a valid regex")
149 })
150 }
151
152 pub fn new(guid: impl Into<String>) -> Result<Self, ValidationError> {
164 let guid = guid.into();
165
166 if guid.is_empty() {
168 return Err(ValidationError::EmptyGuid);
169 }
170
171 if guid.len() > MAX_GUID_LEN {
173 return Err(ValidationError::GuidTooLong {
174 actual: guid.len(),
175 max: MAX_GUID_LEN,
176 });
177 }
178
179 if !Self::uuid_regex().is_match(&guid) {
181 return Err(ValidationError::InvalidGuidFormat(guid));
182 }
183
184 if guid.contains('/') || guid.contains('\\') || guid.contains("..") {
186 return Err(ValidationError::InvalidCharactersInGuid);
187 }
188
189 Ok(Self(guid))
190 }
191
192 #[must_use = "this method returns the inner value without modifying the type"]
194 pub fn as_str(&self) -> &str {
195 &self.0
196 }
197
198 #[must_use = "this method returns the inner value without modifying the type"]
200 pub fn as_url_safe(&self) -> &str {
201 &self.0
203 }
204}
205
206impl fmt::Display for AppGuid {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 write!(f, "{}", self.0)
209 }
210}
211
212impl AsRef<str> for AppGuid {
213 fn as_ref(&self) -> &str {
214 &self.0
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
220#[serde(transparent)]
221pub struct AppName(String);
222
223impl AppName {
224 pub fn new(name: impl Into<String>) -> Result<Self, ValidationError> {
234 let name = name.into();
235
236 let trimmed = name.trim();
238 if trimmed.is_empty() {
239 return Err(ValidationError::EmptyApplicationName);
240 }
241
242 if trimmed.len() > MAX_APP_NAME_LEN {
244 return Err(ValidationError::ApplicationNameTooLong {
245 actual: trimmed.len(),
246 max: MAX_APP_NAME_LEN,
247 });
248 }
249
250 if trimmed.chars().any(|c| c.is_control()) {
252 return Err(ValidationError::InvalidCharactersInName);
253 }
254
255 if trimmed.contains("..") || trimmed.contains('/') || trimmed.contains('\\') {
257 return Err(ValidationError::SuspiciousNamePattern);
258 }
259
260 Ok(Self(trimmed.to_string()))
261 }
262
263 #[must_use = "this method returns the inner value without modifying the type"]
265 pub fn as_str(&self) -> &str {
266 &self.0
267 }
268}
269
270impl fmt::Display for AppName {
271 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272 write!(f, "{}", self.0)
273 }
274}
275
276impl AsRef<str> for AppName {
277 fn as_ref(&self) -> &str {
278 &self.0
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
284#[serde(transparent)]
285pub struct Description(String);
286
287impl Description {
288 pub fn new(desc: impl Into<String>) -> Result<Self, ValidationError> {
294 let desc = desc.into();
295
296 if desc.len() > MAX_DESCRIPTION_LEN {
298 return Err(ValidationError::DescriptionTooLong {
299 actual: desc.len(),
300 max: MAX_DESCRIPTION_LEN,
301 });
302 }
303
304 if desc.contains('\0') {
306 return Err(ValidationError::NullByteInDescription);
307 }
308
309 Ok(Self(desc))
310 }
311
312 #[must_use = "this method returns the inner value without modifying the type"]
314 pub fn as_str(&self) -> &str {
315 &self.0
316 }
317}
318
319impl fmt::Display for Description {
320 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321 write!(f, "{}", self.0)
322 }
323}
324
325impl AsRef<str> for Description {
326 fn as_ref(&self) -> &str {
327 &self.0
328 }
329}
330
331pub fn validate_url_segment(segment: &str, max_len: usize) -> Result<&str, ValidationError> {
337 if segment.is_empty() {
338 return Err(ValidationError::EmptySegment);
339 }
340
341 if segment.len() > max_len {
342 return Err(ValidationError::SegmentTooLong {
343 actual: segment.len(),
344 max: max_len,
345 });
346 }
347
348 if segment.contains("..") || segment.contains('/') || segment.contains('\\') {
350 return Err(ValidationError::InvalidPathCharacters);
351 }
352
353 if segment.chars().any(|c| c.is_control()) {
355 return Err(ValidationError::ControlCharactersNotAllowed);
356 }
357
358 Ok(segment)
359}
360
361pub fn validate_page_size(size: Option<u32>) -> Result<u32, ValidationError> {
408 match size {
409 None => Ok(DEFAULT_PAGE_SIZE),
410 Some(0) => Err(ValidationError::InvalidPageSize(0)),
411 Some(s) if s > MAX_PAGE_SIZE => {
412 log::warn!(
413 "Page size {} exceeds maximum {}, capping to maximum",
414 s,
415 MAX_PAGE_SIZE
416 );
417 Ok(MAX_PAGE_SIZE)
418 }
419 Some(s) => Ok(s),
420 }
421}
422
423pub fn validate_page_number(page: Option<u32>) -> Result<Option<u32>, ValidationError> {
466 match page {
467 None => Ok(None),
468 Some(p) if p > MAX_PAGE_NUMBER => {
469 log::warn!(
470 "Page number {} exceeds maximum {}, capping to maximum",
471 p,
472 MAX_PAGE_NUMBER
473 );
474 Ok(Some(MAX_PAGE_NUMBER))
475 }
476 Some(p) => Ok(Some(p)),
477 }
478}
479
480#[must_use = "this function performs URL encoding and returns the encoded value"]
506pub fn encode_query_param(value: &str) -> String {
507 encode(value).into_owned()
508}
509
510#[must_use = "this function builds and returns a query parameter tuple"]
529pub fn build_query_param(key: &str, value: &str) -> (String, String) {
530 (key.to_string(), encode_query_param(value))
531}
532
533pub fn validate_veracode_url(url_str: &str) -> Result<(), ValidationError> {
585 let parsed_url = Url::parse(url_str)
587 .map_err(|e| ValidationError::InvalidUrl(format!("Failed to parse URL: {}", e)))?;
588
589 if parsed_url.scheme() != "https" {
591 return Err(ValidationError::InsecureScheme(
592 parsed_url.scheme().to_string(),
593 ));
594 }
595
596 let host = parsed_url
598 .host_str()
599 .ok_or_else(|| ValidationError::InvalidUrl("URL missing host".to_string()))?;
600
601 let is_allowed = host.ends_with(".veracode.com")
606 || host.ends_with(".veracode.eu")
607 || host.ends_with(".veracode.us")
608 || host == "api.veracode.com"
609 || host == "api.veracode.eu"
610 || host == "api.veracode.us"
611 || host == "analysiscenter.veracode.com"
612 || host == "analysiscenter.veracode.eu"
613 || host == "analysiscenter.veracode.us";
614
615 if !is_allowed {
616 return Err(ValidationError::InvalidDomain(host.to_string()));
617 }
618
619 Ok(())
620}
621
622pub fn validate_scan_id(scan_id: &str) -> Result<(), ValidationError> {
672 if scan_id.is_empty() {
674 return Err(ValidationError::EmptyScanId);
675 }
676
677 if scan_id.len() > MAX_SCAN_ID_LEN {
679 return Err(ValidationError::ScanIdTooLong {
680 actual: scan_id.len(),
681 max: MAX_SCAN_ID_LEN,
682 });
683 }
684
685 if !scan_id
688 .chars()
689 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
690 {
691 return Err(ValidationError::InvalidScanIdCharacters);
692 }
693
694 Ok(())
695}
696
697#[cfg(test)]
698#[allow(clippy::expect_used)]
699mod tests {
700 use super::*;
701
702 #[test]
703 fn test_app_guid_valid() {
704 let guid =
705 AppGuid::new("550e8400-e29b-41d4-a716-446655440000").expect("should create valid guid");
706 assert_eq!(guid.as_str(), "550e8400-e29b-41d4-a716-446655440000");
707 }
708
709 #[test]
710 fn test_app_guid_invalid_format() {
711 assert!(AppGuid::new("not-a-guid").is_err());
712 assert!(AppGuid::new("12345").is_err());
713 assert!(AppGuid::new("").is_err());
714 }
715
716 #[test]
717 fn test_app_guid_path_traversal() {
718 assert!(AppGuid::new("../etc/passwd").is_err());
719 assert!(AppGuid::new("550e8400/../test").is_err());
720 }
721
722 #[test]
723 fn test_app_name_valid() {
724 let name = AppName::new("My Application").expect("should create valid app name");
725 assert_eq!(name.as_str(), "My Application");
726 }
727
728 #[test]
729 fn test_app_name_trims_whitespace() {
730 let name = AppName::new(" Trimmed ").expect("should create valid app name");
731 assert_eq!(name.as_str(), "Trimmed");
732 }
733
734 #[test]
735 fn test_app_name_empty() {
736 assert!(AppName::new("").is_err());
737 assert!(AppName::new(" ").is_err());
738 }
739
740 #[test]
741 fn test_app_name_too_long() {
742 let long_name = "a".repeat(MAX_APP_NAME_LEN + 1);
743 assert!(AppName::new(long_name).is_err());
744 }
745
746 #[test]
747 fn test_app_name_path_traversal() {
748 assert!(AppName::new("../etc/passwd").is_err());
749 assert!(AppName::new("test/../admin").is_err());
750 assert!(AppName::new("test/admin").is_err());
751 }
752
753 #[test]
754 fn test_description_valid() {
755 let desc = Description::new("This is a valid description")
756 .expect("should create valid description");
757 assert_eq!(desc.as_str(), "This is a valid description");
758 }
759
760 #[test]
761 fn test_description_too_long() {
762 let long_desc = "a".repeat(MAX_DESCRIPTION_LEN + 1);
763 assert!(Description::new(long_desc).is_err());
764 }
765
766 #[test]
767 fn test_description_null_byte() {
768 assert!(Description::new("test\0null").is_err());
769 }
770
771 #[test]
772 fn test_validate_url_segment() {
773 assert!(validate_url_segment("valid-segment", 100).is_ok());
774 assert!(validate_url_segment("", 100).is_err());
775 assert!(validate_url_segment("../traversal", 100).is_err());
776 assert!(validate_url_segment("test/path", 100).is_err());
777 }
778
779 #[test]
780 fn test_validate_page_size_default() {
781 let result = validate_page_size(None).expect("should return default");
782 assert_eq!(result, DEFAULT_PAGE_SIZE);
783 }
784
785 #[test]
786 fn test_validate_page_size_valid() {
787 let result = validate_page_size(Some(100)).expect("should accept valid size");
788 assert_eq!(result, 100);
789
790 let result = validate_page_size(Some(MAX_PAGE_SIZE)).expect("should accept max size");
791 assert_eq!(result, MAX_PAGE_SIZE);
792 }
793
794 #[test]
795 fn test_validate_page_size_zero() {
796 let result = validate_page_size(Some(0));
797 assert!(result.is_err());
798 assert!(matches!(result, Err(ValidationError::InvalidPageSize(0))));
799 }
800
801 #[test]
802 fn test_validate_page_size_too_large() {
803 let result = validate_page_size(Some(1000)).expect("should cap to max");
804 assert_eq!(result, MAX_PAGE_SIZE);
805
806 let result = validate_page_size(Some(u32::MAX)).expect("should cap to max");
807 assert_eq!(result, MAX_PAGE_SIZE);
808 }
809
810 #[test]
811 fn test_validate_page_number_none() {
812 let result = validate_page_number(None).expect("should accept None");
813 assert_eq!(result, None);
814 }
815
816 #[test]
817 fn test_validate_page_number_valid() {
818 let result = validate_page_number(Some(10)).expect("should accept valid page");
819 assert_eq!(result, Some(10));
820
821 let result = validate_page_number(Some(MAX_PAGE_NUMBER)).expect("should accept max page");
822 assert_eq!(result, Some(MAX_PAGE_NUMBER));
823 }
824
825 #[test]
826 fn test_validate_page_number_too_large() {
827 let result = validate_page_number(Some(50000)).expect("should cap to max");
828 assert_eq!(result, Some(MAX_PAGE_NUMBER));
829
830 let result = validate_page_number(Some(u32::MAX)).expect("should cap to max");
831 assert_eq!(result, Some(MAX_PAGE_NUMBER));
832 }
833
834 #[test]
835 fn test_encode_query_param_normal() {
836 assert_eq!(encode_query_param("MyApp"), "MyApp");
837 assert_eq!(encode_query_param("test-app"), "test-app");
838 assert_eq!(encode_query_param("app_123"), "app_123");
839 }
840
841 #[test]
842 fn test_encode_query_param_injection_attempts() {
843 assert_eq!(encode_query_param("foo&admin=true"), "foo%26admin%3Dtrue");
845
846 assert_eq!(encode_query_param("key=value"), "key%3Dvalue");
848
849 assert_eq!(encode_query_param("test;command"), "test%3Bcommand");
851
852 assert_eq!(encode_query_param("50%off"), "50%25off");
854
855 assert_eq!(encode_query_param("My App"), "My%20App");
857
858 assert_eq!(
860 encode_query_param("foo&bar=baz;test%data"),
861 "foo%26bar%3Dbaz%3Btest%25data"
862 );
863 }
864
865 #[test]
866 fn test_encode_query_param_path_traversal() {
867 assert_eq!(encode_query_param("../etc/passwd"), "..%2Fetc%2Fpasswd");
868 assert_eq!(encode_query_param("..\\windows"), "..%5Cwindows");
869 }
870
871 #[test]
872 fn test_build_query_param() {
873 let param = build_query_param("name", "MyApp");
874 assert_eq!(param.0, "name");
875 assert_eq!(param.1, "MyApp");
876
877 let param = build_query_param("name", "My App & Co");
878 assert_eq!(param.0, "name");
879 assert_eq!(param.1, "My%20App%20%26%20Co");
880
881 let param = build_query_param("filter", "status=active");
882 assert_eq!(param.0, "filter");
883 assert_eq!(param.1, "status%3Dactive");
884 }
885
886 #[test]
887 fn test_validate_veracode_url_commercial() {
888 assert!(validate_veracode_url("https://api.veracode.com/appsec/v1/applications").is_ok());
889 assert!(
890 validate_veracode_url("https://analysiscenter.veracode.com/api/5.0/getapplist.do")
891 .is_ok()
892 );
893 }
894
895 #[test]
896 fn test_validate_veracode_url_european() {
897 assert!(validate_veracode_url("https://api.veracode.eu/appsec/v1/applications").is_ok());
898 assert!(
899 validate_veracode_url("https://analysiscenter.veracode.eu/api/5.0/getapplist.do")
900 .is_ok()
901 );
902 }
903
904 #[test]
905 fn test_validate_veracode_url_federal() {
906 assert!(validate_veracode_url("https://api.veracode.us/appsec/v1/applications").is_ok());
907 assert!(
908 validate_veracode_url("https://analysiscenter.veracode.us/api/5.0/getapplist.do")
909 .is_ok()
910 );
911 }
912
913 #[test]
914 fn test_validate_veracode_url_subdomain() {
915 assert!(validate_veracode_url("https://pipeline.veracode.com/v1/scan").is_ok());
917 assert!(validate_veracode_url("https://results.veracode.eu/report").is_ok());
918 }
919
920 #[test]
921 fn test_validate_veracode_url_reject_http() {
922 let result = validate_veracode_url("http://api.veracode.com/test");
923 assert!(result.is_err());
924 assert!(matches!(result, Err(ValidationError::InsecureScheme(_))));
925 }
926
927 #[test]
928 fn test_validate_veracode_url_reject_wrong_domain() {
929 let result = validate_veracode_url("https://evil.com/test");
931 assert!(result.is_err());
932 assert!(matches!(result, Err(ValidationError::InvalidDomain(_))));
933
934 let result = validate_veracode_url("https://localhost:8080/admin");
936 assert!(result.is_err());
937 assert!(matches!(result, Err(ValidationError::InvalidDomain(_))));
938
939 let result = validate_veracode_url("https://192.168.1.1/admin");
941 assert!(result.is_err());
942 assert!(matches!(result, Err(ValidationError::InvalidDomain(_))));
943
944 let result = validate_veracode_url("https://169.254.169.254/latest/meta-data/");
946 assert!(result.is_err());
947 assert!(matches!(result, Err(ValidationError::InvalidDomain(_))));
948 }
949
950 #[test]
951 fn test_validate_veracode_url_reject_similar_domain() {
952 let result = validate_veracode_url("https://api.veracode.com.evil.com/test");
954 assert!(result.is_err());
955 assert!(matches!(result, Err(ValidationError::InvalidDomain(_))));
956
957 let result = validate_veracode_url("https://api.veracode.org/test");
959 assert!(result.is_err());
960 assert!(matches!(result, Err(ValidationError::InvalidDomain(_))));
961 }
962
963 #[test]
964 fn test_validate_veracode_url_invalid_format() {
965 let result = validate_veracode_url("not-a-url");
966 assert!(result.is_err());
967 assert!(matches!(result, Err(ValidationError::InvalidUrl(_))));
968
969 let result = validate_veracode_url("");
970 assert!(result.is_err());
971 assert!(matches!(result, Err(ValidationError::InvalidUrl(_))));
972 }
973
974 #[test]
975 fn test_validate_scan_id_valid() {
976 assert!(validate_scan_id("abc123").is_ok());
977 assert!(validate_scan_id("scan-id-123").is_ok());
978 assert!(validate_scan_id("SCAN_ID_456").is_ok());
979 assert!(validate_scan_id("a1b2c3-d4e5-f6").is_ok());
980 assert!(validate_scan_id("123456789").is_ok());
981 assert!(validate_scan_id("test_scan_123").is_ok());
982 }
983
984 #[test]
985 fn test_validate_scan_id_empty() {
986 let result = validate_scan_id("");
987 assert!(result.is_err());
988 assert!(matches!(result, Err(ValidationError::EmptyScanId)));
989 }
990
991 #[test]
992 fn test_validate_scan_id_too_long() {
993 let long_id = "a".repeat(MAX_SCAN_ID_LEN + 1);
994 let result = validate_scan_id(&long_id);
995 assert!(result.is_err());
996 assert!(matches!(result, Err(ValidationError::ScanIdTooLong { .. })));
997 }
998
999 #[test]
1000 fn test_validate_scan_id_path_traversal() {
1001 let result = validate_scan_id("../admin");
1003 assert!(result.is_err());
1004 assert!(matches!(
1005 result,
1006 Err(ValidationError::InvalidScanIdCharacters)
1007 ));
1008
1009 let result = validate_scan_id("scan/../other");
1010 assert!(result.is_err());
1011 assert!(matches!(
1012 result,
1013 Err(ValidationError::InvalidScanIdCharacters)
1014 ));
1015
1016 let result = validate_scan_id("scan/path");
1018 assert!(result.is_err());
1019 assert!(matches!(
1020 result,
1021 Err(ValidationError::InvalidScanIdCharacters)
1022 ));
1023
1024 let result = validate_scan_id("scan\\path");
1026 assert!(result.is_err());
1027 assert!(matches!(
1028 result,
1029 Err(ValidationError::InvalidScanIdCharacters)
1030 ));
1031 }
1032
1033 #[test]
1034 fn test_validate_scan_id_injection_attempts() {
1035 let result = validate_scan_id("scan?admin=true");
1037 assert!(result.is_err());
1038 assert!(matches!(
1039 result,
1040 Err(ValidationError::InvalidScanIdCharacters)
1041 ));
1042
1043 let result = validate_scan_id("scan¶m=value");
1045 assert!(result.is_err());
1046 assert!(matches!(
1047 result,
1048 Err(ValidationError::InvalidScanIdCharacters)
1049 ));
1050
1051 let result = validate_scan_id("scan=admin");
1053 assert!(result.is_err());
1054 assert!(matches!(
1055 result,
1056 Err(ValidationError::InvalidScanIdCharacters)
1057 ));
1058
1059 let result = validate_scan_id("scan;drop table");
1061 assert!(result.is_err());
1062 assert!(matches!(
1063 result,
1064 Err(ValidationError::InvalidScanIdCharacters)
1065 ));
1066
1067 let result = validate_scan_id("scan id");
1069 assert!(result.is_err());
1070 assert!(matches!(
1071 result,
1072 Err(ValidationError::InvalidScanIdCharacters)
1073 ));
1074
1075 let result = validate_scan_id("scan@host");
1077 assert!(result.is_err());
1078 assert!(matches!(
1079 result,
1080 Err(ValidationError::InvalidScanIdCharacters)
1081 ));
1082
1083 let result = validate_scan_id("scan#fragment");
1084 assert!(result.is_err());
1085 assert!(matches!(
1086 result,
1087 Err(ValidationError::InvalidScanIdCharacters)
1088 ));
1089 }
1090
1091 #[test]
1092 fn test_validate_scan_id_max_length() {
1093 let max_id = "a".repeat(MAX_SCAN_ID_LEN);
1095 assert!(validate_scan_id(&max_id).is_ok());
1096 }
1097}
1098
1099#[cfg(test)]
1101mod proptest_security {
1102 use super::*;
1103 use proptest::prelude::*;
1104
1105 fn valid_uuid_strategy() -> impl Strategy<Value = String> {
1107 "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
1109 }
1110
1111 fn path_traversal_strategy() -> impl Strategy<Value = String> {
1113 prop_oneof![
1114 Just("../".to_string()),
1115 Just("..\\".to_string()),
1116 Just("../../".to_string()),
1117 Just("..\\..\\".to_string()),
1118 Just("/etc/passwd".to_string()),
1119 Just("\\windows\\system32".to_string()),
1120 Just("....//".to_string()),
1121 Just("..;/".to_string()),
1122 ]
1123 }
1124
1125 fn injection_strategy() -> impl Strategy<Value = String> {
1127 prop_oneof![
1128 Just("'; DROP TABLE users--".to_string()),
1129 Just("<script>alert('xss')</script>".to_string()),
1130 Just("${jndi:ldap://evil.com/a}".to_string()),
1131 Just("{{7*7}}".to_string()),
1132 Just("%0a%0d".to_string()),
1133 Just("\0null\0byte".to_string()),
1134 Just("admin' OR '1'='1".to_string()),
1135 Just("&admin=true".to_string()),
1136 Just("?param=value".to_string()),
1137 ]
1138 }
1139
1140 proptest! {
1141 #![proptest_config(ProptestConfig {
1142 cases: if cfg!(miri) { 5 } else { 1000 },
1143 failure_persistence: None,
1144 .. ProptestConfig::default()
1145 })]
1146
1147 #[test]
1149 fn prop_valid_uuids_accepted(uuid in valid_uuid_strategy()) {
1150 let result = AppGuid::new(&uuid);
1151 prop_assert!(result.is_ok(), "Valid UUID should be accepted: {}", uuid);
1152 }
1153
1154 #[test]
1156 fn prop_guid_rejects_path_traversal(
1157 traversal in path_traversal_strategy(),
1158 valid_uuid in valid_uuid_strategy()
1159 ) {
1160 let combined = format!("{}{}", valid_uuid, traversal);
1162 prop_assert!(AppGuid::new(&combined).is_err());
1163
1164 let result = AppGuid::new(&traversal);
1166 prop_assert!(result.is_err(), "Path traversal should be rejected: {}", traversal);
1167 }
1168
1169 #[test]
1171 fn prop_guid_rejects_empty(whitespace in r"\s*") {
1172 prop_assert!(AppGuid::new(whitespace).is_err());
1173 }
1174
1175 #[test]
1177 fn prop_guid_rejects_oversized(extra_chars in 1..=100usize) {
1178 let long_string = "a".repeat(MAX_GUID_LEN.saturating_add(extra_chars));
1179 prop_assert!(AppGuid::new(long_string).is_err());
1180 }
1181
1182 #[test]
1184 fn prop_appname_trims_whitespace(
1185 name in "[a-zA-Z0-9 ]{1,100}",
1186 leading in r"\s{0,10}",
1187 trailing in r"\s{0,10}"
1188 ) {
1189 let input = format!("{}{}{}", leading, name, trailing);
1190 if let Ok(app_name) = AppName::new(&input) {
1191 let trimmed = name.trim();
1192 prop_assert_eq!(app_name.as_str(), trimmed);
1193 prop_assert!(!app_name.as_str().starts_with(' '));
1194 prop_assert!(!app_name.as_str().ends_with(' '));
1195 }
1196 }
1197
1198 #[test]
1200 fn prop_appname_rejects_path_traversal(traversal in path_traversal_strategy()) {
1201 prop_assert!(AppName::new(traversal).is_err());
1202 }
1203
1204 #[test]
1206 fn prop_appname_rejects_control_chars(
1207 prefix in "[a-zA-Z]{1,10}",
1208 suffix in "[a-zA-Z]{1,10}",
1209 control_char in 0x00u8..0x20u8
1210 ) {
1211 let input = format!("{}{}{}", prefix, char::from(control_char), suffix);
1213 let trimmed = input.trim();
1214
1215 if trimmed.chars().any(|c| c.is_control()) {
1217 prop_assert!(AppName::new(&input).is_err());
1218 }
1219 }
1220
1221 #[test]
1223 fn prop_appname_enforces_length(extra in 1..=100usize) {
1224 let too_long = "a".repeat(MAX_APP_NAME_LEN.saturating_add(extra));
1225 prop_assert!(AppName::new(too_long).is_err());
1226 }
1227
1228 #[test]
1230 fn prop_description_rejects_null_bytes(
1231 prefix in "[a-zA-Z0-9 ]{0,100}",
1232 suffix in "[a-zA-Z0-9 ]{0,100}"
1233 ) {
1234 let with_null = format!("{}\0{}", prefix, suffix);
1235 prop_assert!(Description::new(with_null).is_err());
1236 }
1237
1238 #[test]
1240 fn prop_description_enforces_length(extra in 1..=1000usize) {
1241 let too_long = "a".repeat(MAX_DESCRIPTION_LEN.saturating_add(extra));
1242 prop_assert!(Description::new(too_long).is_err());
1243 }
1244
1245 #[test]
1247 fn prop_url_segment_rejects_traversal(traversal in path_traversal_strategy()) {
1248 prop_assert!(validate_url_segment(&traversal, 1000).is_err());
1249 }
1250
1251 #[test]
1253 fn prop_url_segment_rejects_control_chars(
1254 prefix in "[a-zA-Z]{1,10}",
1255 control_char in 0x00u8..0x20u8
1256 ) {
1257 let input = format!("{}{}", prefix, char::from(control_char));
1258 prop_assert!(validate_url_segment(&input, 1000).is_err());
1259 }
1260
1261 #[test]
1263 fn prop_url_segment_enforces_max_len(
1264 segment in "[a-zA-Z0-9_-]{50,100}",
1265 max_len in 1..50usize
1266 ) {
1267 if segment.len() > max_len {
1268 prop_assert!(validate_url_segment(&segment, max_len).is_err());
1269 }
1270 }
1271
1272 #[test]
1274 fn prop_page_size_default_on_none(_unit in prop::bool::ANY) {
1275 prop_assert_eq!(validate_page_size(None).expect("Should return default page size"), DEFAULT_PAGE_SIZE);
1276 }
1277
1278 #[test]
1280 fn prop_page_size_rejects_zero(_unit in prop::bool::ANY) {
1281 prop_assert!(validate_page_size(Some(0)).is_err());
1282 }
1283
1284 #[test]
1286 fn prop_page_size_caps_at_max(size in (MAX_PAGE_SIZE + 1)..=u32::MAX) {
1287 let result = validate_page_size(Some(size)).expect("Should cap at max page size");
1288 prop_assert_eq!(result, MAX_PAGE_SIZE);
1289 prop_assert!(result <= MAX_PAGE_SIZE);
1290 }
1291
1292 #[test]
1294 fn prop_page_size_accepts_valid(size in 1..=MAX_PAGE_SIZE) {
1295 let result = validate_page_size(Some(size)).expect("Valid page size should be accepted");
1296 prop_assert_eq!(result, size);
1297 }
1298
1299 #[test]
1301 fn prop_page_number_none_on_none(_unit in prop::bool::ANY) {
1302 prop_assert_eq!(validate_page_number(None).expect("Should return None for None input"), None);
1303 }
1304
1305 #[test]
1307 fn prop_page_number_caps_at_max(page in (MAX_PAGE_NUMBER + 1)..=u32::MAX) {
1308 let result = validate_page_number(Some(page)).expect("Should cap at max page number");
1309 prop_assert_eq!(result, Some(MAX_PAGE_NUMBER));
1310 }
1311
1312 #[test]
1314 fn prop_page_number_accepts_valid(page in 0..=MAX_PAGE_NUMBER) {
1315 let result = validate_page_number(Some(page)).expect("Valid page number should be accepted");
1316 prop_assert_eq!(result, Some(page));
1317 }
1318
1319 #[test]
1321 fn prop_encode_neutralizes_injection(value in ".*") {
1322 let encoded = encode_query_param(&value);
1323
1324 if value.contains('&') {
1326 prop_assert!(encoded.contains("%26"), "& should be encoded to %26");
1327 }
1328 if value.contains('=') {
1329 prop_assert!(encoded.contains("%3D"), "= should be encoded to %3D");
1330 }
1331 if value.contains(';') {
1332 prop_assert!(encoded.contains("%3B"), "; should be encoded to %3B");
1333 }
1334 if value.contains('?') {
1335 prop_assert!(encoded.contains("%3F"), "? should be encoded to %3F");
1336 }
1337 }
1338
1339 #[test]
1341 fn prop_encode_is_idempotent(value in ".*") {
1342 let encoded_once = encode_query_param(&value);
1343 let encoded_twice = encode_query_param(&encoded_once);
1344 prop_assert!(encoded_twice.contains("%25") || encoded_once == encoded_twice);
1346 }
1347
1348 #[test]
1350 fn prop_build_query_param_encodes(
1351 key in "[a-zA-Z_][a-zA-Z0-9_]{0,20}",
1352 value in ".*"
1353 ) {
1354 let (result_key, result_value) = build_query_param(&key, &value);
1355 prop_assert_eq!(result_key, key);
1356 prop_assert_eq!(result_value, encode_query_param(&value));
1357 }
1358
1359 #[test]
1361 fn prop_veracode_url_rejects_http(
1362 subdomain in "[a-z]{3,10}",
1363 tld in prop::sample::select(vec!["com", "eu", "us"])
1364 ) {
1365 let url = format!("http://{}.veracode.{}/path", subdomain, tld);
1366 prop_assert!(validate_veracode_url(&url).is_err());
1367 }
1368
1369 #[test]
1371 fn prop_veracode_url_rejects_wrong_domain(
1372 domain in "[a-z]{5,15}",
1373 tld in "[a-z]{2,3}"
1374 ) {
1375 prop_assume!(domain != "veracode");
1377
1378 let url = format!("https://{}.{}/path", domain, tld);
1379 prop_assert!(validate_veracode_url(&url).is_err());
1380 }
1381
1382 #[test]
1384 fn prop_veracode_url_accepts_valid(
1385 subdomain in "[a-z]{3,10}",
1386 tld in prop::sample::select(vec!["com", "eu", "us"]),
1387 path in "[a-z0-9/_-]{0,50}"
1388 ) {
1389 let url = format!("https://{}.veracode.{}/{}", subdomain, tld, path);
1390 prop_assert!(validate_veracode_url(&url).is_ok());
1391 }
1392
1393 #[test]
1395 fn prop_veracode_url_blocks_localhost(
1396 port in 1..=65535u16,
1397 path in "[a-z0-9/_-]{0,20}"
1398 ) {
1399 let url = format!("https://localhost:{}/{}", port, path);
1400 prop_assert!(validate_veracode_url(&url).is_err());
1401 }
1402
1403 #[test]
1405 fn prop_veracode_url_blocks_ip_addresses(
1406 a in 0..=255u8,
1407 b in 0..=255u8,
1408 c in 0..=255u8,
1409 d in 0..=255u8
1410 ) {
1411 let url = format!("https://{}.{}.{}.{}/path", a, b, c, d);
1412 prop_assert!(validate_veracode_url(&url).is_err());
1413 }
1414
1415 #[test]
1417 fn prop_scan_id_rejects_empty(_unit in prop::bool::ANY) {
1418 prop_assert!(validate_scan_id("").is_err());
1419 }
1420
1421 #[test]
1423 fn prop_scan_id_enforces_length(extra in 1..=100usize) {
1424 let too_long = "a".repeat(MAX_SCAN_ID_LEN.saturating_add(extra));
1425 prop_assert!(validate_scan_id(&too_long).is_err());
1426 }
1427
1428 #[test]
1430 fn prop_scan_id_rejects_traversal(traversal in path_traversal_strategy()) {
1431 prop_assert!(validate_scan_id(&traversal).is_err());
1432 }
1433
1434 #[test]
1436 fn prop_scan_id_accepts_valid_chars(
1437 scan_id in "[a-zA-Z0-9_-]{1,128}"
1438 ) {
1439 prop_assert!(validate_scan_id(&scan_id).is_ok());
1440 }
1441
1442 #[test]
1444 fn prop_scan_id_rejects_special_chars(
1445 special_char in prop::sample::select(vec!['?', '&', '=', ';', '/', '\\', '.', ' ', '@', '#', '%'])
1446 ) {
1447 let invalid_id = format!("scan{}id", special_char);
1448 prop_assert!(validate_scan_id(&invalid_id).is_err());
1449 }
1450
1451 #[test]
1453 fn prop_scan_id_rejects_injection(injection in injection_strategy()) {
1454 prop_assert!(validate_scan_id(&injection).is_err());
1455 }
1456 }
1457}
1458
1459#[cfg(kani)]
1461mod kani_proofs {
1462 use super::*;
1463
1464 #[kani::proof]
1480 fn verify_page_size_caps_at_maximum() {
1481 let size: u32 = kani::any();
1482
1483 let result = validate_page_size(Some(size));
1484
1485 if let Ok(validated_size) = result {
1487 assert!(
1488 validated_size <= MAX_PAGE_SIZE,
1489 "Page size must be capped at maximum"
1490 );
1491 }
1492 }
1493
1494 #[kani::proof]
1499 fn verify_page_size_rejects_zero() {
1500 let result = validate_page_size(Some(0));
1501 assert!(result.is_err(), "Zero page size must be rejected");
1502 }
1503
1504 #[kani::proof]
1509 fn verify_page_number_caps_at_maximum() {
1510 let page: u32 = kani::any();
1511
1512 let result = validate_page_number(Some(page));
1513
1514 if let Ok(Some(validated_page)) = result {
1516 assert!(
1517 validated_page <= MAX_PAGE_NUMBER,
1518 "Page number must be capped at maximum"
1519 );
1520 }
1521 }
1522}