Skip to main content

template_model/
vpc_cidr.rs

1//! Subnet CIDR placement analysis.
2//!
3//! EC2 requires every IPv4 subnet CIDR to lie inside one of its VPC's IPv4
4//! networks - the VPC's own `CidrBlock` or a secondary block attached through
5//! `AWS::EC2::VPCCidrBlock` - and forbids two subnets of one VPC from
6//! overlapping. Both checks are evaluated on the concrete deployment scenarios
7//! of each `CidrBlock`, so a `Fn::If` or mapping-driven value is judged in every
8//! branch it can take and two subnets are compared only in scenarios that can
9//! occur together.
10//!
11//! A network that is only known at deployment - an IPAM allocation, a CIDR
12//! taken from a parameter, or an unresolved reference - makes the containing
13//! question unanswerable, and no finding is emitted for it. Two subnets are
14//! compared only when they provably belong to the same VPC.
15
16use 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/// A subnet whose IPv4 CIDR lies outside every IPv4 network of its VPC.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SubnetOutsideVpcFinding {
33    pub subnet_id: String,
34    pub message: String,
35}
36
37/// A subnet whose IPv4 CIDR overlaps an earlier subnet of the same VPC in a
38/// deployment scenario where both exist.
39#[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
47/// One concrete IPv4 network a `CidrBlock` can take, with the condition
48/// assumptions under which it takes it.
49struct CidrScenario {
50    network: Ipv4Network,
51    conditions: HashMap<String, bool>,
52}
53
54/// The IPv4 networks of a VPC, or `None` when at least one of them is unknown
55/// before deployment and containment therefore cannot be decided.
56fn 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
72/// The IPv4 networks a resource's own `CidrBlock` can take across its
73/// scenarios, or `None` when the block is absent, IPAM-allocated, taken from a
74/// parameter, or unresolved in any scenario.
75fn 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
88/// Every reachable scenario in which the resource's `CidrBlock` is a literal
89/// IPv4 network. Values that are not IPv4 CIDRs belong to other rules.
90fn 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
105/// Whether a scenario can occur at all once the resource's own condition is
106/// added to its assumptions.
107fn scenario_is_reachable(model: &SemanticModel, resource_id: &str, conditions: &HashMap<String, bool>) -> bool {
108    assumptions_are_satisfiable(model, conditions, [resource_id])
109}
110
111/// Whether two subnets' scenarios can be deployed together: their condition
112/// assumptions must agree wherever they overlap and be jointly satisfiable
113/// alongside both resources' own conditions.
114fn 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
129/// Whether the scenario assumptions, extended with every listed resource's own
130/// condition being true, can all hold at once.
131fn 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
156/// Subnets whose IPv4 CIDR falls outside every IPv4 network of the VPC they
157/// reference, one finding per distinct offending CIDR.
158pub 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
199/// Pairs of same-VPC subnets whose IPv4 CIDRs overlap in a scenario where both
200/// are deployed. Each finding is attributed to the later subnet in template
201/// order, with the earlier one as its related resource.
202pub 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}