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 let mut update_custom_kms_alias = false;
1087
1088 if let Some(ref profile) = existing_app.profile {
1089 if repo_url.is_some()
1091 && (profile.repo_url.is_none()
1092 || profile
1093 .repo_url
1094 .as_ref()
1095 .is_some_and(|u| u.trim().is_empty()))
1096 {
1097 update_repo_url = true;
1098 needs_update = true;
1099 }
1100
1101 if description.is_some()
1103 && (profile.description.is_none()
1104 || profile
1105 .description
1106 .as_ref()
1107 .is_some_and(|d| d.as_str().trim().is_empty()))
1108 {
1109 update_description = true;
1110 needs_update = true;
1111 }
1112
1113 if custom_kms_alias.is_some()
1115 && (profile.custom_kms_alias.is_none()
1116 || profile
1117 .custom_kms_alias
1118 .as_ref()
1119 .is_some_and(|k| k.trim().is_empty()))
1120 {
1121 update_custom_kms_alias = true;
1122 needs_update = true;
1123 }
1124 }
1125
1126 if needs_update {
1127 log::debug!("🔄 Updating fields for existing application '{}'", name);
1128 if update_repo_url {
1129 log::debug!(
1130 " Setting repo_url: {}",
1131 repo_url.as_deref().unwrap_or("None")
1132 );
1133 }
1134 if update_description {
1135 log::debug!(
1136 " Setting description: {}",
1137 description.as_deref().unwrap_or("None")
1138 );
1139 }
1140 if update_custom_kms_alias {
1141 log::debug!(
1142 " Setting custom_kms_alias: {}",
1143 custom_kms_alias.as_deref().unwrap_or("None")
1144 );
1145 }
1146
1147 let profile = existing_app.profile.as_ref().ok_or_else(|| {
1149 VeracodeError::InvalidResponse(format!("Application '{}' has no profile", name))
1150 })?;
1151 let update_request = UpdateApplicationRequest {
1152 profile: UpdateApplicationProfile {
1153 name: Some(profile.name.clone()),
1154 description: if update_description {
1155 description.map(Description::new).transpose()?
1156 } else {
1157 profile.description.clone()
1158 },
1159 business_unit: profile.business_unit.clone(),
1160 business_owners: profile.business_owners.clone(),
1161 business_criticality: profile.business_criticality, policies: profile.policies.clone(),
1163 teams: profile.teams.clone(), tags: profile.tags.clone(),
1165 custom_fields: profile.custom_fields.clone(),
1166 custom_kms_alias: if update_custom_kms_alias {
1167 custom_kms_alias
1168 } else {
1169 profile.custom_kms_alias.clone()
1170 },
1171 repo_url: if update_repo_url {
1172 repo_url
1173 } else {
1174 profile.repo_url.clone()
1175 },
1176 },
1177 };
1178
1179 let guid = AppGuid::new(&existing_app.guid)?;
1180 return self.update_application(&guid, &update_request).await;
1181 }
1182
1183 return Ok(existing_app);
1184 }
1185
1186 let teams = if let Some(names) = team_names {
1190 let identity_api = self.identity_api();
1191 let mut resolved_teams = Vec::new();
1192
1193 for team_name in names {
1194 match identity_api.get_team_guid_by_name(&team_name).await {
1195 Ok(Some(team_guid)) => {
1196 resolved_teams.push(Team {
1197 guid: Some(team_guid),
1198 team_id: None,
1199 team_name: None, team_legacy_id: None,
1201 });
1202 }
1203 Ok(None) => {
1204 return Err(VeracodeError::NotFound(format!(
1205 "Team '{}' not found",
1206 team_name
1207 )));
1208 }
1209 Err(identity_err) => {
1210 return Err(VeracodeError::InvalidResponse(format!(
1211 "Failed to lookup team '{}': {}",
1212 team_name, identity_err
1213 )));
1214 }
1215 }
1216 }
1217
1218 Some(resolved_teams)
1219 } else {
1220 None
1221 };
1222
1223 let create_request = CreateApplicationRequest {
1224 profile: CreateApplicationProfile {
1225 name: AppName::new(name)?,
1226 business_criticality,
1227 description: description.map(Description::new).transpose()?,
1228 business_unit: None,
1229 business_owners: None,
1230 policies: None,
1231 teams,
1232 tags: None,
1233 custom_fields: None,
1234 custom_kms_alias,
1235 repo_url,
1236 },
1237 };
1238
1239 self.create_application(&create_request).await
1240 }
1241
1242 pub async fn create_application_if_not_exists_with_team_guids(
1263 &self,
1264 name: &str,
1265 business_criticality: BusinessCriticality,
1266 description: Option<String>,
1267 team_guids: Option<Vec<String>>,
1268 ) -> Result<Application, VeracodeError> {
1269 if let Some(existing_app) = self.get_application_by_name(name).await? {
1271 return Ok(existing_app);
1272 }
1273
1274 let teams = team_guids.map(|guids| {
1278 guids
1279 .into_iter()
1280 .map(|team_guid| Team {
1281 guid: Some(team_guid),
1282 team_id: None, team_name: None, team_legacy_id: None, })
1286 .collect()
1287 });
1288
1289 let create_request = CreateApplicationRequest {
1290 profile: CreateApplicationProfile {
1291 name: AppName::new(name)?,
1292 business_criticality,
1293 description: description.map(Description::new).transpose()?,
1294 business_unit: None,
1295 business_owners: None,
1296 policies: None,
1297 teams,
1298 tags: None,
1299 custom_fields: None,
1300 custom_kms_alias: None,
1301 repo_url: None,
1302 },
1303 };
1304
1305 self.create_application(&create_request).await
1306 }
1307
1308 pub async fn create_application_if_not_exists_simple(
1328 &self,
1329 name: &str,
1330 business_criticality: BusinessCriticality,
1331 description: Option<String>,
1332 ) -> Result<Application, VeracodeError> {
1333 self.create_application_if_not_exists(
1334 name,
1335 business_criticality,
1336 description,
1337 None,
1338 None,
1339 None,
1340 )
1341 .await
1342 }
1343
1344 pub async fn enable_application_encryption(
1386 &self,
1387 app_guid: &AppGuid,
1388 kms_alias: &str,
1389 ) -> Result<Application, VeracodeError> {
1390 validate_kms_alias(kms_alias).map_err(VeracodeError::InvalidConfig)?;
1392
1393 let current_app = self.get_application(app_guid).await?;
1395
1396 let profile = current_app
1397 .profile
1398 .ok_or_else(|| VeracodeError::NotFound("Application profile not found".to_string()))?;
1399
1400 let update_request = UpdateApplicationRequest {
1402 profile: UpdateApplicationProfile {
1403 name: Some(profile.name),
1404 description: profile.description,
1405 business_unit: profile.business_unit,
1406 business_owners: profile.business_owners,
1407 business_criticality: profile.business_criticality,
1408 policies: profile.policies,
1409 teams: profile.teams,
1410 tags: profile.tags,
1411 custom_fields: profile.custom_fields,
1412 custom_kms_alias: Some(kms_alias.to_string()),
1413 repo_url: profile.repo_url,
1414 },
1415 };
1416
1417 self.update_application(app_guid, &update_request).await
1418 }
1419
1420 pub async fn change_encryption_key(
1439 &self,
1440 app_guid: &AppGuid,
1441 new_kms_alias: &str,
1442 ) -> Result<Application, VeracodeError> {
1443 validate_kms_alias(new_kms_alias).map_err(VeracodeError::InvalidConfig)?;
1445
1446 let current_app = self.get_application(app_guid).await?;
1448
1449 let profile = current_app
1450 .profile
1451 .ok_or_else(|| VeracodeError::NotFound("Application profile not found".to_string()))?;
1452
1453 let update_request = UpdateApplicationRequest {
1455 profile: UpdateApplicationProfile {
1456 name: Some(profile.name),
1457 description: profile.description,
1458 business_unit: profile.business_unit,
1459 business_owners: profile.business_owners,
1460 business_criticality: profile.business_criticality,
1461 policies: profile.policies,
1462 teams: profile.teams,
1463 tags: profile.tags,
1464 custom_fields: profile.custom_fields,
1465 custom_kms_alias: Some(new_kms_alias.to_string()),
1466 repo_url: profile.repo_url,
1467 },
1468 };
1469
1470 self.update_application(app_guid, &update_request).await
1471 }
1472
1473 pub async fn get_application_encryption_status(
1490 &self,
1491 app_guid: &AppGuid,
1492 ) -> Result<Option<String>, VeracodeError> {
1493 let app = self.get_application(app_guid).await?;
1494
1495 Ok(app.profile.and_then(|profile| profile.custom_kms_alias))
1497 }
1498}
1499
1500pub fn validate_kms_alias(alias: &str) -> Result<(), String> {
1523 if !alias.starts_with("alias/") {
1525 return Err("KMS alias must start with 'alias/'".to_string());
1526 }
1527
1528 if alias.len() < 8 || alias.len() > 256 {
1530 return Err("KMS alias must be between 8 and 256 characters long".to_string());
1531 }
1532
1533 let alias_name = alias
1535 .strip_prefix("alias/")
1536 .ok_or_else(|| "KMS alias must start with 'alias/'".to_string())?;
1537
1538 if alias_name.starts_with("aws") || alias_name.ends_with("aws") {
1540 return Err("KMS alias cannot begin or end with 'aws' (reserved by AWS)".to_string());
1541 }
1542
1543 if !alias_name
1545 .chars()
1546 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '/')
1547 {
1548 return Err("KMS alias can only contain alphanumeric characters, hyphens, underscores, and forward slashes".to_string());
1549 }
1550
1551 if alias_name.is_empty() {
1553 return Err("KMS alias name cannot be empty after 'alias/' prefix".to_string());
1554 }
1555
1556 Ok(())
1557}
1558
1559#[cfg(test)]
1560#[allow(clippy::expect_used)]
1561mod tests {
1562 use super::*;
1563
1564 #[test]
1565 fn test_query_params() {
1566 let query = ApplicationQuery::new()
1567 .with_name("test_app")
1568 .with_policy_compliance("PASSED")
1569 .with_page(1)
1570 .with_size(50);
1571
1572 let params = query.to_query_params();
1573 assert!(params.contains(&("name".to_string(), "test_app".to_string())));
1574 assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1575 assert!(params.contains(&("page".to_string(), "1".to_string())));
1576 assert!(params.contains(&("size".to_string(), "50".to_string())));
1577 }
1578
1579 #[test]
1580 fn test_application_query_builder() {
1581 let query = ApplicationQuery::new()
1582 .with_name("MyApp")
1583 .with_policy_compliance("DID_NOT_PASS")
1584 .with_modified_after("2023-01-01T00:00:00.000Z")
1585 .with_page(2)
1586 .with_size(25);
1587
1588 assert_eq!(query.name, Some("MyApp".to_string()));
1589 assert_eq!(query.policy_compliance, Some("DID_NOT_PASS".to_string()));
1590 assert_eq!(
1591 query.modified_after,
1592 Some("2023-01-01T00:00:00.000Z".to_string())
1593 );
1594 assert_eq!(query.page, Some(2));
1595 assert_eq!(query.size, Some(25));
1596 }
1597
1598 #[test]
1599 fn test_application_query_normalize_defaults() {
1600 let query = ApplicationQuery::new();
1601 let normalized = query.normalize().expect("should normalize");
1602
1603 assert_eq!(normalized.size, Some(50)); assert_eq!(normalized.page, None);
1606 }
1607
1608 #[test]
1609 fn test_application_query_normalize_valid_values() {
1610 let query = ApplicationQuery::new().with_page(10).with_size(100);
1611 let normalized = query.normalize().expect("should normalize");
1612
1613 assert_eq!(normalized.page, Some(10));
1614 assert_eq!(normalized.size, Some(100));
1615 }
1616
1617 #[test]
1618 fn test_application_query_normalize_zero_size() {
1619 let query = ApplicationQuery::new().with_size(0);
1620 let result = query.normalize();
1621
1622 assert!(result.is_err());
1623 }
1624
1625 #[test]
1626 fn test_application_query_normalize_caps_large_size() {
1627 let query = ApplicationQuery::new().with_size(10000);
1628 let normalized = query.normalize().expect("should cap to max");
1629
1630 assert_eq!(normalized.size, Some(500));
1632 }
1633
1634 #[test]
1635 fn test_application_query_normalize_caps_large_page() {
1636 let query = ApplicationQuery::new().with_page(50000);
1637 let normalized = query.normalize().expect("should cap to max");
1638
1639 assert_eq!(normalized.page, Some(10000));
1641 }
1642
1643 #[test]
1644 fn test_query_params_url_encoding_normal() {
1645 let query = ApplicationQuery::new()
1646 .with_name("MyApp")
1647 .with_policy_compliance("PASSED");
1648
1649 let params = query.to_query_params();
1650
1651 assert!(params.contains(&("name".to_string(), "MyApp".to_string())));
1653 assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1654 }
1655
1656 #[test]
1657 fn test_query_params_url_encoding_special_chars() {
1658 let query = ApplicationQuery::new()
1659 .with_name("My App & Co")
1660 .with_policy_compliance("DID_NOT_PASS");
1661
1662 let params = query.to_query_params();
1663
1664 assert!(params.contains(&("name".to_string(), "My%20App%20%26%20Co".to_string())));
1666 }
1667
1668 #[test]
1669 fn test_query_params_injection_attempt() {
1670 let query = ApplicationQuery::new().with_name("foo&admin=true");
1672
1673 let params = query.to_query_params();
1674
1675 assert!(params.contains(&("name".to_string(), "foo%26admin%3Dtrue".to_string())));
1677
1678 assert!(!params.iter().any(|(key, _)| key == "admin"));
1680 }
1681
1682 #[test]
1683 fn test_query_params_equals_injection() {
1684 let query = ApplicationQuery::new().with_name("test=malicious");
1686
1687 let params = query.to_query_params();
1688
1689 assert!(params.contains(&("name".to_string(), "test%3Dmalicious".to_string())));
1691 }
1692
1693 #[test]
1694 fn test_query_params_semicolon_injection() {
1695 let query = ApplicationQuery::new().with_name("test;rm -rf /");
1697
1698 let params = query.to_query_params();
1699
1700 assert!(params.contains(&("name".to_string(), "test%3Brm%20-rf%20%2F".to_string())));
1702 }
1703
1704 #[test]
1705 fn test_query_params_multiple_fields_with_encoding() {
1706 let mut query = ApplicationQuery::new()
1707 .with_name("App & Test")
1708 .with_policy_compliance("PASSED")
1709 .with_modified_after("2023-01-01T00:00:00.000Z");
1710 query.business_unit = Some("Test & Development".to_string());
1711
1712 let params = query.to_query_params();
1713
1714 assert!(params.contains(&("name".to_string(), "App%20%26%20Test".to_string())));
1716 assert!(params.contains(&("policy_compliance".to_string(), "PASSED".to_string())));
1717 assert!(params.contains(&(
1718 "modified_after".to_string(),
1719 "2023-01-01T00%3A00%3A00.000Z".to_string()
1720 )));
1721 assert!(params.contains(&(
1722 "business_unit".to_string(),
1723 "Test%20%26%20Development".to_string()
1724 )));
1725 }
1726
1727 #[test]
1728 fn test_create_application_request_with_teams() {
1729 let team_names = vec!["Security Team".to_string(), "Development Team".to_string()];
1730 let teams: Vec<Team> = team_names
1731 .into_iter()
1732 .map(|team_name| Team {
1733 guid: None,
1734 team_id: None,
1735 team_name: Some(team_name),
1736 team_legacy_id: None,
1737 })
1738 .collect();
1739
1740 let request = CreateApplicationRequest {
1741 profile: CreateApplicationProfile {
1742 name: AppName::new("Test Application").expect("valid name"),
1743 business_criticality: BusinessCriticality::Medium,
1744 description: Some(Description::new("Test description").expect("valid description")),
1745 business_unit: None,
1746 business_owners: None,
1747 policies: None,
1748 teams: Some(teams.clone()),
1749 tags: None,
1750 custom_fields: None,
1751 custom_kms_alias: None,
1752 repo_url: None,
1753 },
1754 };
1755
1756 assert_eq!(request.profile.name.as_str(), "Test Application");
1757 assert_eq!(
1758 request.profile.business_criticality,
1759 BusinessCriticality::Medium
1760 );
1761 assert!(request.profile.teams.is_some());
1762
1763 let request_teams = request.profile.teams.expect("teams should be present");
1764 assert_eq!(request_teams.len(), 2);
1765 assert_eq!(
1766 request_teams
1767 .first()
1768 .expect("should have first team")
1769 .team_name,
1770 Some("Security Team".to_string())
1771 );
1772 assert_eq!(
1773 request_teams
1774 .get(1)
1775 .expect("should have second team")
1776 .team_name,
1777 Some("Development Team".to_string())
1778 );
1779 }
1780
1781 #[test]
1782 fn test_create_application_request_with_team_guids() {
1783 let team_guids = vec!["team-guid-1".to_string(), "team-guid-2".to_string()];
1784 let teams: Vec<Team> = team_guids
1785 .into_iter()
1786 .map(|team_guid| Team {
1787 guid: Some(team_guid),
1788 team_id: None,
1789 team_name: None,
1790 team_legacy_id: None,
1791 })
1792 .collect();
1793
1794 let request = CreateApplicationRequest {
1795 profile: CreateApplicationProfile {
1796 name: AppName::new("Test Application").expect("valid name"),
1797 business_criticality: BusinessCriticality::High,
1798 description: Some(Description::new("Test description").expect("valid description")),
1799 business_unit: None,
1800 business_owners: None,
1801 policies: None,
1802 teams: Some(teams.clone()),
1803 tags: None,
1804 custom_fields: None,
1805 custom_kms_alias: None,
1806 repo_url: None,
1807 },
1808 };
1809
1810 assert_eq!(request.profile.name.as_str(), "Test Application");
1811 assert_eq!(
1812 request.profile.business_criticality,
1813 BusinessCriticality::High
1814 );
1815 assert!(request.profile.teams.is_some());
1816
1817 let request_teams = request.profile.teams.expect("teams should be present");
1818 assert_eq!(request_teams.len(), 2);
1819 assert_eq!(
1820 request_teams.first().expect("should have first team").guid,
1821 Some("team-guid-1".to_string())
1822 );
1823 assert_eq!(
1824 request_teams.get(1).expect("should have second team").guid,
1825 Some("team-guid-2".to_string())
1826 );
1827 assert!(
1828 request_teams
1829 .first()
1830 .expect("should have first team")
1831 .team_name
1832 .is_none()
1833 );
1834 assert!(
1835 request_teams
1836 .get(1)
1837 .expect("should have second team")
1838 .team_name
1839 .is_none()
1840 );
1841 }
1842
1843 #[test]
1844 fn test_create_application_profile_cmek_serialization() {
1845 let profile_with_cmek = CreateApplicationProfile {
1847 name: AppName::new("Test Application").expect("valid name"),
1848 business_criticality: BusinessCriticality::High,
1849 description: None,
1850 business_unit: None,
1851 business_owners: None,
1852 policies: None,
1853 teams: None,
1854 tags: None,
1855 custom_fields: None,
1856 custom_kms_alias: Some("alias/my-app-key".to_string()),
1857 repo_url: None,
1858 };
1859
1860 let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
1861 assert!(json.contains("custom_kms_alias"));
1862 assert!(json.contains("alias/my-app-key"));
1863
1864 let profile_without_cmek = CreateApplicationProfile {
1866 name: AppName::new("Test Application").expect("valid name"),
1867 business_criticality: BusinessCriticality::High,
1868 description: None,
1869 business_unit: None,
1870 business_owners: None,
1871 policies: None,
1872 teams: None,
1873 tags: None,
1874 custom_fields: None,
1875 custom_kms_alias: None,
1876 repo_url: None,
1877 };
1878
1879 let json = serde_json::to_string(&profile_without_cmek).expect("should serialize to json");
1880 assert!(!json.contains("custom_kms_alias"));
1881 }
1882
1883 #[test]
1884 fn test_update_application_profile_cmek_serialization() {
1885 let profile_with_cmek = UpdateApplicationProfile {
1887 name: Some(AppName::new("Updated Application").expect("valid name")),
1888 description: None,
1889 business_unit: None,
1890 business_owners: None,
1891 business_criticality: BusinessCriticality::Medium,
1892 policies: None,
1893 teams: None,
1894 tags: None,
1895 custom_fields: None,
1896 custom_kms_alias: Some("alias/updated-key".to_string()),
1897 repo_url: None,
1898 };
1899
1900 let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
1901 assert!(json.contains("custom_kms_alias"));
1902 assert!(json.contains("alias/updated-key"));
1903
1904 let profile_without_cmek = UpdateApplicationProfile {
1906 name: Some(AppName::new("Updated Application").expect("valid name")),
1907 description: None,
1908 business_unit: None,
1909 business_owners: None,
1910 business_criticality: BusinessCriticality::Medium,
1911 policies: None,
1912 teams: None,
1913 tags: None,
1914 custom_fields: None,
1915 custom_kms_alias: None,
1916 repo_url: None,
1917 };
1918
1919 let json = serde_json::to_string(&profile_without_cmek).expect("should serialize to json");
1920 assert!(!json.contains("custom_kms_alias"));
1921 }
1922
1923 #[test]
1924 fn test_validate_kms_alias_valid_cases() {
1925 assert!(validate_kms_alias("alias/my-app-key").is_ok());
1927 assert!(validate_kms_alias("alias/my_app_key_2024").is_ok());
1928 assert!(validate_kms_alias("alias/app/environment/key").is_ok());
1929 assert!(validate_kms_alias("alias/123-test-key").is_ok());
1930 }
1931
1932 #[test]
1933 fn test_validate_kms_alias_invalid_cases() {
1934 assert!(validate_kms_alias("my-app-key").is_err());
1936 assert!(validate_kms_alias("invalid-alias").is_err());
1937
1938 assert!(validate_kms_alias("arn:aws:kms:us-east-1:123456789:alias/my-key").is_err());
1940
1941 assert!(validate_kms_alias("alias/aws-managed").is_err());
1943 assert!(validate_kms_alias("alias/my-key-aws").is_err());
1944
1945 assert!(validate_kms_alias("alias/").is_err());
1947
1948 assert!(validate_kms_alias("alias/a").is_err());
1950
1951 assert!(validate_kms_alias("alias/my@key").is_err());
1953 assert!(validate_kms_alias("alias/my key").is_err());
1954 assert!(validate_kms_alias("alias/my.key").is_err());
1955
1956 let long_alias = format!("alias/{}", "a".repeat(251));
1958 assert!(validate_kms_alias(&long_alias).is_err());
1959 }
1960
1961 #[test]
1962 fn test_cmek_backward_compatibility() {
1963 let legacy_profile = CreateApplicationProfile {
1965 name: AppName::new("Legacy Application").expect("valid name"),
1966 business_criticality: BusinessCriticality::High,
1967 description: Some(
1968 Description::new("Legacy app without CMEK").expect("valid description"),
1969 ),
1970 business_unit: None,
1971 business_owners: None,
1972 policies: None,
1973 teams: None,
1974 tags: None,
1975 custom_fields: None,
1976 custom_kms_alias: None,
1977 repo_url: None,
1978 };
1979
1980 let request = CreateApplicationRequest {
1981 profile: legacy_profile,
1982 };
1983
1984 let json = serde_json::to_string(&request).expect("should serialize to json");
1986
1987 assert!(!json.contains("custom_kms_alias"));
1989
1990 assert!(json.contains("name"));
1992 assert!(json.contains("business_criticality"));
1993 assert!(json.contains("Legacy Application"));
1994
1995 let _deserialized: CreateApplicationRequest =
1997 serde_json::from_str(&json).expect("should deserialize json");
1998 }
1999
2000 #[test]
2001 fn test_cmek_field_deserialization() {
2002 let json_with_cmek = r#"{
2004 "profile": {
2005 "name": "Test App",
2006 "business_criticality": "HIGH",
2007 "custom_kms_alias": "alias/test-key"
2008 }
2009 }"#;
2010
2011 let request: CreateApplicationRequest =
2012 serde_json::from_str(json_with_cmek).expect("should deserialize json");
2013 assert_eq!(
2014 request.profile.custom_kms_alias,
2015 Some("alias/test-key".to_string())
2016 );
2017
2018 let json_without_cmek = r#"{
2020 "profile": {
2021 "name": "Test App",
2022 "business_criticality": "HIGH"
2023 }
2024 }"#;
2025
2026 let request: CreateApplicationRequest =
2027 serde_json::from_str(json_without_cmek).expect("should deserialize json");
2028 assert_eq!(request.profile.custom_kms_alias, None);
2029 }
2030
2031 #[test]
2032 fn test_create_application_profile_with_cmek() {
2033 let profile_with_cmek = CreateApplicationProfile {
2035 name: AppName::new("MyApplication").expect("valid name"),
2036 business_criticality: BusinessCriticality::High,
2037 description: Some(
2038 Description::new("Application created for assessment scanning")
2039 .expect("valid description"),
2040 ),
2041 business_unit: None,
2042 business_owners: None,
2043 policies: None,
2044 teams: None,
2045 tags: None,
2046 custom_fields: None,
2047 custom_kms_alias: Some("alias/my-encryption-key".to_string()),
2048 repo_url: Some("https://github.com/user/repo".to_string()),
2049 };
2050
2051 let request = CreateApplicationRequest {
2052 profile: profile_with_cmek,
2053 };
2054
2055 let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2056
2057 println!("\n📦 Example JSON Payload sent to Veracode API:");
2059 println!("{}", json);
2060 println!();
2061
2062 assert!(json.contains("custom_kms_alias"));
2063 assert!(json.contains("alias/my-encryption-key"));
2064
2065 let deserialized: CreateApplicationRequest =
2067 serde_json::from_str(&json).expect("should deserialize json");
2068 assert_eq!(
2069 deserialized.profile.custom_kms_alias,
2070 Some("alias/my-encryption-key".to_string())
2071 );
2072 }
2073
2074 #[test]
2075 fn test_create_application_profile_without_cmek() {
2076 let profile_without_cmek = CreateApplicationProfile {
2078 name: AppName::new("MyApplication").expect("valid name"),
2079 business_criticality: BusinessCriticality::High,
2080 description: Some(
2081 Description::new("Application created for assessment scanning")
2082 .expect("valid description"),
2083 ),
2084 business_unit: None,
2085 business_owners: None,
2086 policies: None,
2087 teams: None,
2088 tags: None,
2089 custom_fields: None,
2090 custom_kms_alias: None,
2091 repo_url: Some("https://github.com/user/repo".to_string()),
2092 };
2093
2094 let request = CreateApplicationRequest {
2095 profile: profile_without_cmek,
2096 };
2097
2098 let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2099
2100 println!("\n📦 Example JSON Payload sent to Veracode API (without --cmek):");
2102 println!("{}", json);
2103 println!("⚠️ Notice: 'custom_kms_alias' field is NOT included in the payload");
2104 println!();
2105
2106 assert!(!json.contains("custom_kms_alias"));
2107
2108 let deserialized: CreateApplicationRequest =
2110 serde_json::from_str(&json).expect("should deserialize json");
2111 assert_eq!(deserialized.profile.custom_kms_alias, None);
2112 }
2113
2114 #[test]
2115 fn test_update_application_profile_with_cmek() {
2116 let profile_with_cmek = UpdateApplicationProfile {
2118 name: Some(AppName::new("Updated Application").expect("valid name")),
2119 description: Some(Description::new("Updated description").expect("valid description")),
2120 business_unit: None,
2121 business_owners: None,
2122 business_criticality: BusinessCriticality::Medium,
2123 policies: None,
2124 teams: None,
2125 tags: None,
2126 custom_fields: None,
2127 custom_kms_alias: Some("alias/updated-key".to_string()),
2128 repo_url: None,
2129 };
2130
2131 let json = serde_json::to_string(&profile_with_cmek).expect("should serialize to json");
2132 assert!(json.contains("custom_kms_alias"));
2133 assert!(json.contains("alias/updated-key"));
2134 }
2135
2136 #[test]
2139 fn test_cmek_enabled_payload_structure() {
2140 let request = CreateApplicationRequest {
2141 profile: CreateApplicationProfile {
2142 name: AppName::new("MyApplication").expect("valid name"),
2143 business_criticality: BusinessCriticality::High,
2144 description: Some(
2145 Description::new("Application created for assessment scanning")
2146 .expect("valid description"),
2147 ),
2148 business_unit: None,
2149 business_owners: None,
2150 policies: None,
2151 teams: None,
2152 tags: None,
2153 custom_fields: None,
2154 custom_kms_alias: Some("alias/my-encryption-key".to_string()),
2155 repo_url: Some("https://github.com/user/repo".to_string()),
2156 },
2157 };
2158
2159 let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2160
2161 let expected_keys = vec![
2163 "profile",
2164 "name",
2165 "business_criticality",
2166 "description",
2167 "custom_kms_alias",
2168 "repo_url",
2169 ];
2170
2171 for key in expected_keys {
2172 assert!(
2173 json.contains(&format!("\"{key}\"")),
2174 "Expected key '{}' not found in payload",
2175 key
2176 );
2177 }
2178
2179 assert!(json.contains("\"custom_kms_alias\": \"alias/my-encryption-key\""));
2181 assert!(json.contains("\"business_criticality\": \"HIGH\""));
2182 assert!(json.contains("\"name\": \"MyApplication\""));
2183
2184 let parsed: serde_json::Value =
2186 serde_json::from_str(&json).expect("should deserialize json");
2187 assert_eq!(
2188 parsed
2189 .get("profile")
2190 .and_then(|p| p.get("custom_kms_alias"))
2191 .and_then(|v| v.as_str())
2192 .expect("should have custom_kms_alias"),
2193 "alias/my-encryption-key"
2194 );
2195 assert_eq!(
2196 parsed
2197 .get("profile")
2198 .and_then(|p| p.get("business_criticality"))
2199 .and_then(|v| v.as_str())
2200 .expect("should have business_criticality"),
2201 "HIGH"
2202 );
2203 }
2204
2205 #[test]
2208 fn test_cmek_disabled_payload_structure() {
2209 let request = CreateApplicationRequest {
2210 profile: CreateApplicationProfile {
2211 name: AppName::new("MyApplication").expect("valid name"),
2212 business_criticality: BusinessCriticality::High,
2213 description: Some(
2214 Description::new("Application created for assessment scanning")
2215 .expect("valid description"),
2216 ),
2217 business_unit: None,
2218 business_owners: None,
2219 policies: None,
2220 teams: None,
2221 tags: None,
2222 custom_fields: None,
2223 custom_kms_alias: None, repo_url: Some("https://github.com/user/repo".to_string()),
2225 },
2226 };
2227
2228 let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2229
2230 assert!(
2232 !json.contains("custom_kms_alias"),
2233 "custom_kms_alias should not be present when None"
2234 );
2235
2236 assert!(json.contains("\"name\": \"MyApplication\""));
2238 assert!(json.contains("\"business_criticality\": \"HIGH\""));
2239 assert!(json.contains("\"repo_url\""));
2240
2241 let parsed: serde_json::Value =
2243 serde_json::from_str(&json).expect("should deserialize json");
2244 assert_eq!(
2245 parsed
2246 .get("profile")
2247 .and_then(|p| p.get("name"))
2248 .and_then(|v| v.as_str())
2249 .expect("should have name"),
2250 "MyApplication"
2251 );
2252 assert_eq!(
2253 parsed
2254 .get("profile")
2255 .and_then(|p| p.get("business_criticality"))
2256 .and_then(|v| v.as_str())
2257 .expect("should have business_criticality"),
2258 "HIGH"
2259 );
2260
2261 assert!(
2263 !parsed
2264 .get("profile")
2265 .and_then(|p| p.as_object())
2266 .expect("should have profile object")
2267 .contains_key("custom_kms_alias"),
2268 "custom_kms_alias key should not exist in JSON object"
2269 );
2270 }
2271
2272 #[test]
2274 fn test_cmek_alias_format_variations() {
2275 let test_cases = vec![
2277 "alias/production-key",
2278 "alias/dev_environment_key",
2279 "alias/app/prod/2024",
2280 "alias/KEY123",
2281 "alias/my-app-key-2024",
2282 ];
2283
2284 for alias in test_cases {
2285 let request = CreateApplicationRequest {
2286 profile: CreateApplicationProfile {
2287 name: AppName::new("TestApp").expect("valid name"),
2288 business_criticality: BusinessCriticality::Medium,
2289 description: None,
2290 business_unit: None,
2291 business_owners: None,
2292 policies: None,
2293 teams: None,
2294 tags: None,
2295 custom_fields: None,
2296 custom_kms_alias: Some(alias.to_string()),
2297 repo_url: None,
2298 },
2299 };
2300
2301 let json = serde_json::to_string(&request).expect("should serialize to json");
2302 assert!(
2303 json.contains(alias),
2304 "Alias '{}' should be present in payload",
2305 alias
2306 );
2307
2308 let parsed: CreateApplicationRequest =
2310 serde_json::from_str(&json).expect("should deserialize json");
2311 assert_eq!(parsed.profile.custom_kms_alias, Some(alias.to_string()));
2312 }
2313 }
2314
2315 #[test]
2317 fn test_complete_application_profile_with_cmek() {
2318 let request = CreateApplicationRequest {
2319 profile: CreateApplicationProfile {
2320 name: AppName::new("CompleteApplication").expect("valid name"),
2321 business_criticality: BusinessCriticality::VeryHigh,
2322 description: Some(
2323 Description::new("Full featured application with CMEK")
2324 .expect("valid description"),
2325 ),
2326 business_unit: Some(BusinessUnit {
2327 id: Some(123),
2328 name: Some("Engineering".to_string()),
2329 guid: Some("bu-guid-123".to_string()),
2330 }),
2331 business_owners: Some(vec![BusinessOwner {
2332 email: Some("owner@example.com".to_string()),
2333 name: Some("App Owner".to_string()),
2334 }]),
2335 policies: None,
2336 teams: Some(vec![Team {
2337 guid: Some("team-guid-456".to_string()),
2338 team_id: None,
2339 team_name: None,
2340 team_legacy_id: None,
2341 }]),
2342 tags: Some("production,encrypted".to_string()),
2343 custom_fields: Some(vec![CustomField {
2344 name: Some("Environment".to_string()),
2345 value: Some("Production".to_string()),
2346 }]),
2347 custom_kms_alias: Some("alias/production-cmek-key".to_string()),
2348 repo_url: Some("https://github.com/company/secure-app".to_string()),
2349 },
2350 };
2351
2352 let json = serde_json::to_string_pretty(&request).expect("should serialize to json");
2353
2354 assert!(json.contains("\"custom_kms_alias\": \"alias/production-cmek-key\""));
2356 assert!(json.contains("\"business_unit\""));
2357 assert!(json.contains("\"business_owners\""));
2358 assert!(json.contains("\"teams\""));
2359 assert!(json.contains("\"tags\""));
2360 assert!(json.contains("\"custom_fields\""));
2361
2362 let parsed: CreateApplicationRequest =
2364 serde_json::from_str(&json).expect("should deserialize json");
2365 assert_eq!(
2366 parsed.profile.custom_kms_alias,
2367 Some("alias/production-cmek-key".to_string())
2368 );
2369 assert!(parsed.profile.business_unit.is_some());
2370 assert!(parsed.profile.business_owners.is_some());
2371 }
2372}
2373
2374#[cfg(test)]
2375#[allow(clippy::expect_used)] mod proptests {
2377 use super::*;
2378 use proptest::prelude::*;
2379
2380 fn valid_kms_alias_strategy() -> impl Strategy<Value = String> {
2382 prop::string::string_regex("[a-zA-Z0-9_/-]{2,250}")
2386 .expect("valid regex pattern for KMS alias")
2387 .prop_map(|s| format!("alias/{}", s))
2388 .prop_filter("Cannot start with aws", |s| {
2389 !s.strip_prefix("alias/").unwrap_or("").starts_with("aws")
2390 })
2391 .prop_filter("Cannot end with aws", |s| {
2392 !s.strip_prefix("alias/").unwrap_or("").ends_with("aws")
2393 })
2394 }
2395
2396 fn invalid_kms_alias_strategy() -> impl Strategy<Value = String> {
2397 prop_oneof![
2398 prop::string::string_regex("[a-zA-Z0-9_/-]{5,20}")
2400 .expect("valid regex for missing prefix test"),
2401 Just("arn:aws:kms:us-east-1:123456789:alias/test".to_string()),
2403 Just("alias/aws-managed".to_string()),
2405 Just("alias/test-aws".to_string()),
2406 Just("alias/".to_string()),
2408 Just("alias/a".to_string()),
2410 Just("alias/test@key".to_string()),
2412 Just("alias/test key".to_string()),
2413 Just("alias/test.key".to_string()),
2414 prop::string::string_regex("[a-z]{252}")
2416 .expect("valid regex for too long test")
2417 .prop_map(|s| format!("alias/{}", s)),
2418 ]
2419 }
2420
2421 proptest! {
2422 #![proptest_config(ProptestConfig {
2423 cases: if cfg!(miri) { 5 } else { 1000 },
2424 failure_persistence: None, .. ProptestConfig::default()
2426 })]
2427
2428 #[test]
2429 fn proptest_valid_kms_aliases_accepted(alias in valid_kms_alias_strategy()) {
2430 prop_assert!(validate_kms_alias(&alias).is_ok(),
2431 "Valid alias rejected: {}", alias);
2432 }
2433
2434 #[test]
2435 fn proptest_invalid_kms_aliases_rejected(alias in invalid_kms_alias_strategy()) {
2436 prop_assert!(validate_kms_alias(&alias).is_err(),
2437 "Invalid alias accepted: {}", alias);
2438 }
2439
2440 #[test]
2441 fn proptest_kms_alias_length_bounds(
2442 prefix in prop::string::string_regex("[a-zA-Z0-9_/-]{1,7}").expect("valid regex for prefix"),
2443 suffix in prop::string::string_regex("[a-zA-Z0-9_/-]{251,300}").expect("valid regex for suffix")
2444 ) {
2445 let too_short = format!("alias/{}", prefix);
2446 let too_long = format!("alias/{}", suffix);
2447
2448 prop_assert!(validate_kms_alias(&too_short).is_err() || too_short.len() >= 8,
2449 "Too short alias not rejected");
2450 prop_assert!(validate_kms_alias(&too_long).is_err(),
2451 "Too long alias not rejected");
2452 }
2453 }
2454}
2455
2456#[cfg(test)]
2457#[allow(clippy::expect_used)] mod query_proptests {
2459 use super::*;
2460 use crate::validation::encode_query_param;
2461 use proptest::prelude::*;
2462
2463 proptest! {
2464 #![proptest_config(ProptestConfig {
2465 cases: if cfg!(miri) { 5 } else { 1000 },
2466 failure_persistence: None, .. ProptestConfig::default()
2468 })]
2469
2470 #[test]
2471 fn proptest_query_param_no_injection(
2472 value in prop::string::string_regex(".{1,100}").expect("valid regex for query param")
2473 ) {
2474 let encoded = encode_query_param(&value);
2475
2476 prop_assert!(!encoded.contains('&'), "Ampersand not encoded");
2478 prop_assert!(!encoded.contains('=') || value.contains('=') && encoded.contains("%3D"),
2479 "Equals not encoded");
2480 prop_assert!(!encoded.contains(';'), "Semicolon not encoded");
2481 }
2482
2483 #[test]
2484 fn proptest_query_param_path_traversal_encoded(
2485 segments in prop::collection::vec(
2486 prop::string::string_regex("[a-zA-Z0-9]{1,10}").expect("valid regex for path segments"),
2487 1..5
2488 )
2489 ) {
2490 let path_traversal = segments.join("../");
2491 let encoded = encode_query_param(&path_traversal);
2492
2493 prop_assert!(!encoded.contains("../"), "Path traversal sequence '../' not broken by encoding");
2496 prop_assert!(!encoded.contains("..\\"), "Path traversal sequence '..\\' not broken by encoding");
2497 prop_assert!(encoded.contains("%2F") || !path_traversal.contains('/'),
2498 "Forward slash not encoded");
2499 }
2500
2501 #[test]
2502 fn proptest_application_query_to_params_no_key_pollution(
2503 name in prop::option::of(prop::string::string_regex("[a-zA-Z0-9 &=;]{1,50}").expect("valid regex for app name")),
2504 compliance in prop::option::of(Just("PASSED".to_string())),
2505 page in prop::option::of(0u32..1000u32),
2506 size in prop::option::of(1u32..1000u32)
2507 ) {
2508 let mut query = ApplicationQuery::new();
2509 if let Some(n) = name {
2510 query = query.with_name(&n);
2511 }
2512 if let Some(c) = compliance {
2513 query = query.with_policy_compliance(&c);
2514 }
2515 query.page = page;
2516 query.size = size;
2517
2518 let params = query.to_query_params();
2519
2520 let mut seen_keys = std::collections::HashSet::new();
2522 for (key, _) in params.iter() {
2523 prop_assert!(!seen_keys.contains(key),
2524 "Duplicate parameter key: {}", key);
2525 seen_keys.insert(key.clone());
2526 }
2527 }
2528 }
2529}
2530
2531#[cfg(test)]
2532#[allow(clippy::expect_used)] mod pagination_proptests {
2534 use super::*;
2535 use crate::validation::{MAX_PAGE_NUMBER, MAX_PAGE_SIZE};
2536 use proptest::prelude::*;
2537
2538 proptest! {
2539 #![proptest_config(ProptestConfig {
2540 cases: if cfg!(miri) { 5 } else { 1000 },
2541 failure_persistence: None, .. ProptestConfig::default()
2543 })]
2544
2545 #[test]
2546 fn proptest_page_size_bounds_enforced(size in 0u32..u32::MAX) {
2547 match validate_page_size(Some(size)) {
2548 Ok(validated) => {
2549 prop_assert!(validated >= 1, "Zero page size accepted");
2550 prop_assert!(validated <= MAX_PAGE_SIZE,
2551 "Page size {} exceeds maximum {}", validated, MAX_PAGE_SIZE);
2552 }
2553 Err(_) => {
2554 prop_assert_eq!(size, 0, "Non-zero size rejected");
2555 }
2556 }
2557 }
2558
2559 #[test]
2560 fn proptest_page_number_bounds_enforced(page in 0u32..u32::MAX) {
2561 let validated = validate_page_number(Some(page)).expect("page number validation should not fail");
2562
2563 if let Some(p) = validated {
2564 prop_assert!(p <= MAX_PAGE_NUMBER,
2565 "Page number {} exceeds maximum {}", p, MAX_PAGE_NUMBER);
2566 }
2567 }
2568
2569 #[test]
2570 fn proptest_application_query_normalize_safety(
2571 page in prop::option::of(0u32..u32::MAX),
2572 size in prop::option::of(0u32..u32::MAX)
2573 ) {
2574 let mut query = ApplicationQuery::new();
2575 query.page = page;
2576 query.size = size;
2577
2578 match query.normalize() {
2579 Ok(normalized) => {
2580 if let Some(s) = normalized.size {
2582 prop_assert!((1..=MAX_PAGE_SIZE).contains(&s),
2583 "Normalized size {} out of bounds", s);
2584 }
2585 if let Some(p) = normalized.page {
2586 prop_assert!(p <= MAX_PAGE_NUMBER,
2587 "Normalized page {} exceeds maximum", p);
2588 }
2589 }
2590 Err(_) => {
2591 prop_assert_eq!(size, Some(0), "Unexpected normalization error");
2593 }
2594 }
2595 }
2596 }
2597}
2598#[cfg(test)]
2599mod miri_tests {
2600 use super::*;
2601
2602 #[test]
2603 fn miri_business_owner_debug_redaction() {
2604 let owner = BusinessOwner {
2605 email: Some("sensitive@example.com".to_string()),
2606 name: Some("Sensitive Name".to_string()),
2607 };
2608
2609 let debug_str = format!("{:?}", owner);
2610
2611 assert!(debug_str.contains("[REDACTED]"));
2613 assert!(!debug_str.contains("sensitive@example.com"));
2614 assert!(!debug_str.contains("Sensitive Name"));
2615 }
2616
2617 #[test]
2618 fn miri_business_owner_none_fields() {
2619 let owner = BusinessOwner {
2620 email: None,
2621 name: None,
2622 };
2623
2624 let debug_str = format!("{:?}", owner);
2626 assert!(debug_str.contains("[REDACTED]"));
2627 }
2628
2629 #[test]
2630 fn miri_custom_field_debug_redaction() {
2631 let field = CustomField {
2632 name: Some("API_KEY".to_string()),
2633 value: Some("super-secret-key".to_string()),
2634 };
2635
2636 let debug_str = format!("{:?}", field);
2637
2638 assert!(debug_str.contains("API_KEY"));
2640 assert!(debug_str.contains("[REDACTED]"));
2641 assert!(!debug_str.contains("super-secret-key"));
2642 }
2643
2644 #[test]
2645 fn miri_custom_field_none_value() {
2646 let field = CustomField {
2647 name: Some("EMPTY_FIELD".to_string()),
2648 value: None,
2649 };
2650
2651 let debug_str = format!("{:?}", field);
2653 assert!(debug_str.contains("EMPTY_FIELD"));
2654 assert!(debug_str.contains("[REDACTED]"));
2655 }
2656}
2657
2658#[cfg(test)]
2659mod miri_validation_tests {
2660 use super::*;
2661
2662 #[test]
2663 fn miri_app_name_utf8_boundaries() {
2664 let emoji_name = "MyApp 🚀 Test";
2666 let result = AppName::new(emoji_name);
2667 assert!(result.is_ok());
2668
2669 let combining = "Café"; let result = AppName::new(combining);
2672 assert!(result.is_ok());
2673 }
2674
2675 #[test]
2676 fn miri_description_null_byte_handling() {
2677 let with_null = "test\0value";
2679 let result = Description::new(with_null);
2680 assert!(result.is_err());
2681
2682 if let Err(err) = result {
2684 assert!(matches!(err, ValidationError::NullByteInDescription));
2685 }
2686 }
2687
2688 #[test]
2689 fn miri_kms_alias_character_iteration() {
2690 let test_cases = vec![
2692 "alias/test-key",
2693 "alias/test_key_2024",
2694 "alias/app/prod/key",
2695 "alias/UPPERCASE_KEY",
2696 ];
2697
2698 for alias in test_cases {
2699 let _ = validate_kms_alias(alias);
2700 }
2701 }
2702}
2703
2704#[cfg(test)]
2705#[allow(clippy::expect_used)] mod miri_proptest {
2707 use super::*;
2708 use proptest::prelude::*;
2709
2710 proptest! {
2711 #![proptest_config(ProptestConfig {
2712 cases: if cfg!(miri) { 5 } else { 1000 },
2713 failure_persistence: None, .. ProptestConfig::default()
2715 })]
2716
2717 #[test]
2718 fn miri_proptest_app_name_utf8_safety(
2719 s in prop::string::string_regex("[\\p{L}\\p{N} ]{1,50}").expect("valid regex")
2720 ) {
2721 let _ = AppName::new(&s);
2722 }
2724 }
2725}