1use std::collections::HashMap;
17
18use ipnetwork::Ipv4Network;
19
20use crate::consts::KEY_PROPERTIES;
21use crate::model::SemanticModel;
22
23const VPC_TYPE: &str = "AWS::EC2::VPC";
24const SUBNET_TYPE: &str = "AWS::EC2::Subnet";
25const VPC_CIDR_BLOCK_TYPE: &str = "AWS::EC2::VPCCidrBlock";
26const CIDR_BLOCK_PROPERTY: &str = "CidrBlock";
27const VPC_ID_PROPERTY: &str = "VpcId";
28const IPV4_IPAM_POOL_PROPERTY: &str = "Ipv4IpamPoolId";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SubnetOutsideVpcFinding {
33 pub subnet_id: String,
34 pub message: String,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct SubnetOverlapFinding {
41 pub subnet_id: String,
42 pub message: String,
43 pub earlier_subnet_id: String,
44 pub earlier_subnet_message: String,
45}
46
47struct CidrScenario {
50 network: Ipv4Network,
51 conditions: HashMap<String, bool>,
52}
53
54fn vpc_ipv4_networks(model: &SemanticModel, vpc_id: &str) -> Option<Vec<Ipv4Network>> {
57 let mut networks = static_ipv4_networks(model, vpc_id)?;
58 for attachment in model.resources_of_type(VPC_CIDR_BLOCK_TYPE) {
59 if model.follow_ref(attachment, &property_path(VPC_ID_PROPERTY)) != Some(vpc_id) {
60 continue;
61 }
62 if model.has_property(attachment, &property_path(IPV4_IPAM_POOL_PROPERTY)) {
63 return None;
64 }
65 if model.has_property(attachment, &property_path(CIDR_BLOCK_PROPERTY)) {
66 networks.extend(static_ipv4_networks(model, attachment)?);
67 }
68 }
69 Some(networks)
70}
71
72fn static_ipv4_networks(model: &SemanticModel, resource_id: &str) -> Option<Vec<Ipv4Network>> {
76 let cidr_path = property_path(CIDR_BLOCK_PROPERTY);
77 if model.has_property(resource_id, &property_path(IPV4_IPAM_POOL_PROPERTY))
78 || !model.has_property(resource_id, &cidr_path)
79 || model.is_from_parameter(resource_id, &cidr_path)
80 || model.has_unresolved_scenario(resource_id, &cidr_path)
81 {
82 return None;
83 }
84 let networks: Vec<Ipv4Network> = cidr_scenarios(model, resource_id).into_iter().map(|s| s.network).collect();
85 (!networks.is_empty()).then_some(networks)
86}
87
88fn cidr_scenarios(model: &SemanticModel, resource_id: &str) -> Vec<CidrScenario> {
91 model
92 .resolve_scenarios_json(resource_id, &property_path(CIDR_BLOCK_PROPERTY))
93 .into_iter()
94 .filter_map(|(value, conditions)| {
95 let network: Ipv4Network = value.as_str()?.parse().ok()?;
96 scenario_is_reachable(model, resource_id, &conditions).then_some(CidrScenario { network, conditions })
97 })
98 .collect()
99}
100
101fn property_path(property_name: &str) -> String {
102 format!("{KEY_PROPERTIES}.{property_name}")
103}
104
105fn scenario_is_reachable(model: &SemanticModel, resource_id: &str, conditions: &HashMap<String, bool>) -> bool {
108 assumptions_are_satisfiable(model, conditions, [resource_id])
109}
110
111fn scenarios_coexist(
115 model: &SemanticModel,
116 left: (&str, &HashMap<String, bool>),
117 right: (&str, &HashMap<String, bool>),
118) -> bool {
119 let mut combined = left.1.clone();
120 for (condition, value) in right.1 {
121 if combined.get(condition).is_some_and(|existing| existing != value) {
122 return false;
123 }
124 combined.insert(condition.clone(), *value);
125 }
126 assumptions_are_satisfiable(model, &combined, [left.0, right.0])
127}
128
129fn assumptions_are_satisfiable<'a>(
132 model: &SemanticModel,
133 conditions: &HashMap<String, bool>,
134 resource_ids: impl IntoIterator<Item = &'a str>,
135) -> bool {
136 let mut assumptions: Vec<(String, bool)> = conditions.iter().map(|(name, value)| (name.clone(), *value)).collect();
137 for resource_id in resource_ids {
138 let Some(resource_condition) =
139 model.resources.get(resource_id).and_then(|resource| resource.condition.as_ref())
140 else {
141 continue;
142 };
143 match conditions.get(resource_condition) {
144 Some(false) => return false,
145 Some(true) => {}
146 None => assumptions.push((resource_condition.clone(), true)),
147 }
148 }
149 assumptions.is_empty() || model.conditions.is_satisfiable(&assumptions)
150}
151
152fn subnet_cidr_is_static(model: &SemanticModel, subnet_id: &str) -> bool {
153 !model.is_from_parameter(subnet_id, &property_path(CIDR_BLOCK_PROPERTY))
154}
155
156pub fn subnets_outside_vpc(model: &SemanticModel) -> Vec<SubnetOutsideVpcFinding> {
159 let mut findings = Vec::new();
160 for subnet_id in model.resources_of_type(SUBNET_TYPE) {
161 if !subnet_cidr_is_static(model, subnet_id) {
162 continue;
163 }
164 let Some(vpc_id) = model.follow_ref(subnet_id, &property_path(VPC_ID_PROPERTY)) else {
165 continue;
166 };
167 if model.resources.get(vpc_id).is_none_or(|vpc| vpc.resource_type != VPC_TYPE) {
168 continue;
169 }
170 let Some(vpc_networks) = vpc_ipv4_networks(model, vpc_id) else {
171 continue;
172 };
173 for scenario in cidr_scenarios(model, subnet_id) {
174 if vpc_networks.iter().any(|vpc_network| scenario.network.is_subnet_of(*vpc_network)) {
175 continue;
176 }
177 let finding = SubnetOutsideVpcFinding {
178 subnet_id: subnet_id.clone(),
179 message: outside_vpc_message(&scenario.network, &vpc_networks),
180 };
181 if !findings.contains(&finding) {
182 findings.push(finding);
183 }
184 }
185 }
186 findings
187}
188
189fn outside_vpc_message(subnet_network: &Ipv4Network, vpc_networks: &[Ipv4Network]) -> String {
190 match vpc_networks {
191 [only] => format!("Subnet CIDR '{subnet_network}' is not within VPC CIDR '{only}'"),
192 several => {
193 let rendered: Vec<String> = several.iter().map(|network| format!("'{network}'")).collect();
194 format!("Subnet CIDR '{subnet_network}' is not within any VPC CIDR [{}]", rendered.join(", "))
195 }
196 }
197}
198
199pub fn overlapping_subnets(model: &SemanticModel) -> Vec<SubnetOverlapFinding> {
203 let subnets = model.resources_of_type(SUBNET_TYPE);
204 let vpc_id_path = property_path(VPC_ID_PROPERTY);
205 let mut findings = Vec::new();
206 for (later_index, later_id) in subnets.iter().enumerate() {
207 if !subnet_cidr_is_static(model, later_id) {
208 continue;
209 }
210 let Some(later_vpc) = model.referenced_resource_or_value_identity(later_id, &vpc_id_path) else {
211 continue;
212 };
213 let later_scenarios = cidr_scenarios(model, later_id);
214 if later_scenarios.is_empty() {
215 continue;
216 }
217 for earlier_id in &subnets[..later_index] {
218 if !subnet_cidr_is_static(model, earlier_id)
219 || model.referenced_resource_or_value_identity(earlier_id, &vpc_id_path).as_deref()
220 != Some(later_vpc.as_str())
221 {
222 continue;
223 }
224 for later in &later_scenarios {
225 for earlier in cidr_scenarios(model, earlier_id) {
226 if !later.network.overlaps(earlier.network)
227 || !scenarios_coexist(model, (earlier_id, &earlier.conditions), (later_id, &later.conditions))
228 {
229 continue;
230 }
231 let finding = SubnetOverlapFinding {
232 subnet_id: later_id.clone(),
233 message: format!("'{}' overlaps with '{}'", later.network, earlier.network),
234 earlier_subnet_id: earlier_id.clone(),
235 earlier_subnet_message: format!("Overlapping subnet CIDR {}", earlier.network),
236 };
237 if !findings.contains(&finding) {
238 findings.push(finding);
239 }
240 }
241 }
242 }
243 }
244 findings
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn model(template: &str) -> SemanticModel {
252 SemanticModel::from_bytes(template.as_bytes()).expect("template parses")
253 }
254
255 fn outside(template: &str) -> Vec<(String, String)> {
256 subnets_outside_vpc(&model(template)).into_iter().map(|f| (f.subnet_id, f.message)).collect()
257 }
258
259 fn overlaps(template: &str) -> Vec<(String, String, String)> {
260 overlapping_subnets(&model(template))
261 .into_iter()
262 .map(|f| (f.subnet_id, f.earlier_subnet_id, f.message))
263 .collect()
264 }
265
266 const TWO_VPCS_SAME_LAYOUT: &str = r#"
267Resources:
268 VpcA:
269 Type: AWS::EC2::VPC
270 Properties:
271 CidrBlock: 10.0.0.0/16
272 VpcB:
273 Type: AWS::EC2::VPC
274 Properties:
275 CidrBlock: 10.0.0.0/16
276 SubnetA:
277 Type: AWS::EC2::Subnet
278 Properties:
279 VpcId: !Ref VpcA
280 CidrBlock: 10.0.0.0/24
281 SubnetB:
282 Type: AWS::EC2::Subnet
283 Properties:
284 VpcId: !Ref VpcB
285 CidrBlock: 10.0.0.0/24
286 SubnetC:
287 Type: AWS::EC2::Subnet
288 Properties:
289 VpcId: !GetAtt VpcA.VpcId
290 CidrBlock: 10.0.1.0/24
291 SubnetD:
292 Type: AWS::EC2::Subnet
293 Properties:
294 VpcId: !Ref VpcA
295 CidrBlock: 10.0.1.0/24
296"#;
297
298 #[test]
299 fn subnets_in_different_vpcs_never_overlap() {
300 let found = overlaps(TWO_VPCS_SAME_LAYOUT);
301 assert_eq!(
302 found,
303 [("SubnetD".to_string(), "SubnetC".to_string(), "'10.0.1.0/24' overlaps with '10.0.1.0/24'".to_string())]
304 );
305 }
306
307 #[test]
308 fn ref_and_getatt_to_the_same_vpc_are_one_vpc() {
309 let found = overlaps(TWO_VPCS_SAME_LAYOUT);
310 assert!(found.iter().any(|(later, earlier, _)| later == "SubnetD" && earlier == "SubnetC"));
311 }
312
313 #[test]
314 fn same_parameter_vpc_groups_subnets() {
315 let template = r#"
316Parameters:
317 Vpc:
318 Type: AWS::EC2::VPC::Id
319Resources:
320 A:
321 Type: AWS::EC2::Subnet
322 Properties:
323 VpcId: !Ref Vpc
324 CidrBlock: 10.0.0.0/24
325 B:
326 Type: AWS::EC2::Subnet
327 Properties:
328 VpcId: !Ref Vpc
329 CidrBlock: 10.0.0.0/25
330"#;
331 assert_eq!(overlaps(template).len(), 1);
332 }
333
334 #[test]
335 fn unresolved_cidr_is_not_compared() {
336 let template = r#"
337Resources:
338 Vpc:
339 Type: AWS::EC2::VPC
340 Properties:
341 CidrBlock: 10.0.0.0/16
342 A:
343 Type: AWS::EC2::Subnet
344 Properties:
345 VpcId: !Ref Vpc
346 CidrBlock: !GetAtt Vpc.CidrBlock
347 B:
348 Type: AWS::EC2::Subnet
349 Properties:
350 VpcId: !Ref Vpc
351 CidrBlock: !ImportValue Shared
352 C:
353 Type: AWS::EC2::Subnet
354 Properties:
355 VpcId: !Ref Vpc
356 CidrBlock: !Select [0, !Cidr [!GetAtt Vpc.CidrBlock, 4, 8]]
357"#;
358 assert!(overlaps(template).is_empty());
359 assert!(outside(template).is_empty());
360 }
361
362 #[test]
363 fn opposite_conditional_branches_do_not_overlap() {
364 let template = r#"
365Parameters:
366 Env:
367 Type: String
368Conditions:
369 IsProd: !Equals [!Ref Env, prod]
370Resources:
371 Vpc:
372 Type: AWS::EC2::VPC
373 Properties:
374 CidrBlock: 10.0.0.0/16
375 A:
376 Type: AWS::EC2::Subnet
377 Properties:
378 VpcId: !Ref Vpc
379 CidrBlock: !If [IsProd, 10.0.0.0/24, 10.0.1.0/24]
380 B:
381 Type: AWS::EC2::Subnet
382 Properties:
383 VpcId: !Ref Vpc
384 CidrBlock: !If [IsProd, 10.0.1.0/24, 10.0.0.0/24]
385"#;
386 assert!(overlaps(template).is_empty());
387 }
388
389 #[test]
390 fn overlap_in_either_branch_is_reported() {
391 let template = r#"
392Parameters:
393 Env:
394 Type: String
395Conditions:
396 IsProd: !Equals [!Ref Env, prod]
397Resources:
398 Vpc:
399 Type: AWS::EC2::VPC
400 Properties:
401 CidrBlock: 10.0.0.0/16
402 A:
403 Type: AWS::EC2::Subnet
404 Properties:
405 VpcId: !Ref Vpc
406 CidrBlock: 10.0.1.0/24
407 B:
408 Type: AWS::EC2::Subnet
409 Properties:
410 VpcId: !Ref Vpc
411 CidrBlock: !If [IsProd, 10.0.0.0/24, 10.0.1.0/24]
412"#;
413 assert_eq!(
414 overlaps(template),
415 [("B".to_string(), "A".to_string(), "'10.0.1.0/24' overlaps with '10.0.1.0/24'".to_string())]
416 );
417 }
418
419 #[test]
420 fn mutually_exclusive_subnets_do_not_overlap() {
421 let template = r#"
422Parameters:
423 Env:
424 Type: String
425Conditions:
426 IsProd: !Equals [!Ref Env, prod]
427 IsNotProd: !Not [!Condition IsProd]
428Resources:
429 Vpc:
430 Type: AWS::EC2::VPC
431 Properties:
432 CidrBlock: 10.0.0.0/16
433 A:
434 Type: AWS::EC2::Subnet
435 Condition: IsProd
436 Properties:
437 VpcId: !Ref Vpc
438 CidrBlock: 10.0.0.0/24
439 B:
440 Type: AWS::EC2::Subnet
441 Condition: IsNotProd
442 Properties:
443 VpcId: !Ref Vpc
444 CidrBlock: 10.0.0.0/24
445"#;
446 assert!(overlaps(template).is_empty());
447 }
448
449 #[test]
450 fn secondary_vpc_cidr_blocks_contain_subnets() {
451 let template = r#"
452Resources:
453 Vpc:
454 Type: AWS::EC2::VPC
455 Properties:
456 CidrBlock: 10.0.0.0/16
457 Secondary:
458 Type: AWS::EC2::VPCCidrBlock
459 Properties:
460 VpcId: !Ref Vpc
461 CidrBlock: 10.1.0.0/16
462 Inside:
463 Type: AWS::EC2::Subnet
464 Properties:
465 VpcId: !Ref Vpc
466 CidrBlock: 10.1.0.0/24
467 Outside:
468 Type: AWS::EC2::Subnet
469 Properties:
470 VpcId: !Ref Vpc
471 CidrBlock: 10.2.0.0/24
472"#;
473 assert_eq!(
474 outside(template),
475 [(
476 "Outside".to_string(),
477 "Subnet CIDR '10.2.0.0/24' is not within any VPC CIDR ['10.0.0.0/16', '10.1.0.0/16']".to_string()
478 )]
479 );
480 }
481
482 #[test]
483 fn dynamic_vpc_network_suppresses_containment() {
484 let template = r#"
485Parameters:
486 VpcCidr:
487 Type: String
488 Default: 10.0.0.0/16
489Resources:
490 IpamVpc:
491 Type: AWS::EC2::VPC
492 Properties:
493 Ipv4IpamPoolId: ipam-pool-0123456789abcdef0
494 Ipv4NetmaskLength: 16
495 A:
496 Type: AWS::EC2::Subnet
497 Properties:
498 VpcId: !Ref IpamVpc
499 CidrBlock: 10.9.0.0/24
500 ParamVpc:
501 Type: AWS::EC2::VPC
502 Properties:
503 CidrBlock: !Ref VpcCidr
504 B:
505 Type: AWS::EC2::Subnet
506 Properties:
507 VpcId: !Ref ParamVpc
508 CidrBlock: 10.9.0.0/24
509 ExtendedVpc:
510 Type: AWS::EC2::VPC
511 Properties:
512 CidrBlock: 10.0.0.0/16
513 IpamSecondary:
514 Type: AWS::EC2::VPCCidrBlock
515 Properties:
516 VpcId: !Ref ExtendedVpc
517 Ipv4IpamPoolId: ipam-pool-0123456789abcdef0
518 Ipv4NetmaskLength: 16
519 C:
520 Type: AWS::EC2::Subnet
521 Properties:
522 VpcId: !Ref ExtendedVpc
523 CidrBlock: 10.9.0.0/24
524"#;
525 assert!(outside(template).is_empty());
526 }
527
528 #[test]
529 fn conditional_subnet_cidr_is_checked_in_every_branch() {
530 let template = r#"
531Parameters:
532 Env:
533 Type: String
534Conditions:
535 IsProd: !Equals [!Ref Env, prod]
536Resources:
537 Vpc:
538 Type: AWS::EC2::VPC
539 Properties:
540 CidrBlock: 10.0.0.0/16
541 A:
542 Type: AWS::EC2::Subnet
543 Properties:
544 VpcId: !Ref Vpc
545 CidrBlock: !If [IsProd, 10.0.0.0/24, 10.5.0.0/24]
546"#;
547 assert_eq!(
548 outside(template),
549 [("A".to_string(), "Subnet CIDR '10.5.0.0/24' is not within VPC CIDR '10.0.0.0/16'".to_string())]
550 );
551 }
552
553 #[test]
554 fn literal_subnet_outside_literal_vpc_is_reported() {
555 let template = r#"
556Resources:
557 Vpc:
558 Type: AWS::EC2::VPC
559 Properties:
560 CidrBlock: 10.0.0.0/16
561 A:
562 Type: AWS::EC2::Subnet
563 Properties:
564 VpcId: !Ref Vpc
565 CidrBlock: 192.168.0.0/24
566"#;
567 assert_eq!(
568 outside(template),
569 [("A".to_string(), "Subnet CIDR '192.168.0.0/24' is not within VPC CIDR '10.0.0.0/16'".to_string())]
570 );
571 }
572}