cloudformation-validate-cel-engine 1.10.0

AWS CloudFormation Validate
Documentation
use super::resources_extra::{fargate_condition_scenarios, merge_reachable_scenario_conditions};
use super::{EvalContext, NativeRuleRegistry};
use diagnostics::{Diagnostic, RelatedResource, ResourceRef};
use rules::Category;
use std::collections::BTreeSet;
use template_model::consts::{
    FIELD_CREATION_POLICY, FIELD_RESOURCE_TYPE, FIELD_RESOURCES, FIELD_UPDATE_POLICY, KEY_CREATION_POLICY,
    KEY_UPDATE_POLICY,
};
use template_model::fargate::task_size_is_offered;
use template_model::resolver::ResolvedValue;
use template_model::{SemanticModel, SourceSpan, render_value};
use validation_engine::make_resource_diagnostic;

pub fn register(reg: &mut NativeRuleRegistry) {
    reg.add(Category::Resource, eval_resources);
    reg.add(Category::Resource, crate::rules::resources_extra::eval_extra_resources);
}

fn resolve_concrete(m: &SemanticModel, rid: &str, path: &str) -> Option<serde_json::Value> {
    match m.resolve_deep(rid, path).or_else(|| m.resolve(rid, path).cloned())? {
        ResolvedValue::Concrete { value: v } => Some(v.into_inner()),
        _ => None,
    }
}

fn eval_resources(ctx: &EvalContext) -> Vec<Diagnostic> {
    let mut out = Vec::new();
    let m = ctx.model;

    for name in m.resources_of_type("AWS::ECS::TaskDefinition") {
        let fargate_scenarios = fargate_condition_scenarios(m, name);
        if fargate_scenarios.is_empty() {
            continue;
        }
        let cpu_scenarios = m.resolve_scenarios_json(name, "Properties.Cpu");
        let memory_scenarios = m.resolve_scenarios_json(name, "Properties.Memory");
        let mut invalid_sizes = BTreeSet::new();

        for fargate_conditions in &fargate_scenarios {
            for (cpu, cpu_conditions) in &cpu_scenarios {
                let Some(fargate_cpu_conditions) =
                    merge_reachable_scenario_conditions(m, name, fargate_conditions, cpu_conditions)
                else {
                    continue;
                };
                for (memory, memory_conditions) in &memory_scenarios {
                    if merge_reachable_scenario_conditions(m, name, &fargate_cpu_conditions, memory_conditions)
                        .is_some()
                        && matches!(task_size_is_offered(cpu, memory), Some(false))
                    {
                        invalid_sizes.insert((render_value(cpu), render_value(memory)));
                    }
                }
            }
        }

        for (cpu, memory) in invalid_sizes {
            out.push(make_resource_diagnostic(
                "E3047",
                &format!("Cpu {cpu} is not compatible with Memory {memory} for Fargate"),
                m,
                name,
                "Properties.Cpu",
                Some("Use a valid Fargate CPU/memory combination (e.g., Cpu: 256 with Memory: 512, 1024, or 2048)"),
            ));
        }
    }

    let subnets = m.resources_of_type("AWS::EC2::Subnet");
    for (i, b_name) in subnets.iter().enumerate() {
        if i == 0 {
            continue;
        }
        if m.is_from_parameter(b_name, "Properties.CidrBlock") {
            continue;
        }
        let Some(serde_json::Value::String(b_cidr)) = resolve_concrete(m, b_name, "Properties.CidrBlock") else {
            continue;
        };
        let Ok(net_b) = b_cidr.parse::<ipnetwork::IpNetwork>() else {
            continue;
        };
        let b_cond = m.resources.get(b_name.as_str()).and_then(|r| r.condition.as_deref());
        let b_vpc = resolve_concrete(m, b_name, "Properties.VpcId");

        for a_name in &subnets[..i] {
            let a_cond = m.resources.get(a_name.as_str()).and_then(|r| r.condition.as_deref());
            if !m.conditions.resources_compatible(a_cond, b_cond) {
                continue;
            }
            if m.is_from_parameter(a_name, "Properties.CidrBlock") {
                continue;
            }
            let a_vpc = resolve_concrete(m, a_name, "Properties.VpcId");
            if a_vpc != b_vpc {
                continue;
            }
            let Some(serde_json::Value::String(a_cidr)) = resolve_concrete(m, a_name, "Properties.CidrBlock") else {
                continue;
            };
            let Ok(net_a) = a_cidr.parse::<ipnetwork::IpNetwork>() else {
                continue;
            };
            if !(net_a.contains(net_b.network()) || net_b.contains(net_a.network())) {
                continue;
            }
            let mut diag = make_resource_diagnostic(
                "E3060",
                &format!("'{}' overlaps with '{}'", b_cidr, a_cidr),
                m,
                b_name,
                "Properties.CidrBlock",
                None,
            );
            let span = m.resource_span(a_name, "");
            diag.related_resources.get_or_insert_with(Vec::new).push(RelatedResource {
                resource: Some(ResourceRef {
                    id: Some(a_name.clone()),
                    resource_type: m.resources.get(a_name.as_str()).map(|r| r.resource_type.clone()),
                }),
                location: Some(SourceSpan {
                    start_line: span.start_line,
                    start_column: span.start_column,
                    end_line: span.end_line,
                    end_column: span.end_column,
                }),
                message: format!("Overlapping subnet CIDR {}", a_cidr),
            });
            out.push(diag);
        }
    }

    for (name, res) in &m.resources {
        if res.resource_type.ends_with("::MODULE") && res.properties.contains_key("Tags") {
            out.push(make_resource_diagnostic(
                "E5001",
                &format!("Tags is not permitted within Module resource '{}'", name),
                m,
                name,
                "Properties.Tags",
                None,
            ));
        }
    }

    if let Some(resources) = ctx.input.get(FIELD_RESOURCES).and_then(|r| r.as_object()) {
        for (name, res) in resources {
            let rtype = res.get(FIELD_RESOURCE_TYPE).and_then(|t| t.as_str()).unwrap_or("");
            if rtype.ends_with("::MODULE") {
                if res.get(FIELD_CREATION_POLICY).is_some_and(|v| !v.is_null()) {
                    out.push(make_resource_diagnostic(
                        "E5001",
                        &format!("CreationPolicy is not permitted within Module resource '{}'", name),
                        m,
                        name,
                        KEY_CREATION_POLICY,
                        None,
                    ));
                }
                if res.get(FIELD_UPDATE_POLICY).is_some_and(|v| !v.is_null()) {
                    out.push(make_resource_diagnostic(
                        "E5001",
                        &format!("UpdatePolicy is not permitted within Module resource '{}'", name),
                        m,
                        name,
                        KEY_UPDATE_POLICY,
                        None,
                    ));
                }
            }
        }
    }

    for name in m.resources_of_type("AWS::Lambda::Function") {
        if is_zip_deployment(m, name) {
            // Report a single diagnostic listing every missing property, anchored
            // at the Code property (CloudFormation rejects the resource once, not
            // once per property). Collect them and emit one finding rather than
            // one per property.
            let missing: Vec<&str> = ctx
                .cached_data
                .rule_tables
                .lambda_zip_required_properties
                .iter()
                .map(|s| s.as_str())
                .filter(|prop| {
                    !m.resources.get(name.as_str()).map(|r| r.properties.contains_key(*prop)).unwrap_or(false)
                })
                .collect();
            if !missing.is_empty() {
                let formatted = missing.iter().map(|p| format!("'{}'", p)).collect::<Vec<_>>().join(", ");
                out.push(make_resource_diagnostic(
                    "W2533",
                    &format!(
                        "Properties [{}] missing for zip file deployment at Resources/{}/Properties",
                        formatted, name
                    ),
                    m,
                    name,
                    "Properties.Code",
                    Some("Add the missing properties for zip file deployment"),
                ));
            }
        }
    }

    for name in m.resources_of_type("AWS::SQS::Queue") {
        if let Some(serde_json::Value::Bool(true)) = resolve_concrete(m, name, "Properties.FifoQueue")
            && let Some(serde_json::Value::String(qname)) = resolve_concrete(m, name, "Properties.QueueName")
            && !qname.ends_with(".fifo")
        {
            out.push(make_resource_diagnostic(
                "E2504",
                &format!("FIFO queue name '{}' must end with '.fifo'", qname),
                m,
                name,
                "Properties.QueueName",
                None,
            ));
        }
    }

    out
}

fn is_zip_deployment(m: &SemanticModel, name: &str) -> bool {
    if let Some(serde_json::Value::String(pt)) = resolve_concrete(m, name, "Properties.PackageType") {
        return pt == "Zip";
    }
    if !m.resources.get(name).map(|r| r.properties.contains_key("PackageType")).unwrap_or(false)
        && let Some(serde_json::Value::Object(code)) = resolve_concrete(m, name, "Properties.Code")
    {
        return code.contains_key("ZipFile") || code.contains_key("S3Key");
    }
    false
}