1use crate::conditions::Satisfiability;
19use crate::consts::{
20 MARKER_CONDITIONAL, MARKER_DYNAMIC, MARKER_ENUM, MARKER_IF_FALSE, MARKER_IF_TRUE, MARKER_INTRINSIC, MARKER_REF,
21};
22use crate::message::render_str_list;
23use crate::model::SemanticModel;
24use crate::resolved_value::effective_path_from_scenario_source_path;
25use crate::resolver::{MapEntry, ResolvedValue};
26use crate::serialization::resolved_value_to_json;
27use serde_json::Value;
28use std::collections::{BTreeSet, HashMap, HashSet};
29
30#[derive(Debug, Clone)]
34pub struct PolicyFinding {
35 pub path: String,
36 pub message: String,
37}
38
39#[derive(Debug, Clone)]
42pub struct ScenarioPolicyFinding {
43 pub path: String,
44 pub message: String,
45 pub source_path: String,
46}
47
48const IDENTITY_DOCUMENT_KEYS: &[&str] = &["Statement", "Version"];
49const VERSION_VALUES: &[&str] = &["2008-10-17", "2012-10-17"];
50const EFFECT_VALUES: &[&str] = &["Allow", "Deny"];
51const IDENTITY_STATEMENT_KEYS: &[&str] =
52 &["Action", "Condition", "Effect", "NotAction", "NotResource", "Resource", "Sid"];
53
54const RESOURCE_ARN_PATTERN: &str =
57 "^(arn:(aws[A-Za-z\\-]*?|[A-Za-z?*\\-]*[?*][A-Za-z?*\\-]*):[^:*?]+:[^:]*(:(?:\\d{12}|\\*|aws)?:.+|)|\\*)$";
58
59const CONDITION_OPERATORS: &[&str] = &[
60 "ArnEquals",
61 "ArnLike",
62 "ArnNotEquals",
63 "ArnNotLike",
64 "BinaryEquals",
65 "Bool",
66 "DateEquals",
67 "DateGreaterThan",
68 "DateGreaterThanEquals",
69 "DateLessThan",
70 "DateLessThanEquals",
71 "DateNotEquals",
72 "IpAddress",
73 "NotIpAddress",
74 "Null",
75 "NumericEquals",
76 "NumericGreaterThan",
77 "NumericGreaterThanEquals",
78 "NumericLessThan",
79 "NumericLessThanEquals",
80 "NumericNotEquals",
81 "StringEquals",
82 "StringEqualsIgnoreCase",
83 "StringLike",
84 "StringNotEquals",
85 "StringNotEqualsIgnoreCase",
86 "StringNotLike",
87];
88
89fn resource_arn_matches(value: &str) -> bool {
90 if value == "*" {
91 return true;
92 }
93 let Some(fields) = split_arn_fields(value) else {
94 return false;
95 };
96 if fields.len() < 3 || fields[0] != "arn" {
97 return false;
98 }
99 if !arn_partition_is_valid(&fields[1]) || !arn_service_is_valid(&fields[2]) {
100 return false;
101 }
102
103 if fields.iter().take(5).any(|field| field_has_iam_policy_variable(field)) {
107 return false;
108 }
109
110 if let Some(account) = fields.get(4)
111 && !account.is_empty()
112 && account != "*"
113 && account != "aws"
114 && !field_has_placeholder(account)
115 && !(account.len() == 12 && account.bytes().all(|byte| byte.is_ascii_digit()))
116 {
117 return false;
118 }
119 fields.get(5).is_none_or(|resource| !resource.is_empty())
120}
121
122fn split_arn_fields(value: &str) -> Option<Vec<String>> {
126 let mut fields = Vec::new();
127 let mut current = String::new();
128 let mut chars = value.chars().peekable();
129 let mut in_placeholder = false;
130 while let Some(character) = chars.next() {
131 if character == '$' && chars.peek() == Some(&'{') {
132 in_placeholder = true;
133 current.push(character);
134 } else if character == '}' && in_placeholder {
135 in_placeholder = false;
136 current.push(character);
137 } else if character == ':' && !in_placeholder {
138 fields.push(std::mem::take(&mut current));
139 } else {
140 current.push(character);
141 }
142 }
143 if in_placeholder {
144 return None;
145 }
146 fields.push(current);
147 Some(fields)
148}
149
150fn field_has_placeholder(field: &str) -> bool {
151 field.contains("${")
152}
153
154fn field_has_iam_policy_variable(field: &str) -> bool {
155 let mut remaining = field;
156 while let Some(start) = remaining.find("${") {
157 let content = &remaining[start + 2..];
158 let Some(end) = content.find('}') else {
159 return true;
160 };
161 let name = &content[..end];
162 if name.contains(':') && !name.starts_with("AWS::") {
163 return true;
164 }
165 remaining = &content[end + 1..];
166 }
167 false
168}
169
170fn field_without_placeholders(field: &str) -> Option<String> {
171 let mut output = String::new();
172 let mut remaining = field;
173 while let Some(start) = remaining.find("${") {
174 output.push_str(&remaining[..start]);
175 let content = &remaining[start + 2..];
176 let end = content.find('}')?;
177 remaining = &content[end + 1..];
178 }
179 output.push_str(remaining);
180 Some(output)
181}
182
183fn arn_partition_is_valid(partition: &str) -> bool {
184 if field_has_iam_policy_variable(partition) {
185 return false;
186 }
187 let Some(literal) = field_without_placeholders(partition) else {
188 return false;
189 };
190 if literal.is_empty() {
191 return field_has_placeholder(partition);
192 }
193
194 let uses_only_partition_characters =
195 literal.chars().all(|character| character.is_ascii_alphabetic() || matches!(character, '-' | '*' | '?'));
196 let contains_wildcard = literal.contains(['*', '?']);
197 uses_only_partition_characters && (contains_wildcard || literal.starts_with("aws"))
198}
199
200fn arn_service_is_valid(service: &str) -> bool {
201 if service.contains(['*', '?']) || field_has_iam_policy_variable(service) {
202 return false;
203 }
204 let Some(literal) = field_without_placeholders(service) else {
205 return false;
206 };
207 !literal.is_empty() || field_has_placeholder(service)
208}
209
210fn condition_operator_is_valid(operator: &str) -> bool {
211 let without_set_operator =
212 operator.strip_prefix("ForAnyValue:").or_else(|| operator.strip_prefix("ForAllValues:")).unwrap_or(operator);
213 let base = without_set_operator.strip_suffix("IfExists").unwrap_or(without_set_operator);
214 CONDITION_OPERATORS.contains(&base)
215}
216
217struct SubstitutedPathIndex<'a> {
220 paths: &'a HashSet<String>,
221 sorted_paths: Vec<&'a str>,
222}
223
224impl<'a> SubstitutedPathIndex<'a> {
225 fn new(paths: &'a HashSet<String>) -> Self {
226 let mut sorted_paths: Vec<&str> = paths.iter().map(String::as_str).collect();
227 sorted_paths.sort_unstable();
228 Self { paths, sorted_paths }
229 }
230
231 fn covers(&self, path: &str) -> bool {
239 if self.paths.contains("") || self.paths.contains(path) {
240 return true;
241 }
242
243 if path.match_indices('.').any(|(separator, _)| self.paths.contains(&path[..separator])) {
244 return true;
245 }
246
247 let descendant_index = self.sorted_paths.partition_point(|candidate| {
251 candidate
252 .strip_prefix(path)
253 .map_or(*candidate < path, |suffix| suffix.as_bytes().first().is_none_or(|first| *first < b'.'))
254 });
255 self.sorted_paths.get(descendant_index).is_some_and(|candidate| {
256 candidate.strip_prefix(path).is_some_and(|suffix| {
257 if !suffix.starts_with('.') {
263 return false;
264 }
265 let next_component = &suffix[1..];
266 let component_end = next_component.find('.').unwrap_or(next_component.len());
267 let first_component = &next_component[..component_end];
268 !first_component.bytes().all(|byte| byte.is_ascii_digit())
269 })
270 })
271 }
272}
273
274fn path_is_intrinsic_generated(path: &str, substituted: &SubstitutedPathIndex<'_>) -> bool {
275 substituted.covers(path)
276}
277
278fn is_resolution_marker(value: &Value) -> bool {
279 value.as_object().is_some_and(|object| {
280 object.contains_key(MARKER_DYNAMIC)
281 || object.contains_key(MARKER_REF)
282 || object.contains_key(MARKER_INTRINSIC)
283 || object.contains_key(MARKER_CONDITIONAL)
284 || object.contains_key(MARKER_ENUM)
285 })
286}
287
288fn non_null_property<'a>(object: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a Value> {
289 object.get(key).filter(|value| !value.is_null())
290}
291
292pub fn validate_identity_policy(doc: &Value, substituted: &HashSet<String>) -> Vec<PolicyFinding> {
301 let substituted_index = SubstitutedPathIndex::new(substituted);
302 let mut out = Vec::new();
303 if is_resolution_marker(doc) {
304 return out;
305 }
306 let Some(obj) = doc.as_object() else {
307 out.push(PolicyFinding {
308 path: String::new(),
309 message: format!("{} is not of type 'object'", describe_value(doc)),
310 });
311 return out;
312 };
313
314 for key in obj.keys() {
315 if !IDENTITY_DOCUMENT_KEYS.contains(&key.as_str()) {
316 out.push(PolicyFinding {
317 path: key.clone(),
318 message: format!("Additional properties are not allowed ('{}' was unexpected)", key),
319 });
320 }
321 }
322
323 if let Some(version) = obj.get("Version")
324 && !is_resolution_marker(version)
325 {
326 if let Some(v) = version.as_str() {
327 if !VERSION_VALUES.contains(&v) {
328 out.push(PolicyFinding {
329 path: "Version".to_string(),
330 message: format!("'{}' is not one of {}", v, render_str_list(VERSION_VALUES)),
331 });
332 }
333 } else {
334 out.push(PolicyFinding {
335 path: "Version".to_string(),
336 message: format!("{} is not of type 'string'", describe_value(version)),
337 });
338 }
339 }
340
341 match non_null_property(obj, "Statement") {
342 None => {
343 out.push(PolicyFinding { path: String::new(), message: "'Statement' is a required property".to_string() })
344 }
345 Some(Value::Array(stmts)) => {
346 if stmts.is_empty() {
347 out.push(PolicyFinding {
348 path: "Statement".to_string(),
349 message: "[] is too short (minimum 1 item)".to_string(),
350 });
351 }
352 let mut seen_sids: Vec<(String, usize)> = Vec::new();
353 for (idx, stmt) in stmts.iter().enumerate() {
354 validate_identity_statement(stmt, &format!("Statement.{}", idx), &substituted_index, &mut out);
355 if let Some(statement) = stmt.as_object()
356 && let Some(Value::String(sid)) = statement.get("Sid")
357 && !sid.is_empty()
358 && !path_is_intrinsic_generated(&format!("Statement.{}.Sid", idx), &substituted_index)
359 {
360 if let Some((_, first_index)) = seen_sids.iter().find(|(seen_sid, _)| seen_sid == sid) {
361 out.push(PolicyFinding {
362 path: format!("Statement.{}.Sid", idx),
363 message: format!("'{}' is a duplicate of Statement.{}.Sid", sid, first_index),
364 });
365 } else {
366 seen_sids.push((sid.clone(), idx));
367 }
368 }
369 }
370 }
371 Some(stmt @ Value::Object(_)) => validate_identity_statement(stmt, "Statement", &substituted_index, &mut out),
372 Some(other) => {
373 if !is_resolution_marker(other) {
374 out.push(PolicyFinding {
375 path: "Statement".to_string(),
376 message: format!("{} is not of type 'object', 'array'", describe_value(other)),
377 });
378 }
379 }
380 }
381
382 out
383}
384
385fn contains_scenario_branching(value: &ResolvedValue) -> bool {
386 match value {
387 ResolvedValue::Conditional { .. } | ResolvedValue::Enum { .. } => true,
388 ResolvedValue::List { items } => items.iter().any(contains_scenario_branching),
389 ResolvedValue::Map { entries } => entries.iter().any(|entry| contains_scenario_branching(&entry.value)),
390 ResolvedValue::Concrete { .. }
391 | ResolvedValue::Reference { .. }
392 | ResolvedValue::Dynamic { .. }
393 | ResolvedValue::TypedDynamic { .. } => false,
394 }
395}
396
397fn mask_scenario_branching(value: &ResolvedValue) -> ResolvedValue {
398 match value {
399 ResolvedValue::Conditional { .. } | ResolvedValue::Enum { .. } => {
400 ResolvedValue::Dynamic { reason: "scenario expansion curtailed".to_string() }
401 }
402 ResolvedValue::List { items } => {
403 ResolvedValue::List { items: items.iter().map(mask_scenario_branching).collect() }
404 }
405 ResolvedValue::Map { entries } => ResolvedValue::Map {
406 entries: entries
407 .iter()
408 .map(|entry| MapEntry { key: entry.key.clone(), value: mask_scenario_branching(&entry.value) })
409 .collect(),
410 },
411 other => other.clone(),
412 }
413}
414
415fn branch_alternatives_match(value: &ResolvedValue, concrete_matches: &impl Fn(&serde_json::Value) -> bool) -> bool {
416 match value {
417 ResolvedValue::Conditional { if_true, if_false, .. } => {
418 branch_alternatives_match(if_true, concrete_matches)
419 && branch_alternatives_match(if_false, concrete_matches)
420 }
421 ResolvedValue::Enum { variants } => {
422 variants.iter().all(|variant| branch_alternatives_match(variant, concrete_matches))
423 }
424 ResolvedValue::Concrete { value } => concrete_matches(value),
425 ResolvedValue::Reference { .. } | ResolvedValue::Dynamic { .. } | ResolvedValue::TypedDynamic { .. } => true,
426 ResolvedValue::List { .. } | ResolvedValue::Map { .. } => false,
427 }
428}
429
430fn list_item_branches_are_validation_invariant(
431 value: &ResolvedValue,
432 concrete_matches: &impl Fn(&serde_json::Value) -> bool,
433) -> bool {
434 let ResolvedValue::List { items } = value else {
435 return !contains_scenario_branching(value);
436 };
437 items.iter().all(|item| !contains_scenario_branching(item) || branch_alternatives_match(item, concrete_matches))
438}
439
440fn identity_statement_branches_are_validation_invariant(statement: &ResolvedValue) -> bool {
441 let ResolvedValue::Map { entries } = statement else {
442 return !contains_scenario_branching(statement);
443 };
444 entries.iter().all(|entry| match entry.key.as_str() {
445 "Action" | "NotAction" => {
446 list_item_branches_are_validation_invariant(&entry.value, &serde_json::Value::is_string)
447 }
448 "Resource" | "NotResource" => list_item_branches_are_validation_invariant(&entry.value, &|value| {
449 value.as_str().is_some_and(resource_arn_matches)
450 }),
451 _ => !contains_scenario_branching(&entry.value),
452 })
453}
454
455fn identity_policy_branches_are_validation_invariant(document: &ResolvedValue) -> bool {
456 let ResolvedValue::Map { entries } = document else {
457 return !contains_scenario_branching(document);
458 };
459 entries.iter().all(|entry| {
460 if entry.key != "Statement" {
461 return !contains_scenario_branching(&entry.value);
462 }
463 match &entry.value {
464 ResolvedValue::List { items } => items.iter().all(identity_statement_branches_are_validation_invariant),
465 statement @ ResolvedValue::Map { .. } => identity_statement_branches_are_validation_invariant(statement),
466 value => !contains_scenario_branching(value),
467 }
468 })
469}
470
471fn should_suppress_intrinsic_finding(model: &SemanticModel, resource_id: &str, source_path: &str) -> bool {
474 if !model.is_from_intrinsic(resource_id, source_path) {
475 return false;
476 }
477 let Some(template) = model.authored_sub_template_at(resource_id, source_path) else {
478 return true;
479 };
480 !sub_resource_skeleton_is_definitely_invalid(template)
481}
482
483fn sub_resource_skeleton_is_definitely_invalid(template: &str) -> bool {
484 if template == "*" {
485 return false;
486 }
487 if template.starts_with("${") {
490 return false;
491 }
492 !resource_arn_matches(template)
496}
497
498pub fn inline_identity_policy_document_paths(
500 model: &SemanticModel,
501 resource_id: &str,
502 policies_path: &str,
503) -> Vec<String> {
504 let Some(policies) =
505 model.resolve_deep(resource_id, policies_path).or_else(|| model.resolve(resource_id, policies_path).cloned())
506 else {
507 return Vec::new();
508 };
509 let mut indices = BTreeSet::new();
510 collect_inline_policy_indices(&policies, &mut indices);
511 indices.into_iter().map(|index| format!("{policies_path}.{index}.PolicyDocument")).collect()
512}
513
514fn collect_inline_policy_indices(value: &ResolvedValue, indices: &mut BTreeSet<usize>) {
515 match value {
516 ResolvedValue::List { items } => {
517 for (index, item) in items.iter().enumerate() {
518 if !matches!(item, ResolvedValue::Concrete { value } if value.is_null()) {
519 indices.insert(index);
520 }
521 }
522 }
523 ResolvedValue::Concrete { value } => {
524 if let Some(items) = value.as_array() {
525 for (index, item) in items.iter().enumerate() {
526 if !item.is_null() {
527 indices.insert(index);
528 }
529 }
530 }
531 }
532 ResolvedValue::Conditional { if_true, if_false, .. } => {
533 collect_inline_policy_indices(if_true, indices);
534 collect_inline_policy_indices(if_false, indices);
535 }
536 ResolvedValue::Enum { variants } => {
537 for variant in variants {
538 collect_inline_policy_indices(variant, indices);
539 }
540 }
541 ResolvedValue::Map { .. }
542 | ResolvedValue::Reference { .. }
543 | ResolvedValue::Dynamic { .. }
544 | ResolvedValue::TypedDynamic { .. } => {}
545 }
546}
547
548fn identity_policy_scenario_is_reachable(
549 model: &SemanticModel,
550 resource_id: &str,
551 conditions: &HashMap<String, bool>,
552) -> bool {
553 let mut assumptions: Vec<(String, bool)> = conditions.iter().map(|(name, value)| (name.clone(), *value)).collect();
554 if let Some(resource_condition) = model.resources.get(resource_id).and_then(|resource| resource.condition.as_ref())
555 {
556 match conditions.get(resource_condition) {
557 Some(false) => return false,
558 Some(true) => {}
559 None => assumptions.push((resource_condition.clone(), true)),
560 }
561 }
562 assumptions.sort_unstable();
563 assumptions.is_empty() || model.conditions.satisfiability(&assumptions) == Satisfiability::Satisfiable
564}
565
566fn reachable_identity_policy_scenarios(
567 model: &SemanticModel,
568 resource_id: &str,
569 document_path: &str,
570 use_masked_fallback: bool,
571) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
572 let Some(document) =
573 model.resolve_deep(resource_id, document_path).or_else(|| model.resolve(resource_id, document_path).cloned())
574 else {
575 return Vec::new();
576 };
577 let scenarios = if identity_policy_branches_are_validation_invariant(&document) {
578 vec![(document, HashMap::new())]
579 } else {
580 let expanded = model.resolve_scenarios(resource_id, document_path);
581 if !expanded.is_empty() {
582 expanded
583 } else if use_masked_fallback {
584 vec![(mask_scenario_branching(&document), HashMap::new())]
585 } else {
586 Vec::new()
587 }
588 };
589 scenarios
590 .into_iter()
591 .filter(|(_, conditions)| identity_policy_scenario_is_reachable(model, resource_id, conditions))
592 .collect()
593}
594
595pub fn policy_has_allow_not_action_scenarios(model: &SemanticModel, resource_id: &str, document_path: &str) -> bool {
597 reachable_identity_policy_scenarios(model, resource_id, document_path, false).into_iter().any(
598 |(document, conditions)| {
599 let materialized = materialize_policy_document(
600 &resolved_value_to_json(&document),
601 model,
602 resource_id,
603 document_path,
604 &conditions,
605 );
606 doc_has_allow_not_action(&materialized.value)
607 },
608 )
609}
610
611fn doc_has_allow_not_action(document: &Value) -> bool {
612 let Some(statement) = document.as_object().and_then(|object| object.get("Statement")) else {
613 return false;
614 };
615 match statement {
616 Value::Array(statements) => statements.iter().any(statement_is_allow_not_action),
617 Value::Object(_) => statement_is_allow_not_action(statement),
618 _ => false,
619 }
620}
621
622fn statement_is_allow_not_action(statement: &Value) -> bool {
623 let Some(object) = statement.as_object() else { return false };
624 object.get("Effect").and_then(Value::as_str) == Some("Allow")
625 && matches!(object.get("NotAction"), Some(value) if !value.is_null())
626}
627
628pub fn validate_identity_policy_scenarios(
633 model: &SemanticModel,
634 resource_id: &str,
635 document_path: &str,
636) -> Vec<ScenarioPolicyFinding> {
637 let scenarios = reachable_identity_policy_scenarios(model, resource_id, document_path, true);
638 let mut findings = Vec::new();
639 let mut seen = HashSet::new();
640 let no_substitutions = HashSet::new();
641
642 for (document, conditions) in scenarios {
643 let materialized = materialize_policy_document(
644 &resolved_value_to_json(&document),
645 model,
646 resource_id,
647 document_path,
648 &conditions,
649 );
650 let document = materialized.value;
651 for finding in validate_identity_policy(&document, &no_substitutions) {
652 let effective_path = if finding.path.is_empty() {
653 document_path.to_string()
654 } else {
655 format!("{document_path}.{}", finding.path)
656 };
657 let source_path = source_path_for_effective_path(
658 &materialized.source_paths,
659 model,
660 resource_id,
661 &effective_path,
662 &conditions,
663 );
664 if should_suppress_intrinsic_finding(model, resource_id, &source_path) {
665 continue;
666 }
667 let deduplication_message = scenario_finding_deduplication_message(&finding);
668 if seen.insert((source_path.clone(), deduplication_message)) {
669 let public_path = public_policy_path(document_path, &source_path, &finding.path);
670 findings.push(ScenarioPolicyFinding { path: public_path, message: finding.message, source_path });
671 }
672 }
673 collect_raw_placeholder_findings(
674 &document,
675 &materialized.source_paths,
676 model,
677 resource_id,
678 document_path,
679 &conditions,
680 &mut seen,
681 &mut findings,
682 );
683 }
684 findings
685}
686
687fn scenario_finding_deduplication_message(finding: &PolicyFinding) -> String {
688 if finding.path.is_empty() && finding.message.ends_with(" is not of type 'object'") {
689 "policy document is not an object".to_string()
690 } else {
691 finding.message.clone()
692 }
693}
694fn collect_raw_placeholder_findings(
700 document: &Value,
701 source_paths: &HashMap<String, String>,
702 model: &SemanticModel,
703 resource_id: &str,
704 document_path: &str,
705 conditions: &HashMap<String, bool>,
706 seen: &mut HashSet<(String, String)>,
707 findings: &mut Vec<ScenarioPolicyFinding>,
708) {
709 let Some(obj) = document.as_object() else { return };
710 let statements = match obj.get("Statement") {
711 Some(Value::Array(stmts)) => stmts.as_slice(),
712 Some(stmt @ Value::Object(_)) => std::slice::from_ref(stmt),
713 _ => return,
714 };
715 for (idx, stmt) in statements.iter().enumerate() {
716 let stmt_path = if statements.len() == 1 && !obj.get("Statement").is_some_and(Value::is_array) {
717 "Statement".to_string()
718 } else {
719 format!("Statement.{idx}")
720 };
721 let Some(stmt_obj) = stmt.as_object() else { continue };
722 for key in ["Resource", "NotResource"] {
723 let Some(value) = stmt_obj.get(key) else { continue };
724 let field_path = format!("{stmt_path}.{key}");
725 match value {
726 Value::String(s) => {
727 check_raw_placeholder_in_resource(
728 s,
729 &field_path,
730 document_path,
731 source_paths,
732 model,
733 resource_id,
734 conditions,
735 seen,
736 findings,
737 );
738 }
739 Value::Array(items) => {
740 for (item_idx, item) in items.iter().enumerate() {
741 if let Value::String(s) = item {
742 let item_path = format!("{field_path}.{item_idx}");
743 check_raw_placeholder_in_resource(
744 s,
745 &item_path,
746 document_path,
747 source_paths,
748 model,
749 resource_id,
750 conditions,
751 seen,
752 findings,
753 );
754 }
755 }
756 }
757 _ => {}
758 }
759 }
760 }
761}
762
763fn check_raw_placeholder_in_resource(
764 resource: &str,
765 relative_path: &str,
766 document_path: &str,
767 source_paths: &HashMap<String, String>,
768 model: &SemanticModel,
769 resource_id: &str,
770 conditions: &HashMap<String, bool>,
771 seen: &mut HashSet<(String, String)>,
772 findings: &mut Vec<ScenarioPolicyFinding>,
773) {
774 if !has_raw_cfn_placeholder(resource) {
775 return;
776 }
777 if !resource_arn_matches(resource) {
780 return;
781 }
782 let effective_path = format!("{document_path}.{relative_path}");
783 let source_path = source_path_for_effective_path(source_paths, model, resource_id, &effective_path, conditions);
784 if model.is_from_intrinsic(resource_id, &source_path) {
786 return;
787 }
788 let message = format!("'{}' does not match '{}'", resource, RESOURCE_ARN_PATTERN);
789 if seen.insert((source_path.clone(), message.clone())) {
790 let public_path = public_policy_path(document_path, &source_path, relative_path);
791 findings.push(ScenarioPolicyFinding { path: public_path, message, source_path });
792 }
793}
794
795fn has_raw_cfn_placeholder(value: &str) -> bool {
799 let mut remaining = value;
800 while let Some(start) = remaining.find("${") {
801 let content = &remaining[start + 2..];
802 let Some(end) = content.find('}') else {
803 return true;
804 };
805 let name = &content[..end];
806 let is_iam_variable = name.contains(':') && !name.starts_with("AWS::");
808 if !is_iam_variable {
809 return true;
810 }
811 remaining = &content[end + 1..];
812 }
813 false
814}
815
816struct MaterializedPolicyDocument {
817 value: Value,
818 source_paths: HashMap<String, String>,
819}
820
821fn materialize_policy_document(
822 document: &Value,
823 model: &SemanticModel,
824 resource_id: &str,
825 document_path: &str,
826 conditions: &HashMap<String, bool>,
827) -> MaterializedPolicyDocument {
828 let mut source_paths = HashMap::new();
829 let value = materialize_policy_value(
830 document,
831 model,
832 resource_id,
833 document_path,
834 document_path,
835 conditions,
836 &mut source_paths,
837 );
838 MaterializedPolicyDocument { value, source_paths }
839}
840
841fn materialize_policy_value(
842 value: &Value,
843 model: &SemanticModel,
844 resource_id: &str,
845 effective_path: &str,
846 authored_path: &str,
847 conditions: &HashMap<String, bool>,
848 source_paths: &mut HashMap<String, String>,
849) -> Value {
850 let source_path =
851 model.scenario_source_path(resource_id, authored_path, conditions).unwrap_or_else(|| authored_path.to_string());
852 source_paths.insert(effective_path.to_string(), source_path);
853
854 match value {
855 Value::Object(map) => Value::Object(
856 map.iter()
857 .map(|(key, child)| {
858 let effective_child = format!("{effective_path}.{key}");
859 let authored_child = format!("{authored_path}.{key}");
860 (
861 key.clone(),
862 materialize_policy_value(
863 child,
864 model,
865 resource_id,
866 &effective_child,
867 &authored_child,
868 conditions,
869 source_paths,
870 ),
871 )
872 })
873 .collect(),
874 ),
875 Value::Array(items) => {
876 let mut materialized = Vec::with_capacity(items.len());
877 for (authored_index, item) in items.iter().enumerate() {
878 let authored_item_path = format!("{authored_path}.{authored_index}");
879 let authored_source = model
880 .scenario_source_path(resource_id, &authored_item_path, conditions)
881 .unwrap_or_else(|| authored_item_path.clone());
882 if item.is_null() && model.is_from_intrinsic(resource_id, &authored_source) {
883 continue;
884 }
885 let effective_item_path = format!("{effective_path}.{}", materialized.len());
886 materialized.push(materialize_policy_value(
887 item,
888 model,
889 resource_id,
890 &effective_item_path,
891 &authored_item_path,
892 conditions,
893 source_paths,
894 ));
895 }
896 Value::Array(materialized)
897 }
898 other => other.clone(),
899 }
900}
901
902fn source_path_for_effective_path(
903 source_paths: &HashMap<String, String>,
904 model: &SemanticModel,
905 resource_id: &str,
906 effective_path: &str,
907 conditions: &HashMap<String, bool>,
908) -> String {
909 source_paths.get(effective_path).cloned().unwrap_or_else(|| {
910 model
911 .scenario_source_path(resource_id, effective_path, conditions)
912 .unwrap_or_else(|| effective_path.to_string())
913 })
914}
915
916fn public_policy_path(document_path: &str, source_path: &str, fallback: &str) -> String {
917 let relative = source_path.strip_prefix(document_path).and_then(|path| path.strip_prefix('.')).unwrap_or(fallback);
918 effective_path_from_scenario_source_path(relative).unwrap_or_else(|| fallback.to_string())
919}
920
921fn validate_identity_statement(
922 stmt: &Value,
923 path: &str,
924 substituted: &SubstitutedPathIndex<'_>,
925 out: &mut Vec<PolicyFinding>,
926) {
927 if is_resolution_marker(stmt) {
928 return;
929 }
930 let Some(obj) = stmt.as_object() else {
931 out.push(PolicyFinding {
932 path: path.to_string(),
933 message: format!("{} is not of type 'object'", describe_value(stmt)),
934 });
935 return;
936 };
937
938 for key in obj.keys() {
939 if !IDENTITY_STATEMENT_KEYS.contains(&key.as_str()) {
940 out.push(PolicyFinding {
941 path: format!("{}.{}", path, key),
942 message: format!("Additional properties are not allowed ('{}' was unexpected)", key),
943 });
944 }
945 }
946
947 match non_null_property(obj, "Effect") {
948 None => {
949 out.push(PolicyFinding { path: path.to_string(), message: "'Effect' is a required property".to_string() })
950 }
951 Some(effect_val) => {
952 if !is_resolution_marker(effect_val) {
953 match effect_val.as_str() {
954 Some(effect) => {
955 if !EFFECT_VALUES.contains(&effect)
956 && !path_is_intrinsic_generated(&format!("{}.Effect", path), substituted)
957 {
958 out.push(PolicyFinding {
959 path: format!("{}.Effect", path),
960 message: format!("'{}' is not one of {}", effect, render_str_list(EFFECT_VALUES)),
961 });
962 }
963 }
964 None => out.push(PolicyFinding {
965 path: format!("{}.Effect", path),
966 message: format!("{} is not of type 'string'", describe_value(effect_val)),
967 }),
968 }
969 }
970 }
971 }
972
973 check_required_xor(obj, path, &["Action", "NotAction"], out);
974 check_required_xor(obj, path, &["Resource", "NotResource"], out);
975
976 for key in ["Action", "NotAction"] {
977 if let Some(value) = non_null_property(obj, key)
978 && !is_resolution_marker(value)
979 {
980 check_string_or_string_list_with_min_items(value, &format!("{}.{}", path, key), substituted, out);
981 }
982 }
983 for key in ["Resource", "NotResource"] {
984 if let Some(value) = non_null_property(obj, key) {
985 let is_non_conditional_marker =
988 is_resolution_marker(value) && !value.as_object().is_some_and(|o| o.contains_key(MARKER_CONDITIONAL));
989 if is_non_conditional_marker {
990 continue;
991 }
992 check_resource_string_or_list(value, &format!("{}.{}", path, key), substituted, out);
993 }
994 }
995
996 if let Some(sid) = obj.get("Sid")
997 && !is_resolution_marker(sid)
998 {
999 match sid.as_str() {
1000 Some(s) if s.is_empty() || !s.chars().all(|c| c.is_ascii_alphanumeric()) => {
1001 out.push(PolicyFinding {
1002 path: format!("{}.Sid", path),
1003 message: format!("'{}' does not match '^[A-Za-z0-9]+$'", s),
1004 });
1005 }
1006 Some(_) => {}
1007 None => out.push(PolicyFinding {
1008 path: format!("{}.Sid", path),
1009 message: format!("{} is not of type 'string'", describe_value(sid)),
1010 }),
1011 }
1012 }
1013
1014 if let Some(condition) = obj.get("Condition") {
1015 validate_condition_block(condition, &format!("{}.Condition", path), out);
1016 }
1017}
1018
1019fn validate_condition_block(condition: &Value, path: &str, out: &mut Vec<PolicyFinding>) {
1022 if is_resolution_marker(condition) {
1023 return;
1024 }
1025 let Some(operators) = condition.as_object() else {
1026 out.push(PolicyFinding {
1027 path: path.to_string(),
1028 message: format!("{} is not of type 'object'", describe_value(condition)),
1029 });
1030 return;
1031 };
1032
1033 for (operator, context_values) in operators {
1034 let operator_path = format!("{}.{}", path, operator);
1035 if !condition_operator_is_valid(operator) {
1036 out.push(PolicyFinding {
1037 path: operator_path.clone(),
1038 message: format!("'{}' is not a valid IAM condition operator", operator),
1039 });
1040 }
1041 if is_resolution_marker(context_values) {
1042 continue;
1043 }
1044 let Some(context_map) = context_values.as_object() else {
1045 out.push(PolicyFinding {
1046 path: operator_path,
1047 message: format!("{} is not of type 'object'", describe_value(context_values)),
1048 });
1049 continue;
1050 };
1051 let is_null_operator = operator == "Null";
1052 for (context_key, context_value) in context_map {
1053 if is_resolution_marker(context_value) {
1054 continue;
1055 }
1056 let context_path = format!("{}.{}", operator_path, context_key);
1057 if is_null_operator {
1058 validate_null_condition_value(context_value, &context_path, out);
1059 } else {
1060 validate_condition_value(context_value, &context_path, out);
1061 }
1062 }
1063 }
1064}
1065
1066fn validate_condition_value(value: &Value, path: &str, out: &mut Vec<PolicyFinding>) {
1067 match value {
1068 Value::String(_) | Value::Number(_) | Value::Bool(_) => {}
1069 Value::Array(items) => {
1070 for (index, item) in items.iter().enumerate() {
1071 if is_resolution_marker(item) {
1072 continue;
1073 }
1074 if !item.is_string() {
1075 out.push(PolicyFinding {
1076 path: format!("{}.{}", path, index),
1077 message: format!("{} is not of type 'string'", describe_value(item)),
1078 });
1079 }
1080 }
1081 }
1082 _ => out.push(PolicyFinding {
1083 path: path.to_string(),
1084 message: format!("{} is not of type 'boolean', 'number', 'string', 'array'", describe_value(value)),
1085 }),
1086 }
1087}
1088
1089fn validate_null_condition_value(value: &Value, path: &str, out: &mut Vec<PolicyFinding>) {
1090 let is_boolean = |candidate: &Value| candidate.is_boolean() || matches!(candidate.as_str(), Some("true" | "false"));
1091 match value {
1092 Value::Array(items) => {
1093 for (index, item) in items.iter().enumerate() {
1094 if is_resolution_marker(item) {
1095 continue;
1096 }
1097 if !is_boolean(item) {
1098 out.push(PolicyFinding {
1099 path: format!("{}.{}", path, index),
1100 message: format!("{} is not one of ['true', 'false', true, false]", describe_value(item)),
1101 });
1102 }
1103 }
1104 }
1105 candidate if is_boolean(candidate) => {}
1106 _ => out.push(PolicyFinding {
1107 path: path.to_string(),
1108 message: format!("{} is not one of ['true', 'false', true, false]", describe_value(value)),
1109 }),
1110 }
1111}
1112
1113fn check_string_or_string_list_with_min_items(
1114 value: &Value,
1115 path: &str,
1116 substituted: &SubstitutedPathIndex<'_>,
1117 out: &mut Vec<PolicyFinding>,
1118) {
1119 if path_is_intrinsic_generated(path, substituted) {
1120 return;
1121 }
1122 match value {
1123 Value::String(_) => {}
1124 Value::Array(items) => {
1125 if items.is_empty() {
1126 out.push(PolicyFinding {
1127 path: path.to_string(),
1128 message: "[] is too short (minimum 1 item)".to_string(),
1129 });
1130 return;
1131 }
1132 for (index, item) in items.iter().enumerate() {
1133 let item_path = format!("{}.{}", path, index);
1134 if is_resolution_marker(item) || path_is_intrinsic_generated(&item_path, substituted) {
1135 continue;
1136 }
1137 if !item.is_string() {
1138 out.push(PolicyFinding {
1139 path: item_path,
1140 message: format!("{} is not of type 'string'", describe_value(item)),
1141 });
1142 }
1143 }
1144 }
1145 other => out.push(PolicyFinding {
1146 path: path.to_string(),
1147 message: format!("{} is not of type 'string', 'array'", describe_value(other)),
1148 }),
1149 }
1150}
1151
1152fn check_required_xor(
1155 obj: &serde_json::Map<String, Value>,
1156 path: &str,
1157 pair: &[&str; 2],
1158 out: &mut Vec<PolicyFinding>,
1159) {
1160 let present: Vec<&str> = pair.iter().copied().filter(|key| non_null_property(obj, key).is_some()).collect();
1161 let message = format!("Only one of {} is a required property", render_str_list(pair));
1162 if present.is_empty() {
1163 out.push(PolicyFinding { path: path.to_string(), message });
1164 } else if present.len() > 1 {
1165 for key in present {
1166 out.push(PolicyFinding { path: format!("{}.{}", path, key), message: message.clone() });
1167 }
1168 }
1169}
1170
1171fn check_resource_string_or_list(
1172 value: &Value,
1173 path: &str,
1174 substituted: &SubstitutedPathIndex<'_>,
1175 out: &mut Vec<PolicyFinding>,
1176) {
1177 if path_is_intrinsic_generated(path, substituted) {
1178 return;
1179 }
1180 if let Some(obj) = value.as_object()
1184 && obj.contains_key(MARKER_CONDITIONAL)
1185 {
1186 if let Some(if_true) = obj.get(MARKER_IF_TRUE) {
1187 check_resource_string_or_list(if_true, path, substituted, out);
1188 }
1189 if let Some(if_false) = obj.get(MARKER_IF_FALSE) {
1190 check_resource_string_or_list(if_false, path, substituted, out);
1191 }
1192 return;
1193 }
1194 if is_resolution_marker(value) {
1195 return;
1196 }
1197 match value {
1198 Value::String(resource) => check_resource_arn(resource, path, out),
1199 Value::Array(items) => {
1200 for (index, item) in items.iter().enumerate() {
1201 let item_path = format!("{}.{}", path, index);
1202 if path_is_intrinsic_generated(&item_path, substituted) {
1203 continue;
1204 }
1205 if item.as_object().is_some_and(|object| object.contains_key(MARKER_CONDITIONAL)) {
1206 check_resource_string_or_list(item, &item_path, substituted, out);
1207 continue;
1208 }
1209 if is_resolution_marker(item) {
1210 continue;
1211 }
1212 match item {
1213 Value::String(resource) => check_resource_arn(resource, &item_path, out),
1214 other => out.push(PolicyFinding {
1215 path: item_path,
1216 message: format!("{} is not of type 'string'", describe_value(other)),
1217 }),
1218 }
1219 }
1220 }
1221 other => out.push(PolicyFinding {
1222 path: path.to_string(),
1223 message: format!("{} is not of type 'string', 'array'", describe_value(other)),
1224 }),
1225 }
1226}
1227
1228fn check_resource_arn(resource: &str, path: &str, out: &mut Vec<PolicyFinding>) {
1230 if resource_arn_matches(resource) {
1231 return;
1232 }
1233 out.push(PolicyFinding {
1234 path: path.to_string(),
1235 message: format!("'{}' does not match '{}'", resource, RESOURCE_ARN_PATTERN),
1236 });
1237}
1238
1239fn describe_value(value: &Value) -> String {
1242 match value {
1243 Value::String(s) => format!("'{}'", s),
1244 other => other.to_string(),
1245 }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250 use super::*;
1251 use serde_json::json;
1252
1253 fn findings(doc: serde_json::Value) -> Vec<(String, String)> {
1254 let none = std::collections::HashSet::new();
1255 validate_identity_policy(&doc, &none).into_iter().map(|f| (f.path, f.message)).collect()
1256 }
1257
1258 #[test]
1261 fn substituted_paths_are_not_judged_on_content() {
1262 let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": [""]}]});
1263 let substituted: std::collections::HashSet<String> =
1264 [String::from("Statement.0.Resource.0")].into_iter().collect();
1265 assert!(
1266 validate_identity_policy(&doc, &substituted).is_empty(),
1267 "a parameter-substituted resource entry must not be held to the ARN pattern"
1268 );
1269 }
1270
1271 #[test]
1272 fn well_formed_document_is_clean() {
1273 let doc = json!({
1274 "Version": "2012-10-17",
1275 "Statement": [{
1276 "Sid": "AllowRead",
1277 "Effect": "Allow",
1278 "Action": ["s3:GetObject"],
1279 "Resource": "arn:aws:s3:::my-bucket/*"
1280 }]
1281 });
1282 assert!(findings(doc).is_empty());
1283 }
1284
1285 #[test]
1286 fn independent_valid_action_item_conditions_do_not_materialize_policy_worlds() {
1287 let parameters: serde_json::Map<String, Value> =
1288 (0..8).map(|index| (format!("P{index}"), json!({"Type": "String"}))).collect();
1289 let conditions: serde_json::Map<String, Value> = (0..8)
1290 .map(|index| (format!("C{index}"), json!({"Fn::Equals": [{"Ref": format!("P{index}")}, "true"]})))
1291 .collect();
1292 let actions: Vec<Value> =
1293 (0..8).map(|index| json!({"Fn::If": [format!("C{index}"), "s3:GetObject", "s3:PutObject"]})).collect();
1294 let template = json!({
1295 "Parameters": parameters,
1296 "Conditions": conditions,
1297 "Resources": {
1298 "Policy": {
1299 "Type": "AWS::IAM::ManagedPolicy",
1300 "Properties": {
1301 "PolicyDocument": {
1302 "Statement": [{"Effect": "Allow", "Action": actions, "Resource": "*"}]
1303 }
1304 }
1305 }
1306 }
1307 });
1308 let model = SemanticModel::from_bytes(&serde_json::to_vec(&template).unwrap()).unwrap();
1309
1310 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
1311
1312 assert!(found.is_empty());
1313 assert_eq!(
1314 model.scenario_combinations_used(),
1315 0,
1316 "validation-invariant item branches must not produce a Cartesian set of policy documents"
1317 );
1318 }
1319
1320 #[test]
1321 fn resource_arn_validation_accepts_representative_forms() {
1322 for resource in [
1323 "arn:aws:s3:::my-bucket/*",
1324 "arn:aws:iam::aws:policy/ReadOnlyAccess",
1325 "arn:aws:lambda:us-east-1:123456789012:function:worker",
1326 "arn:aws-us-gov:service:region:*:resource",
1327 ] {
1328 assert!(resource_arn_matches(resource), "expected valid resource ARN: {resource}");
1329 }
1330 for resource in [
1331 "not-an-arn",
1332 "arn:example:s3:::bucket",
1333 "arn:aws::region:123456789012:resource",
1334 "arn:aws:s3::named-account:bucket",
1335 "arn:aws:s3:::",
1336 ] {
1337 assert!(!resource_arn_matches(resource), "expected invalid resource ARN: {resource}");
1338 }
1339 }
1340
1341 #[test]
1342 fn missing_statement_is_required() {
1343 assert_eq!(
1344 findings(json!({"Version": "2012-10-17"})),
1345 [(String::new(), "'Statement' is a required property".into())]
1346 );
1347 }
1348
1349 #[test]
1350 fn invalid_version_and_unknown_document_key() {
1351 let doc = json!({"Version": "blah", "BadProperty": 1, "Statement": []});
1352 let found = findings(doc);
1353 assert!(found.contains(&("Version".into(), "'blah' is not one of ['2008-10-17', '2012-10-17']".into())));
1354 assert!(found.contains(&(
1355 "BadProperty".into(),
1356 "Additional properties are not allowed ('BadProperty' was unexpected)".into()
1357 )));
1358 }
1359
1360 #[test]
1361 fn statement_wrong_type_is_reported() {
1362 assert_eq!(
1363 findings(json!({"Statement": "Test"})),
1364 [("Statement".into(), "'Test' is not of type 'object', 'array'".into())]
1365 );
1366 }
1367
1368 #[test]
1369 fn statement_structure_defects_are_reported() {
1370 let doc = json!({
1371 "Version": "2012-10-17",
1372 "Statement": [{
1373 "Effect": "NotAllow",
1374 "Action": "s3:*",
1375 "Principal": {"AWS": "*"},
1376 "Resource": [{"Effect": "Allow"}, "not-an-arn"]
1377 }]
1378 });
1379 let found = findings(doc);
1380 assert!(found.contains(&("Statement.0.Effect".into(), "'NotAllow' is not one of ['Allow', 'Deny']".into())));
1381 assert!(found.contains(&(
1382 "Statement.0.Principal".into(),
1383 "Additional properties are not allowed ('Principal' was unexpected)".into()
1384 )));
1385 assert!(
1386 found.contains(&("Statement.0.Resource.0".into(), "{\"Effect\":\"Allow\"} is not of type 'string'".into()))
1387 );
1388 assert!(
1389 found.iter().any(|(p, m)| p == "Statement.0.Resource.1" && m.starts_with("'not-an-arn' does not match"))
1390 );
1391 }
1392
1393 #[test]
1394 fn missing_effect_and_action_xor() {
1395 let doc = json!({"Statement": [{"Resource": "*"}]});
1396 let found = findings(doc);
1397 assert!(found.contains(&("Statement.0".into(), "'Effect' is a required property".into())));
1398 assert!(
1399 found
1400 .contains(&("Statement.0".into(), "Only one of ['Action', 'NotAction'] is a required property".into()))
1401 );
1402 }
1403
1404 #[test]
1405 fn null_action_without_not_action_reports_required_pair() {
1406 let doc = json!({"Statement": [{"Effect": "Allow", "Action": null, "Resource": "*"}]});
1407 assert_eq!(
1408 findings(doc),
1409 [("Statement.0".into(), "Only one of ['Action', 'NotAction'] is a required property".into())]
1410 );
1411 }
1412
1413 #[test]
1414 fn null_resource_without_not_resource_reports_required_pair() {
1415 let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": null}]});
1416 assert_eq!(
1417 findings(doc),
1418 [("Statement.0".into(), "Only one of ['Resource', 'NotResource'] is a required property".into())]
1419 );
1420 }
1421
1422 #[test]
1423 fn concrete_xor_member_is_valid_when_other_member_is_null() {
1424 let doc = json!({
1425 "Statement": [{
1426 "Effect": "Allow",
1427 "Action": null,
1428 "NotAction": "s3:DeleteObject",
1429 "Resource": null,
1430 "NotResource": "*"
1431 }]
1432 });
1433 assert!(findings(doc).is_empty());
1434 }
1435
1436 #[test]
1437 fn both_null_xor_members_report_each_required_pair_once() {
1438 let doc = json!({
1439 "Statement": [{
1440 "Effect": "Allow",
1441 "Action": null,
1442 "NotAction": null,
1443 "Resource": null,
1444 "NotResource": null
1445 }]
1446 });
1447 assert_eq!(
1448 findings(doc),
1449 [
1450 ("Statement.0".into(), "Only one of ['Action', 'NotAction'] is a required property".into()),
1451 ("Statement.0".into(), "Only one of ['Resource', 'NotResource'] is a required property".into())
1452 ]
1453 );
1454 }
1455
1456 #[test]
1457 fn null_effect_and_statement_are_reported_as_missing() {
1458 assert_eq!(
1459 findings(json!({"Statement": [{"Effect": null, "Action": "s3:GetObject", "Resource": "*"}]})),
1460 [("Statement.0".into(), "'Effect' is a required property".into())]
1461 );
1462 assert_eq!(
1463 findings(json!({"Statement": null})),
1464 [(String::new(), "'Statement' is a required property".into())]
1465 );
1466 }
1467
1468 #[test]
1469 fn both_action_and_notaction_flagged_at_each_member() {
1470 let doc =
1471 json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "NotAction": "s3:Get*", "Resource": "*"}]});
1472 let found = findings(doc);
1473 assert!(found.contains(&(
1474 "Statement.0.Action".into(),
1475 "Only one of ['Action', 'NotAction'] is a required property".into()
1476 )));
1477 assert!(found.contains(&(
1478 "Statement.0.NotAction".into(),
1479 "Only one of ['Action', 'NotAction'] is a required property".into()
1480 )));
1481 }
1482
1483 #[test]
1484 fn substitution_placeholders_are_not_judged_against_the_arn_pattern() {
1485 let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:iam::${AWS::AccountId}:user/x"}]});
1486 assert!(findings(doc).is_empty());
1487 }
1488
1489 #[test]
1490 fn marker_bearing_field_is_skipped_when_literal_siblings_are_valid() {
1491 let doc = json!({"Statement": [{"Effect": "Allow", "Action": {"__dynamic": "unknown"}, "Resource": "*"}]});
1492 assert!(findings(doc).is_empty(), "a document carrying resolution markers must not be judged");
1493 }
1494
1495 #[test]
1498 fn dynamic_subtrees_are_skipped_but_sibling_literals_are_validated() {
1499 let doc = json!({
1500 "Statement": [{
1501 "Effect": "Invalid",
1502 "Action": {"__dynamic": "unknown"},
1503 "Resource": "*"
1504 }]
1505 });
1506 let found = findings(doc);
1507 assert!(
1508 found.iter().any(|(p, m)| p == "Statement.0.Effect" && m.contains("'Invalid'")),
1509 "an invalid literal Effect alongside dynamic Action must still be reported: {:?}",
1510 found
1511 );
1512 }
1513
1514 #[test]
1515 fn wholly_dynamic_document_is_skipped() {
1516 assert!(findings(json!({"__dynamic": "unknown policy"})).is_empty());
1517 }
1518
1519 #[test]
1521 fn dynamic_array_item_does_not_hide_invalid_literal_sibling() {
1522 let doc = json!({
1523 "Statement": [{
1524 "Effect": "Allow",
1525 "Action": [{"__dynamic": "unknown action"}, 7],
1526 "Resource": "*"
1527 }]
1528 });
1529 let found = findings(doc);
1530 assert!(
1531 found
1532 .iter()
1533 .any(|(path, message)| { path == "Statement.0.Action.1" && message.contains("not of type 'string'") }),
1534 "a dynamic array item must not hide an invalid literal sibling: {found:?}"
1535 );
1536 }
1537
1538 #[test]
1539 fn whole_document_with_markers_only_skips_marked_subtrees() {
1540 let doc = json!({
1541 "Version": "invalid",
1542 "Statement": [{
1543 "Effect": "Allow",
1544 "Action": {"__dynamic": "ref"},
1545 "Resource": "*"
1546 }]
1547 });
1548 let found = findings(doc);
1549 assert!(
1550 found.iter().any(|(p, _)| p == "Version"),
1551 "literal Version must still be validated even when Statement has markers"
1552 );
1553 }
1554
1555 #[test]
1557 fn empty_action_array_reports_min_items() {
1558 let doc = json!({"Statement": [{"Effect": "Allow", "Action": [], "Resource": "*"}]});
1559 let found = findings(doc);
1560 assert!(
1561 found.iter().any(|(p, m)| p == "Statement.0.Action" && m.contains("too short")),
1562 "empty Action array must report minItems violation: {:?}",
1563 found
1564 );
1565 }
1566
1567 #[test]
1568 fn empty_not_action_array_reports_min_items() {
1569 let doc = json!({"Statement": [{"Effect": "Allow", "NotAction": [], "Resource": "*"}]});
1570 let found = findings(doc);
1571 assert!(
1572 found.iter().any(|(p, m)| p == "Statement.0.NotAction" && m.contains("too short")),
1573 "empty NotAction array must report minItems violation: {:?}",
1574 found
1575 );
1576 }
1577
1578 #[test]
1580 fn duplicate_sid_across_statements_is_reported() {
1581 let doc = json!({
1582 "Statement": [
1583 {"Sid": "ReadOnly", "Effect": "Allow", "Action": "s3:Get*", "Resource": "*"},
1584 {"Sid": "ReadOnly", "Effect": "Deny", "Action": "s3:Put*", "Resource": "*"}
1585 ]
1586 });
1587 let found = findings(doc);
1588 assert!(
1589 found.iter().any(|(p, m)| p == "Statement.1.Sid" && m.contains("duplicate")),
1590 "duplicate Sid must be reported: {:?}",
1591 found
1592 );
1593 }
1594
1595 #[test]
1596 fn identity_policy_id_is_rejected_regardless_of_scalar_type() {
1597 for id in [json!("my-policy"), json!(123), json!(true)] {
1598 let doc = json!({"Id": id, "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "*"}]});
1599 assert_eq!(
1600 findings(doc),
1601 [("Id".to_string(), "Additional properties are not allowed ('Id' was unexpected)".to_string())],
1602 "identity-policy Id must be rejected regardless of value type"
1603 );
1604 }
1605 }
1606
1607 #[test]
1609 fn valid_condition_operators_are_accepted() {
1610 let doc = json!({
1611 "Statement": [{
1612 "Effect": "Allow",
1613 "Action": "s3:*",
1614 "Resource": "*",
1615 "Condition": {
1616 "StringEquals": {"aws:RequestedRegion": ["us-east-1"]},
1617 "ForAnyValue:ArnLike": {"aws:PrincipalOrgPaths": ["o-*/r-*/ou-*"]},
1618 "Null": {"aws:TokenIssueTime": ["true"]}
1619 }
1620 }]
1621 });
1622 assert!(findings(doc).is_empty());
1623 }
1624
1625 #[test]
1626 fn invalid_condition_operator_is_reported() {
1627 let doc = json!({
1628 "Statement": [{
1629 "Effect": "Allow",
1630 "Action": "s3:*",
1631 "Resource": "*",
1632 "Condition": {
1633 "InvalidOperator": {"key": ["value"]}
1634 }
1635 }]
1636 });
1637 let found = findings(doc);
1638 assert!(
1639 found.iter().any(|(_, m)| m.contains("not a valid IAM condition operator")),
1640 "invalid operator must be reported: {:?}",
1641 found
1642 );
1643 }
1644
1645 #[test]
1646 fn condition_operator_value_must_be_object() {
1647 let doc = json!({
1648 "Statement": [{
1649 "Effect": "Allow",
1650 "Action": "s3:*",
1651 "Resource": "*",
1652 "Condition": {
1653 "StringEquals": "not-an-object"
1654 }
1655 }]
1656 });
1657 let found = findings(doc);
1658 assert!(
1659 found.iter().any(|(_, m)| m.contains("is not of type 'object'")),
1660 "non-object operator value must be reported: {:?}",
1661 found
1662 );
1663 }
1664
1665 #[test]
1666 fn condition_context_values_follow_operator_schema() {
1667 let doc = json!({
1668 "Statement": [{
1669 "Effect": "Allow",
1670 "Action": "s3:*",
1671 "Resource": "*",
1672 "Condition": {
1673 "StringEquals": {
1674 "aws:RequestedRegion": ["us-east-1", 7],
1675 "aws:PrincipalTag/dynamic": {"__dynamic": "unknown"}
1676 },
1677 "Null": {"aws:TokenIssueTime": "not-a-boolean"}
1678 }
1679 }]
1680 });
1681 let found = findings(doc);
1682 assert!(
1683 found
1684 .iter()
1685 .any(|(path, message)| path.ends_with("aws:RequestedRegion.1")
1686 && message.contains("not of type 'string'")),
1687 "non-string array member must be reported: {:?}",
1688 found
1689 );
1690 assert!(
1691 found.iter().any(|(path, message)| path.ends_with("aws:TokenIssueTime") && message.contains("not one of")),
1692 "Null values must be boolean spellings: {:?}",
1693 found
1694 );
1695 assert!(
1696 found.iter().all(|(path, _)| !path.contains("aws:PrincipalTag/dynamic")),
1697 "a dynamic context value must not suppress or create sibling findings: {:?}",
1698 found
1699 );
1700 }
1701
1702 #[test]
1703 fn sid_must_be_non_empty() {
1704 let doc = json!({
1705 "Statement": [{"Sid": "", "Effect": "Allow", "Action": "s3:*", "Resource": "*"}]
1706 });
1707 let found = findings(doc);
1708 assert!(
1709 found.iter().any(|(path, message)| path == "Statement.0.Sid" && message.contains("does not match")),
1710 "empty Sid must be rejected: {:?}",
1711 found
1712 );
1713 }
1714
1715 #[test]
1717 fn substituted_ancestor_path_suppresses_descendant_check() {
1718 let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": ["not-an-arn"]}]});
1719 let substituted: HashSet<String> = [String::from("Statement.0.Resource")].into_iter().collect();
1720 let found = validate_identity_policy(&doc, &substituted);
1721 assert!(
1722 !found.iter().any(|f| f.path.starts_with("Statement.0.Resource")),
1723 "descendant of substituted path must be suppressed: {:?}",
1724 found
1725 );
1726 }
1727
1728 #[test]
1731 fn substituted_descendant_path_suppresses_generated_ancestor_check() {
1732 let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "not-an-arn"}]});
1733 let substituted: HashSet<String> = [String::from("Statement.0.Resource.Fn::Join.1.0")].into_iter().collect();
1734 let found = validate_identity_policy(&doc, &substituted);
1735 assert!(
1736 !found.iter().any(|finding| finding.path == "Statement.0.Resource"),
1737 "an intrinsic nested beneath the checked path generated the ancestor value: {:?}",
1738 found
1739 );
1740 }
1741
1742 #[test]
1743 fn substituted_path_index_respects_component_boundaries() {
1744 let substituted: HashSet<String> = [
1745 "Statement.0.Action.0",
1746 "Statement.0.Resource-qualifier",
1747 "Statement.0.Resource.Fn::Join.1.0",
1748 "Statement.10.Condition",
1749 ]
1750 .into_iter()
1751 .map(String::from)
1752 .collect();
1753 let index = SubstitutedPathIndex::new(&substituted);
1754
1755 assert!(!index.covers("Statement.0.Action"));
1757 assert!(index.covers("Statement.0.Action.0"));
1759 assert!(index.covers("Statement.0.Action.0.Value"));
1760 assert!(index.covers("Statement.0.Resource"));
1762 assert!(!index.covers("Statement.0.Condition"));
1763 assert!(!index.covers("Statement.0.Actionable"));
1764 assert!(!index.covers("Statement.1"));
1765 }
1766
1767 #[test]
1770 fn many_substituted_policy_paths_preserve_literal_findings() {
1771 const STATEMENT_COUNT: usize = 2_000;
1772 let mut statements = Vec::with_capacity(STATEMENT_COUNT);
1773 let mut substituted = HashSet::with_capacity(STATEMENT_COUNT / 2);
1774 let mut expected_paths = HashSet::with_capacity(STATEMENT_COUNT / 2);
1775
1776 for index in 0..STATEMENT_COUNT {
1777 statements.push(json!({
1778 "Effect": "Allow",
1779 "Action": "s3:GetObject",
1780 "Resource": "not-an-arn"
1781 }));
1782 let resource_path = format!("Statement.{index}.Resource");
1783 if index.is_multiple_of(2) {
1784 substituted.insert(resource_path);
1785 } else {
1786 expected_paths.insert(resource_path);
1787 }
1788 }
1789
1790 let doc = json!({"Statement": statements});
1791 let found = validate_identity_policy(&doc, &substituted);
1792 let actual_paths: HashSet<String> = found.iter().map(|finding| finding.path.clone()).collect();
1793
1794 assert_eq!(found.len(), expected_paths.len());
1795 assert_eq!(actual_paths, expected_paths);
1796 assert!(found.iter().all(|finding| finding.message.contains("does not match")));
1797 }
1798
1799 #[test]
1801 fn dynamic_action_does_not_suppress_effect_validation() {
1802 let doc = json!({
1803 "Statement": [{
1804 "Effect": "BadValue",
1805 "Action": {"__dynamic": "generated by Fn::Join"},
1806 "Resource": "*"
1807 }]
1808 });
1809 let found = findings(doc);
1810 assert!(
1811 found.iter().any(|(p, m)| p == "Statement.0.Effect" && m.contains("'BadValue'")),
1812 "invalid Effect must still be reported when Action is dynamic: {:?}",
1813 found
1814 );
1815 }
1816
1817 #[test]
1822 fn substituted_numeric_list_descendant_does_not_suppress_literal_siblings() {
1823 let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": ["not-an-arn", "also-bad"]}]});
1824 let substituted: HashSet<String> = [String::from("Statement.0.Resource.1")].into_iter().collect();
1825 let found = validate_identity_policy(&doc, &substituted);
1826 assert!(
1827 found.iter().any(|f| f.path == "Statement.0.Resource.0" && f.message.contains("does not match")),
1828 "a substituted numeric sibling must not suppress validation of literal siblings: {:?}",
1829 found
1830 );
1831 assert!(
1832 !found.iter().any(|f| f.path == "Statement.0.Resource.1"),
1833 "the substituted item itself must be suppressed: {:?}",
1834 found
1835 );
1836 }
1837
1838 #[test]
1840 fn non_numeric_descendant_still_suppresses_ancestor() {
1841 let doc = json!({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "not-an-arn"}]});
1842 let substituted: HashSet<String> = [String::from("Statement.0.Resource.Fn::Sub.0")].into_iter().collect();
1843 let found = validate_identity_policy(&doc, &substituted);
1844 assert!(
1845 !found.iter().any(|f| f.path == "Statement.0.Resource"),
1846 "an intrinsic descendant path must suppress the ancestor scalar: {:?}",
1847 found
1848 );
1849 }
1850
1851 #[test]
1856 fn conditional_resource_branches_are_validated() {
1857 let doc = json!({
1858 "Statement": [{
1859 "Effect": "Allow",
1860 "Action": "s3:*",
1861 "Resource": {
1862 "__conditional": "IsProd",
1863 "__if_true": "not-an-arn",
1864 "__if_false": "arn:aws:s3:::bucket/*"
1865 }
1866 }]
1867 });
1868 let found = findings(doc);
1869 assert!(
1870 found.iter().any(|(p, m)| p == "Statement.0.Resource" && m.contains("does not match")),
1871 "invalid ARN in a conditional branch must be reported: {:?}",
1872 found
1873 );
1874 }
1875
1876 #[test]
1878 fn conditional_resource_both_branches_valid_is_clean() {
1879 let doc = json!({
1880 "Statement": [{
1881 "Effect": "Allow",
1882 "Action": "s3:*",
1883 "Resource": {
1884 "__conditional": "IsProd",
1885 "__if_true": "arn:aws:s3:::prod-bucket/*",
1886 "__if_false": "arn:aws:s3:::dev-bucket/*"
1887 }
1888 }]
1889 });
1890 assert!(findings(doc).is_empty());
1891 }
1892
1893 #[test]
1895 fn conditional_resource_invalid_branch_among_valid_array_reported() {
1896 let doc = json!({
1897 "Statement": [{
1898 "Effect": "Allow",
1899 "Action": "s3:*",
1900 "Resource": {
1901 "__conditional": "UseWildcard",
1902 "__if_true": "*",
1903 "__if_false": "bad-arn"
1904 }
1905 }]
1906 });
1907 let found = findings(doc);
1908 assert!(
1909 found.iter().any(|(p, m)| p == "Statement.0.Resource" && m.contains("does not match")),
1910 "invalid false-branch must be reported: {:?}",
1911 found
1912 );
1913 }
1914
1915 #[test]
1919 fn arn_with_cfn_partition_placeholder_is_valid() {
1920 let doc = json!({
1921 "Statement": [{
1922 "Effect": "Allow",
1923 "Action": "iam:*",
1924 "Resource": "arn:${AWS::Partition}:iam::${AWS::AccountId}:user/x"
1925 }]
1926 });
1927 assert!(findings(doc).is_empty());
1928 }
1929
1930 #[test]
1932 fn arn_with_trailing_iam_variable_is_valid() {
1933 let doc = json!({
1934 "Statement": [{
1935 "Effect": "Allow",
1936 "Action": "s3:*",
1937 "Resource": "arn:aws:s3:::bucket/${aws:username}/*"
1938 }]
1939 });
1940 assert!(findings(doc).is_empty());
1941 }
1942
1943 #[test]
1945 fn arn_with_wildcard_partition_and_trailing_iam_variable_is_valid() {
1946 let doc = json!({
1947 "Statement": [{
1948 "Effect": "Allow",
1949 "Action": "s3:*",
1950 "Resource": "arn:*:s3:::bucket/${aws:username}"
1951 }]
1952 });
1953 assert!(findings(doc).is_empty());
1954 }
1955
1956 #[test]
1958 fn arn_with_wildcard_service_is_rejected() {
1959 let doc = json!({
1960 "Statement": [{
1961 "Effect": "Allow",
1962 "Action": "s3:*",
1963 "Resource": "arn:aws:*:us-east-1:${AWS::AccountId}:thing"
1964 }]
1965 });
1966 let found = findings(doc);
1967 assert!(
1968 found.iter().any(|(p, m)| p == "Statement.0.Resource" && m.contains("does not match")),
1969 "wildcard service must be rejected: {:?}",
1970 found
1971 );
1972 }
1973
1974 #[test]
1976 fn literal_wildcard_star_remains_valid() {
1977 let doc = json!({
1978 "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "*"}]
1979 });
1980 assert!(findings(doc).is_empty());
1981 }
1982
1983 #[test]
1985 fn arn_with_iso_partition_is_valid() {
1986 assert!(resource_arn_matches("arn:aws-iso:s3:::bucket/key"));
1987 assert!(resource_arn_matches("arn:aws-iso-b:kms:us-isob-east-1:123456789012:key/id"));
1988 }
1989
1990 #[test]
1992 fn resource_arn_accepts_wildcard_partition() {
1993 for resource in [
1994 "arn:*:s3:::bucket",
1995 "arn:*:iam::123456789012:role/test",
1996 "arn:aw?:s3:::bucket",
1997 "arn:aws-*:kms:us-east-1:123456789012:key/test",
1998 ] {
1999 assert!(resource_arn_matches(resource), "partition wildcard must be accepted in {resource}");
2000 }
2001 }
2002
2003 #[test]
2005 fn resource_arn_rejects_wildcard_service() {
2006 assert!(!resource_arn_matches("arn:aws:*:us-east-1:123456789012:thing"));
2007 assert!(!resource_arn_matches("arn:aws:*:::bucket"));
2008 assert!(!resource_arn_matches("arn:aws:s?:::bucket"));
2009 }
2010
2011 #[test]
2013 fn arn_skeleton_rejects_malformed_prefix() {
2014 let doc = json!({
2015 "Statement": [{
2016 "Effect": "Allow",
2017 "Action": "s3:*",
2018 "Resource": "not-arn:${AWS::Partition}:s3:::bucket"
2019 }]
2020 });
2021 let found = findings(doc);
2022 assert!(
2023 found.iter().any(|(p, m)| p == "Statement.0.Resource" && m.contains("does not match")),
2024 "malformed prefix must be rejected: {:?}",
2025 found
2026 );
2027 }
2028
2029 #[test]
2032 fn incomplete_arn_with_trailing_variable_is_valid() {
2033 let doc = json!({
2034 "Statement": [{
2035 "Effect": "Allow",
2036 "Action": "s3:*",
2037 "Resource": "arn:aws:s3:${AWS::Region}:${AWS::AccountId}:bucket"
2038 }]
2039 });
2040 assert!(findings(doc).is_empty());
2041 }
2042
2043 #[test]
2046 fn exact_incomplete_arn_is_valid() {
2047 assert!(resource_arn_matches("arn:aws:sqs"));
2048 }
2049
2050 #[test]
2052 fn malformed_prefix_with_iam_variable_is_rejected() {
2053 assert!(!resource_arn_matches("not-an-arn-${aws:username}"));
2054 }
2055
2056 #[test]
2058 fn iam_variables_in_arn_prefix_fields_are_rejected() {
2059 for resource in [
2060 "arn:${aws:username}:s3:::bucket/key",
2061 "arn:aws:${aws:username}:::bucket/key",
2062 "arn:aws:s3:${aws:username}:123456789012:bucket/key",
2063 "arn:aws:s3:us-east-1:${aws:username}:bucket/key",
2064 ] {
2065 assert!(!resource_arn_matches(resource), "expected invalid resource ARN: {resource}");
2066 }
2067 }
2068
2069 #[test]
2070 fn unbalanced_placeholder_is_rejected() {
2071 assert!(!resource_arn_matches("arn:aws:s3:::bucket/${aws:username"));
2072 }
2073
2074 #[test]
2077 fn scenario_validation_checks_literal_branch_beside_intrinsic_branch() {
2078 let template = r#"
2079Conditions:
2080 UseGenerated: !Equals [!Ref AWS::Region, us-east-1]
2081Resources:
2082 Policy:
2083 Type: AWS::IAM::ManagedPolicy
2084 Properties:
2085 ManagedPolicyName: test
2086 PolicyDocument:
2087 Version: '2012-10-17'
2088 Statement:
2089 - Effect: Allow
2090 Action: s3:GetObject
2091 Resource: !If
2092 - UseGenerated
2093 - !Sub 'bad-${AWS::AccountId}'
2094 - not-an-arn
2095"#;
2096 let model = SemanticModel::from_bytes(template.as_bytes()).expect("template must parse");
2097 let generated_conditions = std::collections::HashMap::from([("UseGenerated".to_string(), true)]);
2098 let generated_source_path = model
2099 .scenario_source_path("Policy", "Properties.PolicyDocument.Statement.0.Resource", &generated_conditions)
2100 .expect("generated branch must have an authored source path");
2101 assert_eq!(generated_source_path, "Properties.PolicyDocument.Statement.0.Resource.Fn::If.1");
2102 assert!(
2103 model.is_from_intrinsic("Policy", &generated_source_path),
2104 "generated branch must retain intrinsic provenance at {generated_source_path}"
2105 );
2106
2107 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2108
2109 assert_eq!(found.len(), 2, "both branches should fire: {found:?}");
2113 assert!(
2114 found
2115 .iter()
2116 .any(|f| f.source_path.ends_with("Fn::If.1")
2117 && f.message.starts_with("'bad-123456789012' does not match")),
2118 "malformed Fn::Sub branch must fire: {found:?}"
2119 );
2120 assert!(
2121 found
2122 .iter()
2123 .any(|f| f.source_path.ends_with("Fn::If.2") && f.message.starts_with("'not-an-arn' does not match")),
2124 "literal branch must fire: {found:?}"
2125 );
2126 }
2127
2128 #[test]
2130 fn arn_with_service_placeholder_is_valid() {
2131 let doc = json!({
2132 "Statement": [{
2133 "Effect": "Allow",
2134 "Action": "s3:*",
2135 "Resource": "arn:aws:${ServiceName}:us-east-1:123456789012:resource"
2136 }]
2137 });
2138 assert!(findings(doc).is_empty());
2139 }
2140
2141 #[test]
2142 fn same_authored_root_type_violation_is_reported_once_across_scenarios() {
2143 let template = r#"
2144Conditions:
2145 UseObjectStatement: !Equals [!Ref AWS::Region, us-east-1]
2146Resources:
2147 Policy:
2148 Type: AWS::IAM::Policy
2149 Properties:
2150 PolicyName: test
2151 Roles: [test-role]
2152 PolicyDocument:
2153 - !If
2154 - UseObjectStatement
2155 - Statement: {}
2156 - Statement: []
2157"#;
2158 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2159 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2160
2161 assert_eq!(found.len(), 1, "one authored outer-list defect must produce one finding: {found:?}");
2162 assert_eq!(found[0].path, "");
2163 assert_eq!(found[0].source_path, "Properties.PolicyDocument");
2164 assert!(found[0].message.ends_with(" is not of type 'object'"));
2165 }
2166
2167 #[test]
2171 fn identical_conditional_branches_produce_two_findings() {
2172 let template = r#"
2173Parameters:
2174 Env:
2175 Type: String
2176 Default: prod
2177Conditions:
2178 IsProd: !Equals [!Ref Env, prod]
2179Resources:
2180 Policy:
2181 Type: AWS::IAM::ManagedPolicy
2182 Properties:
2183 PolicyDocument:
2184 Version: '2012-10-17'
2185 Statement:
2186 - Effect: Allow
2187 Action: s3:GetObject
2188 Resource: !If [IsProd, not-an-arn, not-an-arn]
2189"#;
2190 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2191 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2192 assert_eq!(found.len(), 2, "identical branches must not be deduplicated: {found:?}");
2193 assert!(found.iter().any(|f| f.source_path.ends_with("Fn::If.1")));
2194 assert!(found.iter().any(|f| f.source_path.ends_with("Fn::If.2")));
2195 }
2196
2197 #[test]
2199 fn valid_fn_sub_arn_is_not_flagged() {
2200 let template = r#"
2201Resources:
2202 Policy:
2203 Type: AWS::IAM::ManagedPolicy
2204 Properties:
2205 PolicyDocument:
2206 Version: '2012-10-17'
2207 Statement:
2208 - Effect: Allow
2209 Action: s3:GetObject
2210 Resource: !Sub "arn:${AWS::Partition}:s3:::my-bucket/*"
2211"#;
2212 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2213 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2214 assert!(found.is_empty(), "valid Fn::Sub must not fire: {found:?}");
2215 }
2216
2217 #[test]
2219 fn indeterminate_fn_sub_is_not_flagged() {
2220 let template = r#"
2221Parameters:
2222 BucketArn:
2223 Type: String
2224 Default: "arn:aws:s3:::my-bucket"
2225Resources:
2226 Policy:
2227 Type: AWS::IAM::ManagedPolicy
2228 Properties:
2229 PolicyDocument:
2230 Version: '2012-10-17'
2231 Statement:
2232 - Effect: Allow
2233 Action: s3:GetObject
2234 Resource: !Sub "${BucketArn}"
2235"#;
2236 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2237 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2238 assert!(found.is_empty(), "indeterminate Fn::Sub must not fire: {found:?}");
2239 }
2240
2241 #[test]
2242 fn simple_fn_sub_with_invalid_parameter_default_is_indeterminate() {
2243 let template = r#"
2244Parameters:
2245 BucketArn:
2246 Type: String
2247 Default: not-an-arn
2248Resources:
2249 Policy:
2250 Type: AWS::IAM::ManagedPolicy
2251 Properties:
2252 PolicyDocument:
2253 Statement:
2254 - Effect: Allow
2255 Action: s3:GetObject
2256 Resource: !Sub "${BucketArn}"
2257"#;
2258 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2259 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2260 assert!(
2261 found.is_empty(),
2262 "an overrideable parameter default must not be judged as deploy-time truth: {found:?}"
2263 );
2264 }
2265
2266 #[test]
2267 fn malformed_arn_prefixed_fn_sub_skeleton_is_flagged() {
2268 let template = r#"
2269Resources:
2270 Policy:
2271 Type: AWS::IAM::ManagedPolicy
2272 Properties:
2273 PolicyDocument:
2274 Statement:
2275 - Effect: Allow
2276 Action: s3:GetObject
2277 Resource: !Sub "arn:${AWS::Partition}:*:::bucket"
2278"#;
2279 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2280 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2281 assert_eq!(found.len(), 1, "the fixed service wildcard makes the authored skeleton invalid: {found:?}");
2282 assert!(found[0].message.contains("does not match"));
2283 }
2284
2285 #[test]
2287 fn raw_cfn_placeholder_outside_fn_sub_fires() {
2288 let template = r#"
2289Resources:
2290 Policy:
2291 Type: AWS::IAM::ManagedPolicy
2292 Properties:
2293 PolicyDocument:
2294 Version: '2012-10-17'
2295 Statement:
2296 - Effect: Allow
2297 Action: s3:GetObject
2298 Resource: "arn:aws:s3:::${BucketName}/*"
2299 - Effect: Deny
2300 Action: s3:DeleteObject
2301 NotResource: "arn:aws:s3:::${ProtectedBucket}/*"
2302"#;
2303 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2304 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2305 assert_eq!(found.len(), 2, "raw placeholders in Resource and NotResource must fire: {found:?}");
2306 assert!(found.iter().all(|finding| finding.message.contains("does not match")));
2307 assert!(found.iter().any(|finding| finding.path.ends_with("Resource")));
2308 assert!(found.iter().any(|finding| finding.path.ends_with("NotResource")));
2309 }
2310
2311 #[test]
2313 fn novalue_action_pruning_produces_min_items_finding() {
2314 let template = r#"
2315Parameters:
2316 Env:
2317 Type: String
2318 Default: prod
2319Conditions:
2320 IsProd: !Equals [!Ref Env, prod]
2321Resources:
2322 Policy:
2323 Type: AWS::IAM::ManagedPolicy
2324 Properties:
2325 PolicyDocument:
2326 Version: '2012-10-17'
2327 Statement:
2328 - Effect: Allow
2329 Action:
2330 - !If [IsProd, !Ref "AWS::NoValue", s3:GetObject]
2331 - !If [IsProd, !Ref "AWS::NoValue", s3:PutObject]
2332 Resource: "*"
2333"#;
2334 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2335 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2336 assert_eq!(found.len(), 1, "expected exactly 1 finding (minItems from IsProd=true): {found:?}");
2337 assert!(
2338 found[0].message.contains("too short"),
2339 "empty Action after NoValue pruning must produce minItems error: {found:?}"
2340 );
2341 }
2342
2343 #[test]
2344 fn novalue_not_action_pruning_produces_min_items_finding() {
2345 let template = r#"
2346Conditions:
2347 IsProd: !Equals [!Ref AWS::Region, us-east-1]
2348Resources:
2349 Policy:
2350 Type: AWS::IAM::ManagedPolicy
2351 Properties:
2352 PolicyDocument:
2353 Statement:
2354 - Effect: Allow
2355 NotAction:
2356 - !If [IsProd, !Ref "AWS::NoValue", s3:GetObject]
2357 - !If [IsProd, !Ref "AWS::NoValue", s3:PutObject]
2358 Resource: "*"
2359"#;
2360 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2361 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2362 assert_eq!(found.len(), 1, "only the empty NotAction scenario must fail: {found:?}");
2363 assert_eq!(found[0].path, "Statement.0.NotAction");
2364 assert!(found[0].message.contains("too short"));
2365 }
2366
2367 #[test]
2368 fn scalar_novalue_is_treated_as_a_missing_action() {
2369 let template = r#"
2370Conditions:
2371 IsProd: !Equals [!Ref AWS::Region, us-east-1]
2372Resources:
2373 Policy:
2374 Type: AWS::IAM::ManagedPolicy
2375 Properties:
2376 PolicyDocument:
2377 Statement:
2378 - Effect: Allow
2379 Action: !If [IsProd, !Ref "AWS::NoValue", s3:GetObject]
2380 Resource: "*"
2381"#;
2382 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2383 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2384 assert_eq!(found.len(), 1, "only the scenario with the removed scalar Action must fail: {found:?}");
2385 assert_eq!(found[0].path, "Statement.0");
2386 assert!(found[0].message.contains("Only one of ['Action', 'NotAction']"));
2387 }
2388
2389 #[test]
2390 fn literal_null_keeps_authored_index_after_novalue_sibling_is_removed() {
2391 let template = r#"
2392Conditions:
2393 IsProd: !Equals [!Ref AWS::Region, us-east-1]
2394Resources:
2395 Policy:
2396 Type: AWS::IAM::ManagedPolicy
2397 Properties:
2398 PolicyDocument:
2399 Statement:
2400 - Effect: Allow
2401 Action:
2402 - !If [IsProd, !Ref "AWS::NoValue", s3:GetObject]
2403 - null
2404 Resource: "*"
2405"#;
2406 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2407 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2408 assert_eq!(found.len(), 1, "the authored null is one defect shared by both scenarios: {found:?}");
2409 assert_eq!(found[0].path, "Statement.0.Action.1");
2410 assert!(found[0].source_path.ends_with("Statement.0.Action.1"));
2411 assert!(found[0].message.contains("not of type 'string'"));
2412 }
2413
2414 #[test]
2416 fn literal_null_in_action_array_produces_type_error() {
2417 let template = r#"
2418Resources:
2419 Policy:
2420 Type: AWS::IAM::ManagedPolicy
2421 Properties:
2422 PolicyDocument:
2423 Version: '2012-10-17'
2424 Statement:
2425 - Effect: Allow
2426 Action:
2427 - null
2428 - s3:GetObject
2429 Resource: "*"
2430"#;
2431 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2432 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2433 assert_eq!(found.len(), 1, "expected exactly 1 finding (type error for null): {found:?}");
2434 assert!(found[0].message.contains("not of type 'string'"), "literal null must produce type error: {found:?}");
2435 }
2436
2437 #[test]
2439 fn invariant_policy_validated_when_budget_exhausted() {
2440 let template = r#"
2441Resources:
2442 Policy:
2443 Type: AWS::IAM::ManagedPolicy
2444 Properties:
2445 PolicyDocument:
2446 Version: '2012-10-17'
2447 Statement:
2448 - Effect: InvalidValue
2449 Action: s3:GetObject
2450 Resource: "*"
2451"#;
2452 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2453 model.add_scenario_combinations_for_test(crate::consts::MAX_TOTAL_SCENARIO_COMBINATIONS);
2455 assert!(model.scenario_budget_exhausted());
2456
2457 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2458 assert_eq!(found.len(), 1, "expected exactly 1 finding (Effect enum error): {found:?}");
2459 assert!(
2460 found[0].message.contains("is not one of"),
2461 "invariant checks must run even with exhausted budget: {found:?}"
2462 );
2463 }
2464
2465 #[test]
2466 fn non_invariant_policy_uses_central_budget_and_keeps_static_checks() {
2467 let template = r#"
2468Conditions:
2469 IsProd: !Equals [!Ref AWS::Region, us-east-1]
2470Resources:
2471 Policy:
2472 Type: AWS::IAM::ManagedPolicy
2473 Properties:
2474 PolicyDocument:
2475 Statement:
2476 - Effect: InvalidValue
2477 Action: s3:GetObject
2478 Resource: !If [IsProd, "*", not-an-arn]
2479"#;
2480 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2481 model.add_scenario_combinations_for_test(crate::consts::MAX_TOTAL_SCENARIO_COMBINATIONS);
2482
2483 let found = validate_identity_policy_scenarios(&model, "Policy", "Properties.PolicyDocument");
2484 assert!(model.scenario_expansion_curtailed(), "central expansion must record the exhausted global budget");
2485 assert_eq!(found.len(), 1, "the static Effect defect must survive conservative fallback: {found:?}");
2486 assert_eq!(found[0].path, "Statement.0.Effect");
2487 }
2488
2489 #[test]
2490 fn inline_policy_path_discovery_is_structural_and_budget_free() {
2491 let template = r#"
2492Conditions:
2493 IsProd: !Equals [!Ref AWS::Region, us-east-1]
2494Resources:
2495 Role:
2496 Type: AWS::IAM::Role
2497 Properties:
2498 Policies: !If
2499 - IsProd
2500 - - PolicyName: One
2501 PolicyDocument: {Statement: []}
2502 - PolicyName: Two
2503 PolicyDocument: {Statement: []}
2504 - - PolicyName: Three
2505 PolicyDocument: {Statement: []}
2506"#;
2507 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2508 let paths = inline_identity_policy_document_paths(&model, "Role", "Properties.Policies");
2509 assert_eq!(paths, ["Properties.Policies.0.PolicyDocument", "Properties.Policies.1.PolicyDocument"]);
2510 assert_eq!(model.scenario_combinations_used(), 0);
2511 }
2512
2513 #[test]
2514 fn not_action_check_does_not_guess_when_expansion_is_curtailed() {
2515 let template = r#"
2516Conditions:
2517 IsProd: !Equals [!Ref AWS::Region, us-east-1]
2518Resources:
2519 Policy:
2520 Type: AWS::IAM::ManagedPolicy
2521 Condition: IsProd
2522 Properties:
2523 PolicyDocument:
2524 Statement: !If
2525 - IsProd
2526 - Effect: Allow
2527 Action: s3:GetObject
2528 Resource: '*'
2529 - Effect: Allow
2530 NotAction: iam:*
2531 Resource: '*'
2532"#;
2533 let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
2534 model.add_scenario_combinations_for_test(crate::consts::MAX_TOTAL_SCENARIO_COMBINATIONS);
2535 assert!(!policy_has_allow_not_action_scenarios(&model, "Policy", "Properties.PolicyDocument"));
2536 assert!(model.scenario_expansion_curtailed());
2537 }
2538}