1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9
10use crate::VeracodeError;
11use crate::client::VeracodeClient;
12use crate::validation::{
13 AppGuid, AppName, Description, ValidationError, build_query_param, validate_page_number,
14 validate_page_size,
15};
16
17#[derive(Debug, Serialize, Deserialize, Clone)]
22pub struct Application {
23 pub guid: String,
25 pub id: u64,
27 pub oid: Option<u64>,
29 pub alt_org_id: Option<u64>,
31 pub organization_id: Option<u64>,
33 pub created: String,
35 pub modified: Option<String>,
37 pub last_completed_scan_date: Option<String>,
39 pub last_policy_compliance_check_date: Option<String>,
41 pub app_profile_url: Option<String>,
43 pub profile: Option<Profile>,
45 pub scans: Option<Vec<Scan>>,
47 pub results_url: Option<String>,
49}
50
51#[derive(Debug, Serialize, Deserialize, Clone)]
58pub struct Profile {
59 pub name: AppName,
61 pub description: Option<Description>,
63 pub tags: Option<String>,
65 pub business_unit: Option<BusinessUnit>,
67 pub business_owners: Option<Vec<BusinessOwner>>,
69 pub policies: Option<Vec<Policy>>,
71 pub teams: Option<Vec<Team>>,
73 pub archer_app_name: Option<String>,
75 pub custom_fields: Option<Vec<CustomField>>,
77 #[serde(serialize_with = "serialize_business_criticality")]
79 pub business_criticality: BusinessCriticality,
80 pub settings: Option<Settings>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub custom_kms_alias: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub repo_url: Option<String>,
88}
89
90#[derive(Debug, Serialize, Deserialize, Clone)]
91pub struct Settings {
92 pub nextday_consultation_allowed: bool,
94 pub static_scan_xpa_or_dpa: bool,
96 pub dynamic_scan_approval_not_required: bool,
98 pub sca_enabled: bool,
100 pub static_scan_xpp_enabled: bool,
102}
103
104#[derive(Debug, Serialize, Deserialize, Clone)]
106pub struct BusinessUnit {
107 pub id: Option<u64>,
109 pub name: Option<String>,
111 pub guid: Option<String>,
113}
114
115#[derive(Serialize, Deserialize, Clone)]
122pub struct BusinessOwner {
123 pub email: Option<String>,
125 pub name: Option<String>,
127}
128
129impl fmt::Debug for BusinessOwner {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 f.debug_struct("BusinessOwner")
132 .field("email", &"[REDACTED]")
133 .field("name", &"[REDACTED]")
134 .finish()
135 }
136}
137
138#[derive(Debug, Serialize, Deserialize, Clone)]
140pub struct Policy {
141 pub guid: String,
143 pub name: String,
145 pub is_default: bool,
147 pub policy_compliance_status: Option<String>,
149}
150
151#[derive(Debug, Serialize, Deserialize, Clone)]
153pub struct Team {
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub guid: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub team_id: Option<u64>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub team_name: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub team_legacy_id: Option<u64>,
166}
167
168#[derive(Serialize, Deserialize, Clone)]
176pub struct CustomField {
177 pub name: Option<String>,
179 pub value: Option<String>,
181}
182
183impl fmt::Debug for CustomField {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185 f.debug_struct("CustomField")
186 .field("name", &self.name)
187 .field("value", &"[REDACTED]")
188 .finish()
189 }
190}
191
192#[derive(Debug, Serialize, Deserialize, Clone)]
194pub struct Scan {
195 pub scan_id: Option<u64>,
197 pub scan_type: Option<String>,
199 pub status: Option<String>,
201 pub scan_url: Option<String>,
203 pub modified_date: Option<String>,
205 pub internal_status: Option<String>,
207 pub links: Option<Vec<Link>>,
209 pub fallback_type: Option<String>,
211 pub full_type: Option<String>,
213}
214
215#[derive(Debug, Serialize, Deserialize, Clone)]
217pub struct Link {
218 pub rel: Option<String>,
220 pub href: Option<String>,
222}
223
224#[derive(Debug, Serialize, Deserialize, Clone)]
226pub struct ApplicationsResponse {
227 #[serde(rename = "_embedded")]
229 pub embedded: Option<EmbeddedApplications>,
230 pub page: Option<PageInfo>,
232 #[serde(rename = "_links")]
234 pub links: Option<HashMap<String, Link>>,
235}
236
237#[derive(Debug, Serialize, Deserialize, Clone)]
239pub struct EmbeddedApplications {
240 pub applications: Vec<Application>,
242}
243
244#[derive(Debug, Serialize, Deserialize, Clone)]
246pub struct PageInfo {
247 pub size: Option<u32>,
249 pub number: Option<u32>,
251 pub total_elements: Option<u64>,
253 pub total_pages: Option<u32>,
255}
256
257#[derive(Debug, Serialize, Deserialize, Clone)]
259pub struct CreateApplicationRequest {
260 pub profile: CreateApplicationProfile,
262}
263
264#[derive(Debug, Serialize, Deserialize, Clone)]
271pub struct CreateApplicationProfile {
272 pub name: AppName,
274 #[serde(serialize_with = "serialize_business_criticality")]
276 pub business_criticality: BusinessCriticality,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub description: Option<Description>,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub business_unit: Option<BusinessUnit>,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub business_owners: Option<Vec<BusinessOwner>>,
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub policies: Option<Vec<Policy>>,
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub teams: Option<Vec<Team>>,
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub tags: Option<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub custom_fields: Option<Vec<CustomField>>,
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub custom_kms_alias: Option<String>,
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub repo_url: Option<String>,
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum BusinessCriticality {
309 VeryHigh,
310 High,
311 Medium,
312 Low,
313 VeryLow,
314}
315
316impl BusinessCriticality {
317 #[must_use]
319 pub fn as_str(&self) -> &'static str {
320 match self {
321 BusinessCriticality::VeryHigh => "VERY_HIGH",
322 BusinessCriticality::High => "HIGH",
323 BusinessCriticality::Medium => "MEDIUM",
324 BusinessCriticality::Low => "LOW",
325 BusinessCriticality::VeryLow => "VERY_LOW",
326 }
327 }
328}
329
330impl From<BusinessCriticality> for String {
331 fn from(criticality: BusinessCriticality) -> Self {
332 criticality.as_str().to_string()
333 }
334}
335
336impl std::fmt::Display for BusinessCriticality {
337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338 write!(f, "{}", self.as_str())
339 }
340}
341
342fn serialize_business_criticality<S>(
344 criticality: &BusinessCriticality,
345 serializer: S,
346) -> Result<S::Ok, S::Error>
347where
348 S: serde::Serializer,
349{
350 serializer.serialize_str(criticality.as_str())
351}
352
353impl std::str::FromStr for BusinessCriticality {
355 type Err = String;
356
357 fn from_str(s: &str) -> Result<Self, Self::Err> {
358 match s {
359 "VERY_HIGH" => Ok(BusinessCriticality::VeryHigh),
360 "HIGH" => Ok(BusinessCriticality::High),
361 "MEDIUM" => Ok(BusinessCriticality::Medium),
362 "LOW" => Ok(BusinessCriticality::Low),
363 "VERY_LOW" => Ok(BusinessCriticality::VeryLow),
364 _ => Err(format!(
365 "Invalid business criticality: '{s}'. Must be one of: VERY_HIGH, HIGH, MEDIUM, LOW, VERY_LOW"
366 )),
367 }
368 }
369}
370
371impl<'de> serde::Deserialize<'de> for BusinessCriticality {
373 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
374 where
375 D: serde::Deserializer<'de>,
376 {
377 let s = String::deserialize(deserializer)?;
378 s.parse().map_err(serde::de::Error::custom)
379 }
380}
381
382#[derive(Debug, Serialize, Deserialize, Clone)]
384pub struct UpdateApplicationRequest {
385 pub profile: UpdateApplicationProfile,
387}
388
389#[derive(Debug, Serialize, Deserialize, Clone)]
396pub struct UpdateApplicationProfile {
397 pub name: Option<AppName>,
399 pub description: Option<Description>,
401 pub business_unit: Option<BusinessUnit>,
403 pub business_owners: Option<Vec<BusinessOwner>>,
405 #[serde(serialize_with = "serialize_business_criticality")]
407 pub business_criticality: BusinessCriticality,
408 pub policies: Option<Vec<Policy>>,
410 pub teams: Option<Vec<Team>>,
412 pub tags: Option<String>,
414 pub custom_fields: Option<Vec<CustomField>>,
416 #[serde(skip_serializing_if = "Option::is_none")]
418 pub custom_kms_alias: Option<String>,
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub repo_url: Option<String>,
422}
423
424#[derive(Debug, Clone, Default)]
426pub struct ApplicationQuery {
427 pub name: Option<String>,
429 pub policy_compliance: Option<String>,
431 pub modified_after: Option<String>,
433 pub modified_before: Option<String>,
435 pub created_after: Option<String>,
437 pub created_before: Option<String>,
439 pub scan_type: Option<String>,
441 pub tags: Option<String>,
443 pub business_unit: Option<String>,
445 pub page: Option<u32>,
447 pub size: Option<u32>,
449}
450
451impl ApplicationQuery {
452 #[must_use = "builder methods consume self and return modified Self"]
454 pub fn new() -> Self {
455 ApplicationQuery::default()
456 }
457
458 #[must_use = "builder methods consume self and return modified Self"]
460 pub fn with_name(mut self, name: &str) -> Self {
461 self.name = Some(name.to_string());
462 self
463 }
464
465 #[must_use = "builder methods consume self and return modified Self"]
467 pub fn with_policy_compliance(mut self, compliance: &str) -> Self {
468 self.policy_compliance = Some(compliance.to_string());
469 self
470 }
471
472 #[must_use = "builder methods consume self and return modified Self"]
474 pub fn with_modified_after(mut self, date: &str) -> Self {
475 self.modified_after = Some(date.to_string());
476 self
477 }
478
479 #[must_use = "builder methods consume self and return modified Self"]
481 pub fn with_modified_before(mut self, date: &str) -> Self {
482 self.modified_before = Some(date.to_string());
483 self
484 }
485
486 #[must_use]
488 pub fn with_page(mut self, page: u32) -> Self {
489 self.page = Some(page);
490 self
491 }
492
493 #[must_use]
495 pub fn with_size(mut self, size: u32) -> Self {
496 self.size = Some(size);
497 self
498 }
499
500 pub fn normalize(mut self) -> Result<Self, ValidationError> {
525 self.size = Some(validate_page_size(self.size)?);
527 self.page = validate_page_number(self.page)?;
528
529 Ok(self)
530 }
531
532 #[must_use]
534 pub fn to_query_params(&self) -> Vec<(String, String)> {
535 Vec::from(self)
536 }
537}
538
539impl From<&ApplicationQuery> for Vec<(String, String)> {
545 fn from(query: &ApplicationQuery) -> Self {
546 let mut params = Vec::new();
547
548 if let Some(ref name) = query.name {
549 params.push(build_query_param("name", name));
550 }
551 if let Some(ref compliance) = query.policy_compliance {
552 params.push(build_query_param("policy_compliance", compliance));
553 }
554 if let Some(ref date) = query.modified_after {
555 params.push(build_query_param("modified_after", date));
556 }
557 if let Some(ref date) = query.modified_before {
558 params.push(build_query_param("modified_before", date));
559 }
560 if let Some(ref date) = query.created_after {
561 params.push(build_query_param("created_after", date));
562 }
563 if let Some(ref date) = query.created_before {
564 params.push(build_query_param("created_before", date));
565 }
566 if let Some(ref scan_type) = query.scan_type {
567 params.push(build_query_param("scan_type", scan_type));
568 }
569 if let Some(ref tags) = query.tags {
570 params.push(build_query_param("tags", tags));
571 }
572 if let Some(ref business_unit) = query.business_unit {
573 params.push(build_query_param("business_unit", business_unit));
574 }
575 if let Some(page) = query.page {
576 params.push(("page".to_string(), page.to_string()));
577 }
578 if let Some(size) = query.size {
579 params.push(("size".to_string(), size.to_string()));
580 }
581
582 params
583 }
584}
585
586impl From<ApplicationQuery> for Vec<(String, String)> {
592 fn from(query: ApplicationQuery) -> Self {
593 let mut params = Vec::new();
594
595 if let Some(name) = query.name {
596 params.push(build_query_param("name", &name));
597 }
598 if let Some(compliance) = query.policy_compliance {
599 params.push(build_query_param("policy_compliance", &compliance));
600 }
601 if let Some(date) = query.modified_after {
602 params.push(build_query_param("modified_after", &date));
603 }
604 if let Some(date) = query.modified_before {
605 params.push(build_query_param("modified_before", &date));
606 }
607 if let Some(date) = query.created_after {
608 params.push(build_query_param("created_after", &date));
609 }
610 if let Some(date) = query.created_before {
611 params.push(build_query_param("created_before", &date));
612 }
613 if let Some(scan_type) = query.scan_type {
614 params.push(build_query_param("scan_type", &scan_type));
615 }
616 if let Some(tags) = query.tags {
617 params.push(build_query_param("tags", &tags));
618 }
619 if let Some(business_unit) = query.business_unit {
620 params.push(build_query_param("business_unit", &business_unit));
621 }
622 if let Some(page) = query.page {
623 params.push(("page".to_string(), page.to_string()));
624 }
625 if let Some(size) = query.size {
626 params.push(("size".to_string(), size.to_string()));
627 }
628
629 params
630 }
631}
632
633impl VeracodeClient {
635 pub async fn get_applications(
655 &self,
656 query: Option<ApplicationQuery>,
657 ) -> Result<ApplicationsResponse, VeracodeError> {
658 let endpoint = "/appsec/v1/applications";
659
660 let normalized_query = if let Some(q) = query {
662 Some(q.normalize()?)
663 } else {
664 None
665 };
666
667 let query_params = normalized_query.as_ref().map(Vec::from);
668
669 let response = self.get(endpoint, query_params.as_deref()).await?;
670 let response = Self::handle_response(response, "list applications").await?;
671
672 let apps_response: ApplicationsResponse = response.json().await?;
673 Ok(apps_response)
674 }
675
676 pub async fn get_application(&self, guid: &AppGuid) -> Result<Application, VeracodeError> {
695 let endpoint = format!("/appsec/v1/applications/{}", guid.as_url_safe());
697
698 let response = self.get(&endpoint, None).await?;
699 let response = Self::handle_response(response, "get application details").await?;
700
701 let app: Application = response.json().await?;
702 Ok(app)
703 }
704
705 pub async fn create_application(
720 &self,
721 request: &CreateApplicationRequest,
722 ) -> Result<Application, VeracodeError> {
723 let endpoint = "/appsec/v1/applications";
724
725 if let Ok(json_payload) = serde_json::to_string_pretty(&request) {
727 log::debug!(
728 "🔍 Creating application with JSON payload: {}",
729 json_payload
730 );
731 }
732
733 let response = self.post(endpoint, Some(&request)).await?;
734 let response = Self::handle_response(response, "create application").await?;
735
736 let app: Application = response.json().await?;
737 Ok(app)
738 }
739
740 pub async fn update_application(
760 &self,
761 guid: &AppGuid,
762 request: &UpdateApplicationRequest,
763 ) -> Result<Application, VeracodeError> {
764 let endpoint = format!("/appsec/v1/applications/{}", guid.as_url_safe());
766
767 let response = self.put(&endpoint, Some(&request)).await?;
768 let response = Self::handle_response(response, "update application").await?;
769
770 let app: Application = response.json().await?;
771 Ok(app)
772 }
773
774 pub async fn delete_application(&self, guid: &AppGuid) -> Result<(), VeracodeError> {
792 let endpoint = format!("/appsec/v1/applications/{}", guid.as_url_safe());
794
795 let response = self.delete(&endpoint).await?;
796 let _response = Self::handle_response(response, "delete application").await?;
797
798 Ok(())
799 }
800
801 pub async fn get_non_compliant_applications(&self) -> Result<Vec<Application>, VeracodeError> {
811 let query = ApplicationQuery::new().with_policy_compliance("DID_NOT_PASS");
812
813 let response = self.get_applications(Some(query)).await?;
814
815 if let Some(embedded) = response.embedded {
816 Ok(embedded.applications)
817 } else {
818 Ok(Vec::new())
819 }
820 }
821
822 pub async fn get_applications_modified_after(
836 &self,
837 date: &str,
838 ) -> Result<Vec<Application>, VeracodeError> {
839 let query = ApplicationQuery::new().with_modified_after(date);
840
841 let response = self.get_applications(Some(query)).await?;
842
843 if let Some(embedded) = response.embedded {
844 Ok(embedded.applications)
845 } else {
846 Ok(Vec::new())
847 }
848 }
849
850 pub async fn search_applications_by_name(
864 &self,
865 name: &str,
866 ) -> Result<Vec<Application>, VeracodeError> {
867 let query = ApplicationQuery::new().with_name(name);
868
869 let response = self.get_applications(Some(query)).await?;
870
871 if let Some(embedded) = response.embedded {
872 Ok(embedded.applications)
873 } else {
874 Ok(Vec::new())
875 }
876 }
877
878 pub async fn get_all_applications(&self) -> Result<Vec<Application>, VeracodeError> {
888 let mut all_applications = Vec::new();
889 let mut page = 0;
890
891 loop {
892 let query = ApplicationQuery::new().with_page(page).with_size(100);
893
894 let response = self.get_applications(Some(query)).await?;
895
896 if let Some(embedded) = response.embedded {
897 if embedded.applications.is_empty() {
898 break;
899 }
900 all_applications.extend(embedded.applications);
901 page = page.saturating_add(1);
902 } else {
903 break;
904 }
905 }
906
907 Ok(all_applications)
908 }
909
910 pub async fn get_application_by_name(
924 &self,
925 name: &str,
926 ) -> Result<Option<Application>, VeracodeError> {
927 let applications = self.search_applications_by_name(name).await?;
928
929 Ok(applications.into_iter().find(|app| {
931 if let Some(profile) = &app.profile {
932 profile.name.as_str() == name
933 } else {
934 false
935 }
936 }))
937 }
938
939 pub async fn application_exists_by_name(&self, name: &str) -> Result<bool, VeracodeError> {
953 match self.get_application_by_name(name).await? {
954 Some(_) => Ok(true),
955 None => Ok(false),
956 }
957 }
958
959 pub async fn get_app_id_from_guid(&self, guid: &AppGuid) -> Result<String, VeracodeError> {
976 let app = self.get_application(guid).await?;
977 Ok(app.id.to_string())
978 }
979
980 pub async fn create_application_if_not_exists(
1072 &self,
1073 name: &str,
1074 business_criticality: BusinessCriticality,
1075 description: Option<String>,
1076 team_names: Option<Vec<String>>,
1077 repo_url: Option<String>,
1078 custom_kms_alias: Option<String>,
1079 ) -> Result<Application, VeracodeError> {
1080 if let Some(existing_app) = self.get_application_by_name(name).await? {
1082 let mut needs_update = false;
1084 let mut update_repo_url = false;
1085 let mut update_description = false;
1086 if let Some(ref profile) = existing_app.profile {
1091 if repo_url.is_some()
1093 && (profile.repo_url.is_none()
1094 || profile
1095 .repo_url
1096 .as_ref()
1097 .is_some_and(|u| u.trim().is_empty()))
1098 {
1099 update_repo_url = true;
1100 needs_update = true;
1101 }
1102
1103 if description.is_some()
1105 && (profile.description.is_none()
1106 || profile
1107 .description
1108 .as_ref()
1109 .is_some_and(|d| d.as_str().trim().is_empty()))
1110 {
1111 update_description = true;
1112 needs_update = true;
1113 }
1114
1115 }
1131
1132 if needs_update {
1133 log::debug!("🔄 Updating fields for existing application '{}'", name);
1134 if update_repo_url {
1135 log::debug!(
1136 " Setting repo_url: {}",
1137 repo_url.as_deref().unwrap_or("None")
1138 );
1139 }
1140 if update_description {
1141 log::debug!(
1142 " Setting description: {}",
1143 description.as_deref().unwrap_or("None")
1144 );
1145 }
1146 let profile = existing_app.profile.as_ref().ok_or_else(|| {
1156 VeracodeError::InvalidResponse(format!("Application '{}' has no profile", name))
1157 })?;
1158 let update_request = UpdateApplicationRequest {
1159 profile: UpdateApplicationProfile {
1160 name: Some(profile.name.clone()),
1161 description: if update_description {
1162 description.map(Description::new).transpose()?
1163 } else {
1164 profile.description.clone()
1165 },
1166 business_unit: profile.business_unit.clone(),
1167 business_owners: profile.business_owners.clone(),
1168 business_criticality: profile.business_criticality, policies: profile.policies.clone(),
1170 teams: profile.teams.clone(), tags: profile.tags.clone(),
1172 custom_fields: profile.custom_fields.clone(),
1173 custom_kms_alias: profile.custom_kms_alias.clone(), repo_url: if update_repo_url {
1182 repo_url
1183 } else {
1184 profile.repo_url.clone()
1185 },
1186 },
1187 };
1188
1189 let guid = AppGuid::new(&existing_app.guid)?;
1190 return self.update_application(&guid, &update_request).await;
1191 }
1192
1193 return Ok(existing_app);
1194 }
1195
1196 let teams = if let Some(names) = team_names {
1200 let identity_api = self.identity_api();
1201 let mut resolved_teams = Vec::new();
1202
1203 for team_name in names {
1204 match identity_api.get_team_guid_by_name(&team_name).await {
1205 Ok(Some(team_guid)) => {
1206 resolved_teams.push(Team {
1207 guid: Some(team_guid),
1208 team_id: None,
1209 team_name: None, team_legacy_id: None,
1211 });
1212 }
1213 Ok(None) => {
1214 return Err(VeracodeError::NotFound(format!(
1215 "Team '{}' not found",
1216 team_name
1217 )));
1218 }
1219 Err(identity_err) => {
1220 return Err(VeracodeError::InvalidResponse(format!(
1221 "Failed to lookup team '{}': {}",
1222 team_name, identity_err
1223 )));
1224 }
1225 }
1226 }
1227
1228 Some(resolved_teams)
1229 } else {
1230 None
1231 };
1232
1233 let create_request = CreateApplicationRequest {
1234 profile: CreateApplicationProfile {
1235 name: AppName::new(name)?,
1236 business_criticality,
1237 description: description.map(Description::new).transpose()?,
1238 business_unit: None,
1239 business_owners: None,
1240 policies: None,
1241 teams,
1242 tags: None,
1243 custom_fields: None,
1244 custom_kms_alias,
1245 repo_url,
1246 },
1247 };
1248
1249 self.create_application(&create_request).await
1250 }
1251
1252 pub async fn create_application_if_not_exists_with_team_guids(
1273 &self,
1274 name: &str,
1275 business_criticality: BusinessCriticality,
1276 description: Option<String>,
1277 team_guids: Option<Vec<String>>,
1278 ) -> Result<Application, VeracodeError> {
1279 if let Some(existing_app) = self.get_application_by_name(name).await? {
1281 return Ok(existing_app);
1282 }
1283
1284 let teams = team_guids.map(|guids| {
1288 guids
1289 .into_iter()
1290 .map(|team_guid| Team {
1291 guid: Some(team_guid),
1292 team_id: None, team_name: None, team_legacy_id: None, })
1296 .collect()
1297 });
1298
1299 let create_request = CreateApplicationRequest {
1300 profile: CreateApplicationProfile {
1301 name: AppName::new(name)?,
1302 business_criticality,
1303 description: description.map(Description::new).transpose()?,
1304 business_unit: None,
1305 business_owners: None,
1306 policies: None,
1307 teams,
1308 tags: None,
1309 custom_fields: None,
1310 custom_kms_alias: None,
1311 repo_url: None,
1312 },
1313 };
1314
1315 self.create_application(&create_request).await
1316 }
1317
1318 pub async fn create_application_if_not_exists_simple(
1338 &self,
1339 name: &str,
1340 business_criticality: BusinessCriticality,
1341 description: Option<String>,
1342 ) -> Result<Application, VeracodeError> {
1343 self.create_application_if_not_exists(
1344 name,
1345 business_criticality,
1346 description,
1347 None,
1348 None,
1349 None,
1350 )
1351 .await
1352 }
1353
1354 pub async fn enable_application_encryption(
1396 &self,
1397 app_guid: &AppGuid,
1398 kms_alias: &str,
1399 ) -> Result<Application, VeracodeError> {
1400 validate_kms_alias(kms_alias).map_err(VeracodeError::InvalidConfig)?;
1402
1403 let current_app = self.get_application(app_guid).await?;
1405
1406 let profile = current_app
1407 .profile
1408 .ok_or_else(|| VeracodeError::NotFound("Application profile not found".to_string()))?;
1409
1410 let update_request = UpdateApplicationRequest {
1412 profile: UpdateApplicationProfile {
1413 name: Some(profile.name),
1414 description: profile.description,
1415 business_unit: profile.business_unit,
1416 business_owners: profile.business_owners,
1417 business_criticality: profile.business_criticality,
1418 policies: profile.policies,
1419 teams: profile.teams,
1420 tags: profile.tags,
1421 custom_fields: profile.custom_fields,
1422 custom_kms_alias: Some(kms_alias.to_string()),
1423 repo_url: profile.repo_url,
1424 },
1425 };
1426
1427 self.update_application(app_guid, &update_request).await
1428 }
1429
1430 pub async fn change_encryption_key(
1449 &self,
1450 app_guid: &AppGuid,
1451 new_kms_alias: &str,
1452 ) -> Result<Application, VeracodeError> {
1453 validate_kms_alias(new_kms_alias).map_err(VeracodeError::InvalidConfig)?;
1455
1456 let current_app = self.get_application(app_guid).await?;
1458
1459 let profile = current_app
1460 .profile
1461 .ok_or_else(|| VeracodeError::NotFound("Application profile not found".to_string()))?;
1462
1463 let update_request = UpdateApplicationRequest {
1465 profile: UpdateApplicationProfile {
1466 name: Some(profile.name),
1467 description: profile.description,
1468 business_unit: profile.business_unit,
1469 business_owners: profile.business_owners,
1470 business_criticality: profile.business_criticality,
1471 policies: profile.policies,
1472 teams: profile.teams,
1473 tags: profile.tags,
1474 custom_fields: profile.custom_fields,
1475 custom_kms_alias: Some(new_kms_alias.to_string()),
1476 repo_url: profile.repo_url,
1477 },
1478 };
1479
1480 self.update_application(app_guid, &update_request).await
1481 }
1482
1483 pub async fn get_application_encryption_status(
1500 &self,
1501 app_guid: &AppGuid,
1502 ) -> Result<Option<String>, VeracodeError> {
1503 let app = self.get_application(app_guid).await?;
1504
1505 Ok(app.profile.and_then(|profile| profile.custom_kms_alias))
1507 }
1508}
1509
1510pub fn validate_kms_alias(alias: &str) -> Result<(), String> {
1533 if !alias.starts_with("alias/") {
1535 return Err("KMS alias must start with 'alias/'".to_string());
1536 }
1537
1538 if alias.len() < 8 || alias.len() > 256 {
1540 return Err("KMS alias must be between 8 and 256 characters long".to_string());
1541 }
1542
1543 let alias_name = alias
1545 .strip_prefix("alias/")
1546 .ok_or_else(|| "KMS alias must start with 'alias/'".to_string())?;
1547
1548 if alias_name.starts_with("aws") || alias_name.ends_with("aws") {
1550 return Err("KMS alias cannot begin or end with 'aws' (reserved by AWS)".to_string());
1551 }
1552
1553 if !alias_name
1555 .chars()
1556 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '/')
1557 {
1558 return Err("KMS alias can only contain alphanumeric characters, hyphens, underscores, and forward slashes".to_string());
1559 }
1560
1561 if alias_name.is_empty() {
1563 return Err("KMS alias name cannot be empty after 'alias/' prefix".to_string());
1564 }
1565
1566 Ok(())
1567}
1568
1569#[cfg(test)]
1570#[allow(clippy::expect_used)]
1571mod tests {
1572 use super::*;
1573
1574 #[test]
1575 fn test_query_params() {
1576 let query = ApplicationQuery::new()
1577 .with_name("test_app")
1578 .with_policy_compliance("PASSED")
1579 .with_page(1)
1580 .with_size(50);
1581
1582 let params = query.to_query_params();
1583 assert!(params.contains(&("name".to_string(), "test_app".to_string())));
1584 assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1585 assert!(params.contains(&("page".to_string(), "1".to_string())));
1586 assert!(params.contains(&("size".to_string(), "50".to_string())));
1587 }
1588
1589 #[test]
1590 fn test_application_query_builder() {
1591 let query = ApplicationQuery::new()
1592 .with_name("MyApp")
1593 .with_policy_compliance("DID_NOT_PASS")
1594 .with_modified_after("2023-01-01T00:00:00.000Z")
1595 .with_page(2)
1596 .with_size(25);
1597
1598 assert_eq!(query.name, Some("MyApp".to_string()));
1599 assert_eq!(query.policy_compliance, Some("DID_NOT_PASS".to_string()));
1600 assert_eq!(
1601 query.modified_after,
1602 Some("2023-01-01T00:00:00.000Z".to_string())
1603 );
1604 assert_eq!(query.page, Some(2));
1605 assert_eq!(query.size, Some(25));
1606 }
1607
1608 #[test]
1609 fn test_application_query_normalize_defaults() {
1610 let query = ApplicationQuery::new();
1611 let normalized = query.normalize().expect("should normalize");
1612
1613 assert_eq!(normalized.size, Some(50)); assert_eq!(normalized.page, None);
1616 }
1617
1618 #[test]
1619 fn test_application_query_normalize_valid_values() {
1620 let query = ApplicationQuery::new().with_page(10).with_size(100);
1621 let normalized = query.normalize().expect("should normalize");
1622
1623 assert_eq!(normalized.page, Some(10));
1624 assert_eq!(normalized.size, Some(100));
1625 }
1626
1627 #[test]
1628 fn test_application_query_normalize_zero_size() {
1629 let query = ApplicationQuery::new().with_size(0);
1630 let result = query.normalize();
1631
1632 assert!(result.is_err());
1633 }
1634
1635 #[test]
1636 fn test_application_query_normalize_caps_large_size() {
1637 let query = ApplicationQuery::new().with_size(10000);
1638 let normalized = query.normalize().expect("should cap to max");
1639
1640 assert_eq!(normalized.size, Some(500));
1642 }
1643
1644 #[test]
1645 fn test_application_query_normalize_caps_large_page() {
1646 let query = ApplicationQuery::new().with_page(50000);
1647 let normalized = query.normalize().expect("should cap to max");
1648
1649 assert_eq!(normalized.page, Some(10000));
1651 }
1652
1653 #[test]
1654 fn test_query_params_url_encoding_normal() {
1655 let query = ApplicationQuery::new()
1656 .with_name("MyApp")
1657 .with_policy_compliance("PASSED");
1658
1659 let params = query.to_query_params();
1660
1661 assert!(params.contains(&("name".to_string(), "MyApp".to_string())));
1663 assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1664 }
1665
1666 #[test]
1667 fn test_query_params_url_encoding_special_chars() {
1668 let query = ApplicationQuery::new()
1669 .with_name("My App & Co")
1670 .with_policy_compliance("DID_NOT_PASS");
1671
1672 let params = query.to_query_params();
1673
1674 assert!(params.contains(&("name".to_string(), "My%20App%20%26%20Co".to_string())));
1676 }
1677
1678 #[test]
1679 fn test_query_params_injection_attempt() {
1680 let query = ApplicationQuery::new().with_name("foo&admin=true");
1682
1683 let params = query.to_query_params();
1684
1685 assert!(params.contains(&("name".to_string(), "foo%26admin%3Dtrue".to_string())));
1687
1688 assert!(!params.iter().any(|(key, _)| key == "admin"));
1690 }
1691
1692 #[test]
1693 fn test_query_params_equals_injection() {
1694 let query = ApplicationQuery::new().with_name("test=malicious");
1696
1697 let params = query.to_query_params();
1698
1699 assert!(params.contains(&("name".to_string(), "test%3Dmalicious".to_string())));
1701 }
1702
1703 #[test]
1704 fn test_query_params_semicolon_injection() {
1705 let query = ApplicationQuery::new().with_name("test;rm -rf /");
1707
1708 let params = query.to_query_params();
1709
1710 assert!(params.contains(&("name".to_string(), "test%3Brm%20-rf%20%2F".to_string())));
1712 }
1713
1714 #[test]
1715 fn test_query_params_multiple_fields_with_encoding() {
1716 let mut query = ApplicationQuery::new()
1717 .with_name("App & Test")
1718 .with_policy_compliance("PASSED")
1719 .with_modified_after("2023-01-01T00:00:00.000Z");
1720 query.business_unit = Some("Test & Development".to_string());
1721
1722 let params = query.to_query_params();
1723
1724 assert!(params.contains(&("name".to_string(), "App%20%26%20Test".to_string())));
1726 assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1727 assert!(params.contains(&(
1728 "modified_after".to_string(),
1729 "2023-01-01T00%3A00%3A00.000Z".to_string()
1730 )));
1731 assert!(params.contains(&(
1732 "business_unit".to_string(),
1733 "Test%20%26%20Development".to_string()
1734 )));
1735 }
1736
1737 #[test]
1738 fn test_create_application_request_with_teams() {
1739 let team_names = vec!["Security Team".to_string(), "Development Team".to_string()];
1740 let teams: Vec<Team> = team_names
1741 .into_iter()
1742 .map(|team_name| Team {
1743 guid: None,
1744 team_id: None,
1745 team_name: Some(team_name),
1746 team_legacy_id: None,
1747 })
1748 .collect();
1749
1750 let request = CreateApplicationRequest {
1751 profile: CreateApplicationProfile {
1752 name: AppName::new("Test Application").expect("valid name"),
1753 business_criticality: BusinessCriticality::Medium,
1754 description: Some(Description::new("Test description").expect("valid description")),
1755 business_unit: None,
1756 business_owners: None,
1757 policies: None,
1758 teams: Some(teams.clone()),
1759 tags: None,
1760 custom_fields: None,
1761 custom_kms_alias: None,
1762 repo_url: None,
1763 },
1764 };
1765
1766 assert_eq!(request.profile.name.as_str(), "Test Application");
1767 assert_eq!(
1768 request.profile.business_criticality,
1769 BusinessCriticality::Medium
1770 );
1771 assert!(request.profile.teams.is_some());
1772
1773 let request_teams = request.profile.teams.expect("teams should be present");
1774 assert_eq!(request_teams.len(), 2);
1775 assert_eq!(
1776 request_teams
1777 .first()
1778 .expect("should have first team")
1779 .team_name,
1780 Some("Security Team".to_string())
1781 );
1782 assert_eq!(
1783 request_teams
1784 .get(1)
1785 .expect("should have second team")
1786 .team_name,
1787 Some("Development Team".to_string())
1788 );
1789 }
1790
1791 #[test]
1792 fn test_create_application_request_with_team_guids() {
1793 let team_guids = vec!["team-guid-1".to_string(), "team-guid-2".to_string()];
1794 let teams: Vec<Team> = team_guids
1795 .into_iter()
1796 .map(|team_guid| Team {
1797 guid: Some(team_guid),
1798 team_id: None,
1799 team_name: None,
1800 team_legacy_id: None,
1801 })
1802 .collect();
1803
1804 let request = CreateApplicationRequest {
1805 profile: CreateApplicationProfile {
1806 name: AppName::new("Test Application").expect("valid name"),
1807 business_criticality: BusinessCriticality::High,
1808 description: Some(Description::new("Test description").expect("valid description")),
1809 business_unit: None,
1810 business_owners: None,
1811 policies: None,
1812 teams: Some(teams.clone()),
1813 tags: None,
1814 custom_fields: None,
1815 custom_kms_alias: None,
1816 repo_url: None,
1817 },
1818 };
1819
1820 assert_eq!(request.profile.name.as_str(), "Test Application");
1821 assert_eq!(
1822 request.profile.business_criticality,
1823 BusinessCriticality::High
1824 );
1825 assert!(request.profile.teams.is_some());
1826
1827 let request_teams = request.profile.teams.expect("teams should be present");
1828 assert_eq!(request_teams.len(), 2);
1829 assert_eq!(
1830 request_teams.first().expect("should have first team").guid,
1831 Some("team-guid-1".to_string())
1832 );
1833 assert_eq!(
1834 request_teams.get(1).expect("should have second team").guid,
1835 Some("team-guid-2".to_string())
1836 );
1837 assert!(
1838 request_teams
1839 .first()
1840 .expect("should have first team")
1841 .team_name
1842 .is_none()
1843 );
1844 assert!(
1845 request_teams
1846 .get(1)
1847 .expect("should have second team")
1848 .team_name
1849 .is_none()
1850 );
1851 }
1852
1853 #[test]
1854 fn test_create_application_profile_cmek_serialization() {
1855 let profile_with_cmek = CreateApplicationProfile {
1857 name: AppName::new("Test Application").expect("valid name"),
1858 business_criticality: BusinessCriticality::High,
1859 description: None,
1860 business_unit: None,
1861 business_owners: None,
1862 policies: None,
1863 teams: None,
1864 tags: None,
1865 custom_fields: None,
1866 custom_kms_alias: Some("alias/my-app-key".to_string()),
1867 repo_url: None,
1868 };
1869
1870 let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
1871 assert!(json.contains("custom_kms_alias"));
1872 assert!(json.contains("alias/my-app-key"));
1873
1874 let profile_without_cmek = CreateApplicationProfile {
1876 name: AppName::new("Test Application").expect("valid name"),
1877 business_criticality: BusinessCriticality::High,
1878 description: None,
1879 business_unit: None,
1880 business_owners: None,
1881 policies: None,
1882 teams: None,
1883 tags: None,
1884 custom_fields: None,
1885 custom_kms_alias: None,
1886 repo_url: None,
1887 };
1888
1889 let json = serde_json::to_string(&profile_without_cmek).expect("should serialize to json");
1890 assert!(!json.contains("custom_kms_alias"));
1891 }
1892
1893 #[test]
1894 fn test_update_application_profile_cmek_serialization() {
1895 let profile_with_cmek = UpdateApplicationProfile {
1897 name: Some(AppName::new("Updated Application").expect("valid name")),
1898 description: None,
1899 business_unit: None,
1900 business_owners: None,
1901 business_criticality: BusinessCriticality::Medium,
1902 policies: None,
1903 teams: None,
1904 tags: None,
1905 custom_fields: None,
1906 custom_kms_alias: Some("alias/updated-key".to_string()),
1907 repo_url: None,
1908 };
1909
1910 let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
1911 assert!(json.contains("custom_kms_alias"));
1912 assert!(json.contains("alias/updated-key"));
1913
1914 let profile_without_cmek = UpdateApplicationProfile {
1916 name: Some(AppName::new("Updated Application").expect("valid name")),
1917 description: None,
1918 business_unit: None,
1919 business_owners: None,
1920 business_criticality: BusinessCriticality::Medium,
1921 policies: None,
1922 teams: None,
1923 tags: None,
1924 custom_fields: None,
1925 custom_kms_alias: None,
1926 repo_url: None,
1927 };
1928
1929 let json = serde_json::to_string(&profile_without_cmek).expect("should serialize to json");
1930 assert!(!json.contains("custom_kms_alias"));
1931 }
1932
1933 #[test]
1934 fn test_validate_kms_alias_valid_cases() {
1935 assert!(validate_kms_alias("alias/my-app-key").is_ok());
1937 assert!(validate_kms_alias("alias/my_app_key_2024").is_ok());
1938 assert!(validate_kms_alias("alias/app/environment/key").is_ok());
1939 assert!(validate_kms_alias("alias/123-test-key").is_ok());
1940 }
1941
1942 #[test]
1943 fn test_validate_kms_alias_invalid_cases() {
1944 assert!(validate_kms_alias("my-app-key").is_err());
1946 assert!(validate_kms_alias("invalid-alias").is_err());
1947
1948 assert!(validate_kms_alias("arn:aws:kms:us-east-1:123456789:alias/my-key").is_err());
1950
1951 assert!(validate_kms_alias("alias/aws-managed").is_err());
1953 assert!(validate_kms_alias("alias/my-key-aws").is_err());
1954
1955 assert!(validate_kms_alias("alias/").is_err());
1957
1958 assert!(validate_kms_alias("alias/a").is_err());
1960
1961 assert!(validate_kms_alias("alias/my@key").is_err());
1963 assert!(validate_kms_alias("alias/my key").is_err());
1964 assert!(validate_kms_alias("alias/my.key").is_err());
1965
1966 let long_alias = format!("alias/{}", "a".repeat(251));
1968 assert!(validate_kms_alias(&long_alias).is_err());
1969 }
1970
1971 #[test]
1972 fn test_cmek_backward_compatibility() {
1973 let legacy_profile = CreateApplicationProfile {
1975 name: AppName::new("Legacy Application").expect("valid name"),
1976 business_criticality: BusinessCriticality::High,
1977 description: Some(
1978 Description::new("Legacy app without CMEK").expect("valid description"),
1979 ),
1980 business_unit: None,
1981 business_owners: None,
1982 policies: None,
1983 teams: None,
1984 tags: None,
1985 custom_fields: None,
1986 custom_kms_alias: None,
1987 repo_url: None,
1988 };
1989
1990 let request = CreateApplicationRequest {
1991 profile: legacy_profile,
1992 };
1993
1994 let json = serde_json::to_string(&request).expect("should serialize to json");
1996
1997 assert!(!json.contains("custom_kms_alias"));
1999
2000 assert!(json.contains("name"));
2002 assert!(json.contains("business_criticality"));
2003 assert!(json.contains("Legacy Application"));
2004
2005 let _deserialized: CreateApplicationRequest =
2007 serde_json::from_str(&json).expect("should deserialize json");
2008 }
2009
2010 #[test]
2011 fn test_cmek_field_deserialization() {
2012 let json_with_cmek = r#"{
2014 "profile": {
2015 "name": "Test App",
2016 "business_criticality": "HIGH",
2017 "custom_kms_alias": "alias/test-key"
2018 }
2019 }"#;
2020
2021 let request: CreateApplicationRequest =
2022 serde_json::from_str(json_with_cmek).expect("should deserialize json");
2023 assert_eq!(
2024 request.profile.custom_kms_alias,
2025 Some("alias/test-key".to_string())
2026 );
2027
2028 let json_without_cmek = r#"{
2030 "profile": {
2031 "name": "Test App",
2032 "business_criticality": "HIGH"
2033 }
2034 }"#;
2035
2036 let request: CreateApplicationRequest =
2037 serde_json::from_str(json_without_cmek).expect("should deserialize json");
2038 assert_eq!(request.profile.custom_kms_alias, None);
2039 }
2040
2041 #[test]
2042 fn test_create_application_profile_with_cmek() {
2043 let profile_with_cmek = CreateApplicationProfile {
2045 name: AppName::new("MyApplication").expect("valid name"),
2046 business_criticality: BusinessCriticality::High,
2047 description: Some(
2048 Description::new("Application created for assessment scanning")
2049 .expect("valid description"),
2050 ),
2051 business_unit: None,
2052 business_owners: None,
2053 policies: None,
2054 teams: None,
2055 tags: None,
2056 custom_fields: None,
2057 custom_kms_alias: Some("alias/my-encryption-key".to_string()),
2058 repo_url: Some("https://github.com/user/repo".to_string()),
2059 };
2060
2061 let request = CreateApplicationRequest {
2062 profile: profile_with_cmek,
2063 };
2064
2065 let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2066
2067 println!("\n📦 Example JSON Payload sent to Veracode API:");
2069 println!("{}", json);
2070 println!();
2071
2072 assert!(json.contains("custom_kms_alias"));
2073 assert!(json.contains("alias/my-encryption-key"));
2074
2075 let deserialized: CreateApplicationRequest =
2077 serde_json::from_str(&json).expect("should deserialize json");
2078 assert_eq!(
2079 deserialized.profile.custom_kms_alias,
2080 Some("alias/my-encryption-key".to_string())
2081 );
2082 }
2083
2084 #[test]
2085 fn test_create_application_profile_without_cmek() {
2086 let profile_without_cmek = CreateApplicationProfile {
2088 name: AppName::new("MyApplication").expect("valid name"),
2089 business_criticality: BusinessCriticality::High,
2090 description: Some(
2091 Description::new("Application created for assessment scanning")
2092 .expect("valid description"),
2093 ),
2094 business_unit: None,
2095 business_owners: None,
2096 policies: None,
2097 teams: None,
2098 tags: None,
2099 custom_fields: None,
2100 custom_kms_alias: None,
2101 repo_url: Some("https://github.com/user/repo".to_string()),
2102 };
2103
2104 let request = CreateApplicationRequest {
2105 profile: profile_without_cmek,
2106 };
2107
2108 let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2109
2110 println!("\n📦 Example JSON Payload sent to Veracode API (without --cmek):");
2112 println!("{}", json);
2113 println!("⚠️ Notice: 'custom_kms_alias' field is NOT included in the payload");
2114 println!();
2115
2116 assert!(!json.contains("custom_kms_alias"));
2117
2118 let deserialized: CreateApplicationRequest =
2120 serde_json::from_str(&json).expect("should deserialize json");
2121 assert_eq!(deserialized.profile.custom_kms_alias, None);
2122 }
2123
2124 #[test]
2125 fn test_update_application_profile_with_cmek() {
2126 let profile_with_cmek = UpdateApplicationProfile {
2128 name: Some(AppName::new("Updated Application").expect("valid name")),
2129 description: Some(Description::new("Updated description").expect("valid description")),
2130 business_unit: None,
2131 business_owners: None,
2132 business_criticality: BusinessCriticality::Medium,
2133 policies: None,
2134 teams: None,
2135 tags: None,
2136 custom_fields: None,
2137 custom_kms_alias: Some("alias/updated-key".to_string()),
2138 repo_url: None,
2139 };
2140
2141 let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
2142 assert!(json.contains("custom_kms_alias"));
2143 assert!(json.contains("alias/updated-key"));
2144 }
2145
2146 #[test]
2149 fn test_cmek_enabled_payload_structure() {
2150 let request = CreateApplicationRequest {
2151 profile: CreateApplicationProfile {
2152 name: AppName::new("MyApplication").expect("valid name"),
2153 business_criticality: BusinessCriticality::High,
2154 description: Some(
2155 Description::new("Application created for assessment scanning")
2156 .expect("valid description"),
2157 ),
2158 business_unit: None,
2159 business_owners: None,
2160 policies: None,
2161 teams: None,
2162 tags: None,
2163 custom_fields: None,
2164 custom_kms_alias: Some("alias/my-encryption-key".to_string()),
2165 repo_url: Some("https://github.com/user/repo".to_string()),
2166 },
2167 };
2168
2169 let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2170
2171 let expected_keys = vec![
2173 "profile",
2174 "name",
2175 "business_criticality",
2176 "description",
2177 "custom_kms_alias",
2178 "repo_url",
2179 ];
2180
2181 for key in expected_keys {
2182 assert!(
2183 json.contains(&format!("\"{key}\"")),
2184 "Expected key '{}' not found in payload",
2185 key
2186 );
2187 }
2188
2189 assert!(json.contains("\"custom_kms_alias\": \"alias/my-encryption-key\""));
2191 assert!(json.contains("\"business_criticality\": \"HIGH\""));
2192 assert!(json.contains("\"name\": \"MyApplication\""));
2193
2194 let parsed: serde_json::Value =
2196 serde_json::from_str(&json).expect("should deserialize json");
2197 assert_eq!(
2198 parsed
2199 .get("profile")
2200 .and_then(|p| p.get("custom_kms_alias"))
2201 .and_then(|v| v.as_str())
2202 .expect("should have custom_kms_alias"),
2203 "alias/my-encryption-key"
2204 );
2205 assert_eq!(
2206 parsed
2207 .get("profile")
2208 .and_then(|p| p.get("business_criticality"))
2209 .and_then(|v| v.as_str())
2210 .expect("should have business_criticality"),
2211 "HIGH"
2212 );
2213 }
2214
2215 #[test]
2218 fn test_cmek_disabled_payload_structure() {
2219 let request = CreateApplicationRequest {
2220 profile: CreateApplicationProfile {
2221 name: AppName::new("MyApplication").expect("valid name"),
2222 business_criticality: BusinessCriticality::High,
2223 description: Some(
2224 Description::new("Application created for assessment scanning")
2225 .expect("valid description"),
2226 ),
2227 business_unit: None,
2228 business_owners: None,
2229 policies: None,
2230 teams: None,
2231 tags: None,
2232 custom_fields: None,
2233 custom_kms_alias: None, repo_url: Some("https://github.com/user/repo".to_string()),
2235 },
2236 };
2237
2238 let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2239
2240 assert!(
2242 !json.contains("custom_kms_alias"),
2243 "custom_kms_alias should not be present when None"
2244 );
2245
2246 assert!(json.contains("\"name\": \"MyApplication\""));
2248 assert!(json.contains("\"business_criticality\": \"HIGH\""));
2249 assert!(json.contains("\"repo_url\""));
2250
2251 let parsed: serde_json::Value =
2253 serde_json::from_str(&json).expect("should deserialize json");
2254 assert_eq!(
2255 parsed
2256 .get("profile")
2257 .and_then(|p| p.get("name"))
2258 .and_then(|v| v.as_str())
2259 .expect("should have name"),
2260 "MyApplication"
2261 );
2262 assert_eq!(
2263 parsed
2264 .get("profile")
2265 .and_then(|p| p.get("business_criticality"))
2266 .and_then(|v| v.as_str())
2267 .expect("should have business_criticality"),
2268 "HIGH"
2269 );
2270
2271 assert!(
2273 !parsed
2274 .get("profile")
2275 .and_then(|p| p.as_object())
2276 .expect("should have profile object")
2277 .contains_key("custom_kms_alias"),
2278 "custom_kms_alias key should not exist in JSON object"
2279 );
2280 }
2281
2282 #[test]
2284 fn test_cmek_alias_format_variations() {
2285 let test_cases = vec![
2287 "alias/production-key",
2288 "alias/dev_environment_key",
2289 "alias/app/prod/2024",
2290 "alias/KEY123",
2291 "alias/my-app-key-2024",
2292 ];
2293
2294 for alias in test_cases {
2295 let request = CreateApplicationRequest {
2296 profile: CreateApplicationProfile {
2297 name: AppName::new("TestApp").expect("valid name"),
2298 business_criticality: BusinessCriticality::Medium,
2299 description: None,
2300 business_unit: None,
2301 business_owners: None,
2302 policies: None,
2303 teams: None,
2304 tags: None,
2305 custom_fields: None,
2306 custom_kms_alias: Some(alias.to_string()),
2307 repo_url: None,
2308 },
2309 };
2310
2311 let json = serde_json::to_string(&request).expect("should serialize to json");
2312 assert!(
2313 json.contains(alias),
2314 "Alias '{}' should be present in payload",
2315 alias
2316 );
2317
2318 let parsed: CreateApplicationRequest =
2320 serde_json::from_str(&json).expect("should deserialize json");
2321 assert_eq!(parsed.profile.custom_kms_alias, Some(alias.to_string()));
2322 }
2323 }
2324
2325 #[test]
2327 fn test_complete_application_profile_with_cmek() {
2328 let request = CreateApplicationRequest {
2329 profile: CreateApplicationProfile {
2330 name: AppName::new("CompleteApplication").expect("valid name"),
2331 business_criticality: BusinessCriticality::VeryHigh,
2332 description: Some(
2333 Description::new("Full featured application with CMEK")
2334 .expect("valid description"),
2335 ),
2336 business_unit: Some(BusinessUnit {
2337 id: Some(123),
2338 name: Some("Engineering".to_string()),
2339 guid: Some("bu-guid-123".to_string()),
2340 }),
2341 business_owners: Some(vec![BusinessOwner {
2342 email: Some("owner@example.com".to_string()),
2343 name: Some("App Owner".to_string()),
2344 }]),
2345 policies: None,
2346 teams: Some(vec![Team {
2347 guid: Some("team-guid-456".to_string()),
2348 team_id: None,
2349 team_name: None,
2350 team_legacy_id: None,
2351 }]),
2352 tags: Some("production,encrypted".to_string()),
2353 custom_fields: Some(vec![CustomField {
2354 name: Some("Environment".to_string()),
2355 value: Some("Production".to_string()),
2356 }]),
2357 custom_kms_alias: Some("alias/production-cmek-key".to_string()),
2358 repo_url: Some("https://github.com/company/secure-app".to_string()),
2359 },
2360 };
2361
2362 let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2363
2364 assert!(json.contains("\"custom_kms_alias\": \"alias/production-cmek-key\""));
2366 assert!(json.contains("\"business_unit\""));
2367 assert!(json.contains("\"business_owners\""));
2368 assert!(json.contains("\"teams\""));
2369 assert!(json.contains("\"tags\""));
2370 assert!(json.contains("\"custom_fields\""));
2371
2372 let parsed: CreateApplicationRequest =
2374 serde_json::from_str(&json).expect("should deserialize json");
2375 assert_eq!(
2376 parsed.profile.custom_kms_alias,
2377 Some("alias/production-cmek-key".to_string())
2378 );
2379 assert!(parsed.profile.business_unit.is_some());
2380 assert!(parsed.profile.business_owners.is_some());
2381 }
2382}
2383
2384#[cfg(test)]
2385#[allow(clippy::expect_used)] mod proptests {
2387 use super::*;
2388 use proptest::prelude::*;
2389
2390 fn valid_kms_alias_strategy() -> impl Strategy<Value = String> {
2392 prop::string::string_regex("[a-zA-Z0-9_/-]{2,250}")
2396 .expect("valid regex pattern for KMS alias")
2397 .prop_map(|s| format!("alias/{}", s))
2398 .prop_filter("Cannot start with aws", |s| {
2399 !s.strip_prefix("alias/").unwrap_or("").starts_with("aws")
2400 })
2401 .prop_filter("Cannot end with aws", |s| {
2402 !s.strip_prefix("alias/").unwrap_or("").ends_with("aws")
2403 })
2404 }
2405
2406 fn invalid_kms_alias_strategy() -> impl Strategy<Value = String> {
2407 prop_oneof![
2408 prop::string::string_regex("[a-zA-Z0-9_/-]{5,20}")
2410 .expect("valid regex for missing prefix test"),
2411 Just("arn:aws:kms:us-east-1:123456789:alias/test".to_string()),
2413 Just("alias/aws-managed".to_string()),
2415 Just("alias/test-aws".to_string()),
2416 Just("alias/".to_string()),
2418 Just("alias/a".to_string()),
2420 Just("alias/test@key".to_string()),
2422 Just("alias/test key".to_string()),
2423 Just("alias/test.key".to_string()),
2424 prop::string::string_regex("[a-z]{252}")
2426 .expect("valid regex for too long test")
2427 .prop_map(|s| format!("alias/{}", s)),
2428 ]
2429 }
2430
2431 proptest! {
2432 #![proptest_config(ProptestConfig {
2433 cases: if cfg!(miri) { 5 } else { 1000 },
2434 failure_persistence: None, .. ProptestConfig::default()
2436 })]
2437
2438 #[test]
2439 fn proptest_valid_kms_aliases_accepted(alias in valid_kms_alias_strategy()) {
2440 prop_assert!(validate_kms_alias(&alias).is_ok(),
2441 "Valid alias rejected: {}", alias);
2442 }
2443
2444 #[test]
2445 fn proptest_invalid_kms_aliases_rejected(alias in invalid_kms_alias_strategy()) {
2446 prop_assert!(validate_kms_alias(&alias).is_err(),
2447 "Invalid alias accepted: {}", alias);
2448 }
2449
2450 #[test]
2451 fn proptest_kms_alias_length_bounds(
2452 prefix in prop::string::string_regex("[a-zA-Z0-9_/-]{1,7}").expect("valid regex for prefix"),
2453 suffix in prop::string::string_regex("[a-zA-Z0-9_/-]{251,300}").expect("valid regex for suffix")
2454 ) {
2455 let too_short = format!("alias/{}", prefix);
2456 let too_long = format!("alias/{}", suffix);
2457
2458 prop_assert!(validate_kms_alias(&too_short).is_err() || too_short.len() >= 8,
2459 "Too short alias not rejected");
2460 prop_assert!(validate_kms_alias(&too_long).is_err(),
2461 "Too long alias not rejected");
2462 }
2463 }
2464}
2465
2466#[cfg(test)]
2467#[allow(clippy::expect_used)] mod query_proptests {
2469 use super::*;
2470 use crate::validation::encode_query_param;
2471 use proptest::prelude::*;
2472
2473 proptest! {
2474 #![proptest_config(ProptestConfig {
2475 cases: if cfg!(miri) { 5 } else { 1000 },
2476 failure_persistence: None, .. ProptestConfig::default()
2478 })]
2479
2480 #[test]
2481 fn proptest_query_param_no_injection(
2482 value in prop::string::string_regex(".{1,100}").expect("valid regex for query param")
2483 ) {
2484 let encoded = encode_query_param(&value);
2485
2486 prop_assert!(!encoded.contains('&'), "Ampersand not encoded");
2488 prop_assert!(!encoded.contains('=') || value.contains('=') && encoded.contains("%3D"),
2489 "Equals not encoded");
2490 prop_assert!(!encoded.contains(';'), "Semicolon not encoded");
2491 }
2492
2493 #[test]
2494 fn proptest_query_param_path_traversal_encoded(
2495 segments in prop::collection::vec(
2496 prop::string::string_regex("[a-zA-Z0-9]{1,10}").expect("valid regex for path segments"),
2497 1..5
2498 )
2499 ) {
2500 let path_traversal = segments.join("../");
2501 let encoded = encode_query_param(&path_traversal);
2502
2503 prop_assert!(!encoded.contains("../"), "Path traversal sequence '../' not broken by encoding");
2506 prop_assert!(!encoded.contains("..\\"), "Path traversal sequence '..\\' not broken by encoding");
2507 prop_assert!(encoded.contains("%2F") || !path_traversal.contains('/'),
2508 "Forward slash not encoded");
2509 }
2510
2511 #[test]
2512 fn proptest_application_query_to_params_no_key_pollution(
2513 name in prop::option::of(prop::string::string_regex("[a-zA-Z0-9 &=;]{1,50}").expect("valid regex for app name")),
2514 compliance in prop::option::of(Just("PASSED".to_string())),
2515 page in prop::option::of(0u32..1000u32),
2516 size in prop::option::of(1u32..1000u32)
2517 ) {
2518 let mut query = ApplicationQuery::new();
2519 if let Some(n) = name {
2520 query = query.with_name(&n);
2521 }
2522 if let Some(c) = compliance {
2523 query = query.with_policy_compliance(&c);
2524 }
2525 query.page = page;
2526 query.size = size;
2527
2528 let params = query.to_query_params();
2529
2530 let mut seen_keys = std::collections::HashSet::new();
2532 for (key, _) in params.iter() {
2533 prop_assert!(!seen_keys.contains(key),
2534 "Duplicate parameter key: {}", key);
2535 seen_keys.insert(key.clone());
2536 }
2537 }
2538 }
2539}
2540
2541#[cfg(test)]
2542#[allow(clippy::expect_used)] mod pagination_proptests {
2544 use super::*;
2545 use crate::validation::{MAX_PAGE_NUMBER, MAX_PAGE_SIZE};
2546 use proptest::prelude::*;
2547
2548 proptest! {
2549 #![proptest_config(ProptestConfig {
2550 cases: if cfg!(miri) { 5 } else { 1000 },
2551 failure_persistence: None, .. ProptestConfig::default()
2553 })]
2554
2555 #[test]
2556 fn proptest_page_size_bounds_enforced(size in 0u32..u32::MAX) {
2557 match validate_page_size(Some(size)) {
2558 Ok(validated) => {
2559 prop_assert!(validated >= 1, "Zero page size accepted");
2560 prop_assert!(validated <= MAX_PAGE_SIZE,
2561 "Page size {} exceeds maximum {}", validated, MAX_PAGE_SIZE);
2562 }
2563 Err(_) => {
2564 prop_assert_eq!(size, 0, "Non-zero size rejected");
2565 }
2566 }
2567 }
2568
2569 #[test]
2570 fn proptest_page_number_bounds_enforced(page in 0u32..u32::MAX) {
2571 let validated = validate_page_number(Some(page)).expect("page number validation should not fail");
2572
2573 if let Some(p) = validated {
2574 prop_assert!(p <= MAX_PAGE_NUMBER,
2575 "Page number {} exceeds maximum {}", p, MAX_PAGE_NUMBER);
2576 }
2577 }
2578
2579 #[test]
2580 fn proptest_application_query_normalize_safety(
2581 page in prop::option::of(0u32..u32::MAX),
2582 size in prop::option::of(0u32..u32::MAX)
2583 ) {
2584 let mut query = ApplicationQuery::new();
2585 query.page = page;
2586 query.size = size;
2587
2588 match query.normalize() {
2589 Ok(normalized) => {
2590 if let Some(s) = normalized.size {
2592 prop_assert!((1..=MAX_PAGE_SIZE).contains(&s),
2593 "Normalized size {} out of bounds", s);
2594 }
2595 if let Some(p) = normalized.page {
2596 prop_assert!(p <= MAX_PAGE_NUMBER,
2597 "Normalized page {} exceeds maximum", p);
2598 }
2599 }
2600 Err(_) => {
2601 prop_assert_eq!(size, Some(0), "Unexpected normalization error");
2603 }
2604 }
2605 }
2606 }
2607}
2608#[cfg(test)]
2609mod miri_tests {
2610 use super::*;
2611
2612 #[test]
2613 fn miri_business_owner_debug_redaction() {
2614 let owner = BusinessOwner {
2615 email: Some("sensitive@example.com".to_string()),
2616 name: Some("Sensitive Name".to_string()),
2617 };
2618
2619 let debug_str = format!("{:?}", owner);
2620
2621 assert!(debug_str.contains("[REDACTED]"));
2623 assert!(!debug_str.contains("sensitive@example.com"));
2624 assert!(!debug_str.contains("Sensitive Name"));
2625 }
2626
2627 #[test]
2628 fn miri_business_owner_none_fields() {
2629 let owner = BusinessOwner {
2630 email: None,
2631 name: None,
2632 };
2633
2634 let debug_str = format!("{:?}", owner);
2636 assert!(debug_str.contains("[REDACTED]"));
2637 }
2638
2639 #[test]
2640 fn miri_custom_field_debug_redaction() {
2641 let field = CustomField {
2642 name: Some("API_KEY".to_string()),
2643 value: Some("super-secret-key".to_string()),
2644 };
2645
2646 let debug_str = format!("{:?}", field);
2647
2648 assert!(debug_str.contains("API_KEY"));
2650 assert!(debug_str.contains("[REDACTED]"));
2651 assert!(!debug_str.contains("super-secret-key"));
2652 }
2653
2654 #[test]
2655 fn miri_custom_field_none_value() {
2656 let field = CustomField {
2657 name: Some("EMPTY_FIELD".to_string()),
2658 value: None,
2659 };
2660
2661 let debug_str = format!("{:?}", field);
2663 assert!(debug_str.contains("EMPTY_FIELD"));
2664 assert!(debug_str.contains("[REDACTED]"));
2665 }
2666}
2667
2668#[cfg(test)]
2669mod miri_validation_tests {
2670 use super::*;
2671
2672 #[test]
2673 fn miri_app_name_utf8_boundaries() {
2674 let emoji_name = "MyApp 🚀 Test";
2676 let result = AppName::new(emoji_name);
2677 assert!(result.is_ok());
2678
2679 let combining = "Café"; let result = AppName::new(combining);
2682 assert!(result.is_ok());
2683 }
2684
2685 #[test]
2686 fn miri_description_null_byte_handling() {
2687 let with_null = "test\0value";
2689 let result = Description::new(with_null);
2690 assert!(result.is_err());
2691
2692 if let Err(err) = result {
2694 assert!(matches!(err, ValidationError::NullByteInDescription));
2695 }
2696 }
2697
2698 #[test]
2699 fn miri_kms_alias_character_iteration() {
2700 let test_cases = vec![
2702 "alias/test-key",
2703 "alias/test_key_2024",
2704 "alias/app/prod/key",
2705 "alias/UPPERCASE_KEY",
2706 ];
2707
2708 for alias in test_cases {
2709 let _ = validate_kms_alias(alias);
2710 }
2711 }
2712}
2713
2714#[cfg(test)]
2715#[allow(clippy::expect_used)] mod miri_proptest {
2717 use super::*;
2718 use proptest::prelude::*;
2719
2720 proptest! {
2721 #![proptest_config(ProptestConfig {
2722 cases: if cfg!(miri) { 5 } else { 1000 },
2723 failure_persistence: None, .. ProptestConfig::default()
2725 })]
2726
2727 #[test]
2728 fn miri_proptest_app_name_utf8_safety(
2729 s in prop::string::string_regex("[\\p{L}\\p{N} ]{1,50}").expect("valid regex")
2730 ) {
2731 let _ = AppName::new(&s);
2732 }
2734 }
2735}