Skip to main content

rego_engine/
engine.rs

1use crate::eval_context::{EvaluationContext, EvaluationScope, ReferenceRendering, ReferenceRenderingScope};
2use crate::policies;
3use data_source::embedded;
4use data_source::types::KnownResourceTypes;
5use diagnostics::{Diagnostic, PhaseMetric, phase_metric};
6use log::{debug, info};
7use rules::{RuleInfo, RuleMetadataEntry, RuleOrigin, build_rule_metadata_map};
8use schema_validator::{
9    OverlayCatalog, SchemaMetadataCatalog, SchemaValidator, getatt_data_with_overlays,
10    schema_metadata_catalog_with_overlays,
11};
12use std::collections::{HashMap, HashSet};
13use std::str::from_utf8;
14use std::sync::{Arc, LazyLock, Mutex};
15use template_model::SemanticModel;
16use validation_engine::{
17    EngineConfig, GuardRuleSet, ValidateConfig, ValidationEngine, ValidationError, build_rule_list,
18    extract_diagnostics_from_value, semantic_model_to_input_json,
19};
20
21static REGORUS_DATA: LazyLock<Vec<(&str, &[u8])>> = LazyLock::new(|| {
22    vec![
23        (KNOWN_RESOURCE_TYPES_PATH, &*embedded::KNOWN_RESOURCE_TYPES_BYTES),
24        (PRIMARY_IDENTIFIERS_PATH, &*embedded::PRIMARY_IDENTIFIERS_BYTES),
25        ("data/iam_action_resource_patterns", &*embedded::IAM_ACTION_RESOURCE_PATTERNS_BYTES),
26        ("data/stateful_resource_types", &*embedded::STATEFUL_RESOURCE_TYPES_BYTES),
27        ("data/aws_rds_dbinstance_dbinstanceclass_enum", &*embedded::AWS_RDS_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES),
28        ("data/aws_ec2_instance_instancetype_enum", &*embedded::AWS_EC2_INSTANCE_INSTANCETYPE_ENUM_BYTES),
29        (
30            "data/aws_emr_cluster_instancetypeconfig_instancetype_enum",
31            &*embedded::AWS_EMR_CLUSTER_INSTANCETYPECONFIG_INSTANCETYPE_ENUM_BYTES,
32        ),
33        ("data/aws_gamelift_fleet_ec2instancetype_enum", &*embedded::AWS_GAMELIFT_FLEET_EC2INSTANCETYPE_ENUM_BYTES),
34        ("data/aws_appstream_fleet_instancetype_enum", &*embedded::AWS_APPSTREAM_FLEET_INSTANCETYPE_ENUM_BYTES),
35        ("data/aws_dax_cluster_nodetype_enum", &*embedded::AWS_DAX_CLUSTER_NODETYPE_ENUM_BYTES),
36        ("data/aws_docdb_dbinstance_dbinstanceclass_enum", &*embedded::AWS_DOCDB_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES),
37        (
38            "data/aws_elasticache_cachecluster_cachenodetype_enum",
39            &*embedded::AWS_ELASTICACHE_CACHECLUSTER_CACHENODETYPE_ENUM_BYTES,
40        ),
41        (
42            "data/aws_managedblockchain_node_nodeconfiguration_instancetype_enum",
43            &*embedded::AWS_MANAGEDBLOCKCHAIN_NODE_NODECONFIGURATION_INSTANCETYPE_ENUM_BYTES,
44        ),
45        (
46            "data/aws_neptune_dbinstance_dbinstanceclass_enum",
47            &*embedded::AWS_NEPTUNE_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES,
48        ),
49        (
50            "data/aws_rds_dbcluster_dbclusterinstanceclass_enum",
51            &*embedded::AWS_RDS_DBCLUSTER_DBCLUSTERINSTANCECLASS_ENUM_BYTES,
52        ),
53        ("data/aws_rds_dbinstance_db_instance_class", &*embedded::AWS_RDS_DBINSTANCE_DB_INSTANCE_CLASS_BYTES),
54        ("data/aws_redshift_cluster_nodetype_enum", &*embedded::AWS_REDSHIFT_CLUSTER_NODETYPE_ENUM_BYTES),
55        ("data/aws_amazonmq_broker_instancetype_enum", &*embedded::AWS_AMAZONMQ_BROKER_INSTANCETYPE_ENUM_BYTES),
56        (
57            "data/aws_sagemaker_processing_instancetype_enum",
58            &*embedded::AWS_SAGEMAKER_PROCESSING_INSTANCETYPE_ENUM_BYTES,
59        ),
60        ("data/aws_sagemaker_hosting_instancetype_enum", &*embedded::AWS_SAGEMAKER_HOSTING_INSTANCETYPE_ENUM_BYTES),
61        ("data/aws_sagemaker_transform_instancetype_enum", &*embedded::AWS_SAGEMAKER_TRANSFORM_INSTANCETYPE_ENUM_BYTES),
62        ("data/aws_sagemaker_cluster_instancetype_enum", &*embedded::AWS_SAGEMAKER_CLUSTER_INSTANCETYPE_ENUM_BYTES),
63        (
64            "data/aws_elasticsearch_domain_elasticsearchclusterconfig_instancetype_enum",
65            &*embedded::AWS_ELASTICSEARCH_DOMAIN_ELASTICSEARCHCLUSTERCONFIG_INSTANCETYPE_ENUM_BYTES,
66        ),
67        (
68            "data/aws_opensearchservice_domain_clusterconfig_instancetype_enum",
69            &*embedded::AWS_OPENSEARCHSERVICE_DOMAIN_CLUSTERCONFIG_INSTANCETYPE_ENUM_BYTES,
70        ),
71        (GETATT_ATTRIBUTES_PATH, &*embedded::GETATT_ATTRIBUTES_BYTES),
72        ("data/codepipeline_action_artifact_counts", &*embedded::CODEPIPELINE_ACTION_ARTIFACT_COUNTS_BYTES),
73        ("data/deprecated_resource_types", &*embedded::DEPRECATED_RESOURCE_TYPES_BYTES),
74        ("data/retention_period_requirements", &*embedded::RETENTION_PERIOD_REQUIREMENTS_BYTES),
75        ("data/sensitive_ports", &*embedded::SENSITIVE_PORTS_BYTES),
76        ("data/secretsmanager_arn_fields", &*embedded::SECRETSMANAGER_ARN_FIELDS_BYTES),
77        ("data/rule_data", &*embedded::RULE_DATA_BYTES),
78        ("data/rule_tables", &*embedded::RULE_TABLES_BYTES),
79    ]
80});
81
82/// The built-in rule packages, one query each. A package groups rules by
83/// implementation area; the registry, not the package, decides a rule's category.
84const CORE_PACKAGES: &[&str] = &[
85    "data.structure.violation",
86    "data.intrinsics.violation",
87    "data.references.violation",
88    "data.best_practices.violation",
89    "data.resources.violation",
90];
91
92/// The [`REGORUS_DATA`] entry holding the catalog of resource types the rules
93/// treat as existing.
94const KNOWN_RESOURCE_TYPES_PATH: &str = "data/known_resource_types";
95
96/// The [`REGORUS_DATA`] entry holding GetAtt attributes and attribute types.
97const GETATT_ATTRIBUTES_PATH: &str = "data/getatt_attributes";
98
99/// The [`REGORUS_DATA`] entry holding primary identifiers per type.
100const PRIMARY_IDENTIFIERS_PATH: &str = "data/primary_identifiers";
101
102/// Re-serializes the known-resource-type catalog with `extra_types` appended, or
103/// returns `None` when there is nothing to add so the embedded bytes are used
104/// verbatim.
105fn extend_known_resource_types(extra_types: &[String]) -> anyhow::Result<Option<String>> {
106    if extra_types.is_empty() {
107        return Ok(None);
108    }
109    let mut catalog: KnownResourceTypes = serde_json::from_slice(&embedded::KNOWN_RESOURCE_TYPES_BYTES)
110        .map_err(|e| anyhow::anyhow!("Failed to parse the embedded known_resource_types data: {e}"))?;
111    anyhow::ensure!(!catalog.known_resource_types.is_empty(), "Embedded known_resource_types data must not be empty");
112    for type_name in extra_types {
113        if !catalog.known_resource_types.contains(type_name) {
114            catalog.known_resource_types.push(type_name.clone());
115        }
116    }
117    Ok(Some(serde_json::to_string(&catalog)?))
118}
119
120/// Extends the embedded getatt_attributes data with overlay catalog entries.
121/// Returns `None` when there is nothing to add.
122fn extend_getatt_data(catalog: &OverlayCatalog) -> anyhow::Result<Option<String>> {
123    if catalog.getatt_attributes.is_empty() && catalog.getatt_attribute_types.is_empty() {
124        return Ok(None);
125    }
126    let mut data: serde_json::Value = serde_json::from_slice(&embedded::GETATT_ATTRIBUTES_BYTES)
127        .map_err(|e| anyhow::anyhow!("Failed to parse the embedded getatt_attributes data: {e}"))?;
128
129    // Merge getatt_attributes (sort/dedup after merging)
130    if let Some(attrs_obj) = data.get_mut("getatt_attributes").and_then(|v| v.as_object_mut()) {
131        for (type_name, attrs) in &catalog.getatt_attributes {
132            let entry = attrs_obj.entry(type_name.clone()).or_insert_with(|| serde_json::Value::Array(Vec::new()));
133            if let Some(arr) = entry.as_array_mut() {
134                for attr in attrs {
135                    let val = serde_json::Value::String(attr.clone());
136                    if !arr.contains(&val) {
137                        arr.push(val);
138                    }
139                }
140                arr.sort_by(|a, b| a.as_str().unwrap_or("").cmp(b.as_str().unwrap_or("")));
141                arr.dedup();
142            }
143        }
144    }
145
146    // Merge getatt_attribute_types
147    if let Some(types_obj) = data.get_mut("getatt_attribute_types").and_then(|v| v.as_object_mut()) {
148        for (type_name, attr_types) in &catalog.getatt_attribute_types {
149            let entry =
150                types_obj.entry(type_name.clone()).or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
151            if let Some(obj) = entry.as_object_mut() {
152                for (attr, atype) in attr_types {
153                    obj.insert(attr.clone(), serde_json::Value::String(atype.clone()));
154                }
155            }
156        }
157    }
158
159    Ok(Some(serde_json::to_string(&data)?))
160}
161
162/// Extends the embedded primary_identifiers data with overlay catalog entries.
163/// Returns `None` when there is nothing to add.
164fn extend_primary_identifiers_data(catalog: &OverlayCatalog) -> anyhow::Result<Option<String>> {
165    if catalog.primary_identifiers.is_empty() {
166        return Ok(None);
167    }
168    let mut data: serde_json::Value = serde_json::from_slice(&embedded::PRIMARY_IDENTIFIERS_BYTES)
169        .map_err(|e| anyhow::anyhow!("Failed to parse the embedded primary_identifiers data: {e}"))?;
170
171    if let Some(pids_obj) = data.get_mut("primary_identifiers").and_then(|v| v.as_object_mut()) {
172        for (type_name, pids) in &catalog.primary_identifiers {
173            pids_obj.insert(type_name.clone(), serde_json::json!(pids));
174        }
175    }
176
177    Ok(Some(serde_json::to_string(&data)?))
178}
179
180/// Whether an engine instance loads and evaluates the handwritten built-in
181/// policies, or serves only caller-supplied external rules.
182///
183/// In [`BuiltinRuleMode::ExternalOnly`] the built-in policies are neither loaded
184/// nor advertised through registry metadata, so the engine evaluates only custom
185/// and Guard rules. Embedded data tables, custom builtins, and external-rule
186/// metadata are retained in both modes.
187#[derive(Clone, Copy, PartialEq, Eq)]
188enum BuiltinRuleMode {
189    Enabled,
190    ExternalOnly,
191}
192
193/// Who authored a Rego package, which fixes the contract its rules were written
194/// against: how its diagnostics are attributed and how the resolution builtins
195/// render a reference while it evaluates.
196#[derive(Clone, Copy)]
197enum PolicyPackageKind {
198    /// A handwritten built-in policy shipped with the engine.
199    BuiltIn,
200    /// A caller-supplied custom rule.
201    Custom,
202}
203
204impl PolicyPackageKind {
205    fn source_label(self) -> &'static str {
206        match self {
207            Self::BuiltIn => "Core",
208            Self::Custom => "Custom",
209        }
210    }
211
212    /// The origin stamped on the package's diagnostics; a built-in rule keeps the
213    /// origin the registry records for it.
214    fn rule_origin_override(self) -> Option<&'static RuleOrigin> {
215        match self {
216            Self::BuiltIn => None,
217            Self::Custom => Some(&RuleOrigin::Custom),
218        }
219    }
220
221    /// Built-in policies validate literal content and must never see a logical ID
222    /// where a value belongs. Custom rules keep the logical-ID string they were
223    /// written against, so upgrading the engine does not change what they report.
224    fn reference_rendering(self) -> ReferenceRendering {
225        match self {
226            Self::BuiltIn => ReferenceRendering::Marker,
227            Self::Custom => ReferenceRendering::TargetId,
228        }
229    }
230}
231
232pub struct RegoEngine {
233    base_rego: regorus::Engine,
234    /// Whether the handwritten built-in policies are evaluated, or only the
235    /// caller-supplied external rules.
236    builtin_mode: BuiltinRuleMode,
237    /// Built-in rule metadata from the rules registry only.
238    registry_metadata: HashMap<String, RuleMetadataEntry>,
239    /// Custom rego rule metadata discovered from evaluation output.
240    /// Rego rules embed metadata in their output objects, so it can only
241    /// be extracted after evaluation. May be incomplete if not all rules fired.
242    discovered_custom_metadata: Mutex<HashMap<String, RuleMetadataEntry>>,
243    custom_packages: Vec<String>,
244    /// Guard rules are evaluated by the shared Guard evaluator, not by Rego, so
245    /// every engine reports the same Guard findings.
246    guard_rules: Option<GuardRuleSet>,
247    init_metric: PhaseMetric,
248}
249
250impl RegoEngine {
251    pub fn new(config: EngineConfig) -> anyhow::Result<Self> {
252        let overlay_catalog =
253            config.build_overlay_catalog().map_err(|e| anyhow::anyhow!("Failed to build overlay catalog: {e}"))?;
254        let start = web_time::Instant::now();
255        let schema_metadata = schema_metadata_catalog_with_overlays(&overlay_catalog)?;
256        Self::new_from_parts(config, &overlay_catalog, schema_metadata, BuiltinRuleMode::Enabled, start)
257    }
258
259    /// Constructs the engine reusing metadata from an already-built
260    /// [`SchemaValidator`](schema_validator::SchemaValidator). The validator's
261    /// overlay catalog is treated as authoritative - the engine does not
262    /// re-resolve overlay schemas.
263    ///
264    /// This entry point is intended for language bindings and the CLI, which
265    /// construct a `SchemaValidator` once and share it with the engine. The
266    /// validator's shared schema-metadata catalog is reused rather than rebuilt,
267    /// so every engine built from the same validator shares one catalog rather
268    /// than rebuilding it.
269    #[doc(hidden)]
270    pub fn new_with_schema_validator(config: EngineConfig, validator: &SchemaValidator) -> anyhow::Result<Self> {
271        let start = web_time::Instant::now();
272        let schema_metadata = validator.schema_metadata_catalog()?;
273        Self::new_from_parts(config, validator.overlay_catalog(), schema_metadata, BuiltinRuleMode::Enabled, start)
274    }
275
276    /// Constructs an engine that evaluates only the caller-supplied custom and
277    /// Guard rules, without loading or advertising the handwritten built-in
278    /// policies. Embedded data tables and custom builtins remain available so
279    /// external rules can call them.
280    ///
281    /// This backs the composite engine, whose built-in rules are owned by a
282    /// separate engine; loading them here too would double-evaluate them.
283    pub fn new_external_only(config: EngineConfig) -> anyhow::Result<Self> {
284        let overlay_catalog =
285            config.build_overlay_catalog().map_err(|e| anyhow::anyhow!("Failed to build overlay catalog: {e}"))?;
286        let start = web_time::Instant::now();
287        let schema_metadata = schema_metadata_catalog_with_overlays(&overlay_catalog)?;
288        Self::new_from_parts(config, &overlay_catalog, schema_metadata, BuiltinRuleMode::ExternalOnly, start)
289    }
290
291    /// External-only counterpart to [`RegoEngine::new_with_schema_validator`],
292    /// reusing an already-built validator's overlay catalog and shared
293    /// schema-metadata catalog.
294    #[doc(hidden)]
295    pub fn new_external_only_with_schema_validator(
296        config: EngineConfig,
297        validator: &SchemaValidator,
298    ) -> anyhow::Result<Self> {
299        let start = web_time::Instant::now();
300        let schema_metadata = validator.schema_metadata_catalog()?;
301        Self::new_from_parts(config, validator.overlay_catalog(), schema_metadata, BuiltinRuleMode::ExternalOnly, start)
302    }
303
304    /// Internal constructor that accepts a pre-built overlay catalog and the
305    /// shared schema-metadata catalog resolved for it.
306    fn new_from_parts(
307        config: EngineConfig,
308        overlay_catalog: &OverlayCatalog,
309        schema_metadata: Arc<SchemaMetadataCatalog>,
310        builtin_mode: BuiltinRuleMode,
311        start: web_time::Instant,
312    ) -> anyhow::Result<Self> {
313        let mut rego = regorus::Engine::new();
314        rego.set_strict_builtin_errors(false);
315
316        // Single-pass merge avoids per-file JSON parsing overhead.
317        {
318            // Resource types introduced by an overlay schema are legitimate
319            // targets, so the type catalog the rules consult must include them
320            // rather than reporting them as nonexistent.
321            let overlay_types: Vec<String> = overlay_catalog.type_names.clone();
322            let extended_known_types = extend_known_resource_types(&overlay_types)?;
323
324            // Extend getatt_attributes data with overlay entries
325            let extended_getatt = extend_getatt_data(overlay_catalog)?;
326            // Extend primary_identifiers data with overlay entries
327            let extended_primary_ids = extend_primary_identifiers_data(overlay_catalog)?;
328
329            // Embedded lengths closely size the common no-overlay path; extended
330            // overlay documents can grow the buffer normally when they are larger.
331            let merged_capacity: usize = REGORUS_DATA.iter().map(|(_, json_bytes)| json_bytes.len()).sum();
332            let mut merged = String::with_capacity(merged_capacity);
333            merged.push('{');
334            for (i, (path, json_bytes)) in REGORUS_DATA.iter().enumerate() {
335                let json_str = match (
336                    *path,
337                    extended_known_types.as_deref(),
338                    extended_getatt.as_deref(),
339                    extended_primary_ids.as_deref(),
340                ) {
341                    (KNOWN_RESOURCE_TYPES_PATH, Some(extended), _, _) => extended,
342                    (GETATT_ATTRIBUTES_PATH, _, Some(extended), _) => extended,
343                    (PRIMARY_IDENTIFIERS_PATH, _, _, Some(extended)) => extended,
344                    _ => from_utf8(json_bytes).map_err(|error| {
345                        anyhow::anyhow!("Embedded JSON data '{}' is not valid UTF-8: {}", path, error)
346                    })?,
347                };
348                let inner = json_str
349                    .trim()
350                    .strip_prefix('{')
351                    .and_then(|value| value.strip_suffix('}'))
352                    .ok_or_else(|| anyhow::anyhow!("Embedded JSON data '{}' must be a top-level object", path))?;
353                anyhow::ensure!(!inner.trim().is_empty(), "Embedded JSON data '{}' must not be empty", path);
354                if i > 0 {
355                    merged.push(',');
356                }
357                merged.push_str(inner);
358            }
359            merged.push('}');
360            rego.add_data(regorus::Value::from_json_str(&merged)?)?;
361        }
362
363        // In external-only mode the built-in policies are owned by a separate
364        // engine, so they are neither loaded nor evaluated here. Embedded data,
365        // custom builtins, custom Rego, and Guard rules are still loaded below
366        // regardless of mode.
367        let handwritten_rule_count = match builtin_mode {
368            BuiltinRuleMode::Enabled => {
369                for (path, source) in policies::HANDWRITTEN_REGO_POLICIES {
370                    rego.add_policy(path.to_string(), source.to_string())?;
371                }
372                policies::HANDWRITTEN_REGO_POLICIES.len()
373            }
374            BuiltinRuleMode::ExternalOnly => 0,
375        };
376        debug!("Loaded {} data files, {} handwritten rules", REGORUS_DATA.len(), handwritten_rule_count);
377
378        let guard_rules = GuardRuleSet::compile(&config.guard_rules).map_err(|error| anyhow::anyhow!(error))?;
379
380        for entry in &config.custom_rules {
381            rego.add_policy(entry.name.clone(), entry.content.clone())?;
382        }
383        let mut custom_packages = Vec::new();
384        for entry in &config.custom_rules {
385            for line in entry.content.lines() {
386                let trimmed = line.trim();
387                if let Some(pkg) = trimmed.strip_prefix("package ") {
388                    let eval_path = format!("data.{}.violation", pkg.trim());
389                    if !custom_packages.contains(&eval_path) {
390                        custom_packages.push(eval_path);
391                    }
392                }
393            }
394        }
395
396        // The GetAtt return-type table is the process-wide one the schema store
397        // shares, layered with this engine's overlays; resolving it here keeps the
398        // parse off the first template without giving the engine its own copy.
399        let getatt = getatt_data_with_overlays(overlay_catalog)?;
400        crate::builtins::register_all(&mut rego, Arc::clone(&schema_metadata), getatt)?;
401
402        // External-only construction advertises no built-in rules; their
403        // metadata belongs to the engine that owns the built-in policies.
404        let registry_metadata = match builtin_mode {
405            BuiltinRuleMode::Enabled => build_rule_metadata_map(),
406            BuiltinRuleMode::ExternalOnly => HashMap::new(),
407        };
408
409        // Every evaluation clones `rego`, and a clone inherits the prepared state
410        // (analysis, scheduling, and loop hoisting of every loaded policy). Preparing
411        // once here keeps that cost out of every evaluation; without it an engine
412        // that loads only external rules would re-analyze the whole custom rule set
413        // for each template. A trivial query prepares the engine without evaluating
414        // any rule body, so it applies equally to both built-in modes. A preparation
415        // failure is not swallowed: the first real evaluation reports it.
416        let _ = rego.eval_query("true".to_string(), false);
417
418        info!(
419            "RegoEngine initialized: {} handwritten rules, {} data files, {} registry + {} Guard metadata entries",
420            handwritten_rule_count,
421            REGORUS_DATA.len(),
422            registry_metadata.len(),
423            guard_rules.as_ref().map_or(0, |guard| guard.rule_metadata().len())
424        );
425        let init_metric = phase_metric(start);
426        Ok(RegoEngine {
427            base_rego: rego,
428            builtin_mode,
429            registry_metadata,
430            discovered_custom_metadata: Mutex::new(HashMap::new()),
431            custom_packages,
432            guard_rules,
433            init_metric,
434        })
435    }
436
437    /// Evaluates a single Rego package and appends its diagnostics to `out`.
438    ///
439    /// The package's [`ReferenceRendering`] is in effect only while its query
440    /// runs: Regorus discards memoized rule and builtin results between queries,
441    /// so a value rendered for one package never reaches the next.
442    ///
443    /// Any evaluation or serialization failure is returned as a structured
444    /// [`ValidationError`] - an exception the caller can handle - and is never
445    /// converted into a diagnostic. A rule that fails to run must surface as an
446    /// error, not masquerade as a finding.
447    fn eval_package_into(
448        &self,
449        rego: &mut regorus::Engine,
450        package: &str,
451        kind: PolicyPackageKind,
452        model: &SemanticModel,
453        out: &mut Vec<Diagnostic>,
454    ) -> Result<(), ValidationError> {
455        let source_label = kind.source_label();
456        let _rendering = ReferenceRenderingScope::enter(kind.reference_rendering());
457        let value = rego.eval_rule(package.to_string()).map_err(|e| {
458            ValidationError::Engine(format!("{source_label} rule package '{package}' failed to evaluate: {e}"))
459        })?;
460        let diagnostics_json = serde_json::to_value(&value).map_err(|e| {
461            ValidationError::Engine(format!(
462                "{source_label} rule package '{package}' produced a result that could not be \
463                 serialized to JSON: {e}"
464            ))
465        })?;
466        extract_diagnostics_from_value(&diagnostics_json, model, out, kind.rule_origin_override())
467            .map_err(ValidationError::from)
468    }
469
470    /// The built-in rule IDs that global filtering proves cannot survive under
471    /// `config`, so their handwritten clauses can stop at the `cfn_rule_active`
472    /// guard before doing any work. Only rules the registry defines are eligible;
473    /// custom and guard rules are never globally suppressed here.
474    fn globally_suppressed_builtin_rules(&self, config: &ValidateConfig) -> Arc<HashSet<String>> {
475        let suppressed: HashSet<String> = self
476            .registry_metadata
477            .iter()
478            .filter(|(rule_id, entry)| {
479                config.filters.globally_suppresses_builtin_rule(
480                    rule_id,
481                    entry.category.as_deref(),
482                    entry.severity,
483                    config.strict,
484                    config.severity_level,
485                )
486            })
487            .map(|(rule_id, _)| rule_id.clone())
488            .collect();
489        Arc::new(suppressed)
490    }
491}
492
493impl ValidationEngine for RegoEngine {
494    fn engine_name(&self) -> &str {
495        "rego"
496    }
497
498    fn evaluate_rules(
499        &self,
500        model: &Arc<SemanticModel>,
501        config: &ValidateConfig,
502    ) -> Result<Vec<Diagnostic>, ValidationError> {
503        let context = EvaluationContext::new(
504            model.clone(),
505            config.pseudo_parameter_overrides.region.clone(),
506            self.globally_suppressed_builtin_rules(config),
507        );
508        let _scope = EvaluationScope::enter(context);
509
510        let mut rego = self.base_rego.clone();
511
512        let input_json = semantic_model_to_input_json(model)?;
513        let input_value = crate::builtins::serde_json_to_rego_value(&input_json);
514        rego.set_input(input_value);
515
516        let mut diagnostics = Vec::new();
517
518        // Core built-in policies run only when this engine owns them and the
519        // caller has not disabled built-ins. The custom and Guard evaluation
520        // below runs unconditionally so external rules are always evaluated.
521        let evaluate_builtins = matches!(self.builtin_mode, BuiltinRuleMode::Enabled) && !config.disable_builtin_rules;
522        if evaluate_builtins {
523            // Every package is evaluated whatever the category filters say. The
524            // packages group rules by implementation area, not by registry
525            // category - security and parameter rules live in `best_practices`
526            // and `structure` - so skipping a package for one excluded category
527            // would drop its rules of every other category. Each clause instead
528            // stops at its `cfn_rule_active` guard, which the evaluation context
529            // above feeds from the registry category of every rule, so an
530            // excluded rule costs one builtin call. The packages share no rules,
531            // so one query per package costs the same as one aggregated query.
532            for package in CORE_PACKAGES {
533                self.eval_package_into(&mut rego, package, PolicyPackageKind::BuiltIn, model, &mut diagnostics)?;
534            }
535        }
536
537        for pkg in &self.custom_packages {
538            self.eval_package_into(&mut rego, pkg, PolicyPackageKind::Custom, model, &mut diagnostics)?;
539        }
540
541        if let Some(guard_rules) = &self.guard_rules {
542            diagnostics.extend(guard_rules.evaluate(model)?);
543        }
544
545        if !self.custom_packages.is_empty() {
546            let mut discovered = self.discovered_custom_metadata.lock().unwrap_or_else(|e| e.into_inner());
547            for d in &diagnostics {
548                if d.source == RuleOrigin::Custom {
549                    discovered.entry(d.rule_id.clone()).or_insert_with(|| RuleMetadataEntry {
550                        category: d.category.clone(),
551                        description: d.message.clone(),
552                        severity: d.severity,
553                        origin: RuleOrigin::Custom,
554                    });
555                }
556            }
557        }
558
559        Ok(diagnostics)
560    }
561
562    fn list_rules(&self) -> Vec<RuleInfo> {
563        build_rule_list(&self.registry_metadata, &self.external_rule_metadata())
564    }
565
566    fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
567        &self.registry_metadata
568    }
569
570    /// Returns Guard rule metadata merged with any custom Rego rule metadata
571    /// discovered from prior evaluations. Custom diagnostics already carry their
572    /// current evaluation's rule description, so this accumulated metadata cannot
573    /// overwrite a later report.
574    fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
575        let mut merged = self.guard_rules.as_ref().map(|guard| guard.rule_metadata().clone()).unwrap_or_default();
576        if !self.custom_packages.is_empty() {
577            let discovered = self.discovered_custom_metadata.lock().unwrap_or_else(|e| e.into_inner());
578            merged.extend(discovered.iter().map(|(k, v)| (k.clone(), v.clone())));
579        }
580        merged
581    }
582
583    fn init_metric(&self) -> &PhaseMetric {
584        &self.init_metric
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use diagnostics::DetailLevel;
592    use rules::{Category, FilterConfig, RuleFilterConfig, Severity, lookup_rule};
593    use std::sync::Barrier;
594    use std::thread;
595    use template_model::SemanticModel;
596    use validation_engine::{EngineConfig, ExternalRuleSource, ValidateConfig, ValidationEngine, validate_bytes};
597
598    fn make_engine() -> RegoEngine {
599        RegoEngine::new(EngineConfig::default()).unwrap()
600    }
601
602    fn make_model_from_yaml(yaml: &str) -> Arc<SemanticModel> {
603        let model = SemanticModel::from_bytes(yaml.as_bytes()).unwrap();
604        Arc::new(model)
605    }
606
607    #[test]
608    fn new_default_config_succeeds() {
609        let engine = make_engine();
610        assert_eq!(engine.engine_name(), "rego");
611    }
612
613    #[test]
614    fn list_rules_returns_nonempty() {
615        let engine = make_engine();
616        let rules = engine.list_rules();
617        assert!(!rules.is_empty(), "should have built-in rules");
618    }
619
620    #[test]
621    fn rule_metadata_contains_entries() {
622        let engine = make_engine();
623        assert!(!engine.rule_metadata().is_empty());
624    }
625
626    #[test]
627    fn init_metric_has_positive_duration() {
628        let engine = make_engine();
629        let metric = engine.init_metric();
630        assert!(metric.duration_ms > 0.0, "init duration should be > 0, got {}", metric.duration_ms);
631    }
632
633    #[test]
634    fn evaluate_minimal_template_no_errors() {
635        let engine = make_engine();
636        let model = make_model_from_yaml(
637            r#"
638AWSTemplateFormatVersion: "2010-09-09"
639Resources:
640  MyBucket:
641    Type: AWS::S3::Bucket
642"#,
643        );
644        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
645        assert!(
646            diags.iter().all(|d| d.severity != Severity::Fatal),
647            "rego engine should not produce Fatal diagnostics"
648        );
649    }
650
651    #[test]
652    fn evaluate_empty_resources_does_not_panic() {
653        let engine = make_engine();
654        let model = make_model_from_yaml(
655            r#"
656AWSTemplateFormatVersion: "2010-09-09"
657Resources: {}
658"#,
659        );
660        // CloudFormation rejects templates with empty Resources, so the empty-resources diagnostic is expected.
661        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
662        assert!(
663            diags.iter().any(|d| d.rule_id == "F0001"),
664            "expected F0001 Fatal for empty Resources section, got: {:?}",
665            diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>(),
666        );
667    }
668
669    /// Excluding a category must remove exactly the rules the registry files
670    /// under it. The `best_practices` package also hosts security rules, so a
671    /// package-level skip would wrongly drop them along with the best-practice
672    /// rules; the template fires one of each from that package.
673    #[test]
674    fn evaluate_with_excluded_categories() {
675        let engine = make_engine();
676        let model = make_model_from_yaml(
677            r#"
678AWSTemplateFormatVersion: "2010-09-09"
679Resources:
680  Permission:
681    Type: AWS::Lambda::Permission
682    Properties:
683      Action: lambda:InvokeFunction
684      FunctionName: my-function
685      Principal: sns.amazonaws.com
686      SourceArn: arn:aws:sns:us-east-1:123456789012:my-topic
687"#,
688        );
689        fn rule_ids(diags: &[Diagnostic]) -> HashSet<&str> {
690            diags.iter().map(|d| d.rule_id.as_str()).collect()
691        }
692        let diags_all = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
693        let all_ids = rule_ids(&diags_all);
694        assert!(all_ids.contains("W9002"), "sanity: the best-practice hardcoded-ARN rule fires: {all_ids:?}");
695        assert!(all_ids.contains("W9013"), "sanity: the security hardcoded-account-ID rule fires: {all_ids:?}");
696
697        let excluded = Category::BestPractice;
698        let config = ValidateConfig {
699            filters: FilterConfig::new(
700                RuleFilterConfig::default(),
701                RuleFilterConfig { categories: vec![excluded.as_str().to_string()], ..Default::default() },
702            ),
703            ..Default::default()
704        };
705        let diags_filtered = engine.evaluate_rules(&model, &config).unwrap();
706        let filtered_ids = rule_ids(&diags_filtered);
707
708        let expected_ids: HashSet<&str> =
709            all_ids.iter().copied().filter(|id| lookup_rule(id).is_none_or(|rule| rule.category != excluded)).collect();
710        assert_eq!(filtered_ids, expected_ids, "only rules the registry files under {excluded:?} may disappear");
711        assert!(!filtered_ids.contains("W9002"), "the excluded best-practice rule is gone");
712        assert!(filtered_ids.contains("W9013"), "the security rule hosted in the same package survives");
713    }
714
715    #[test]
716    fn custom_rego_rule_produces_diagnostics() {
717        let custom_rego = r#"
718package custom_test
719import rego.v1
720
721violation contains v if {
722    some name, res in input.resources
723    res.resourceType == "AWS::S3::Bucket"
724    v := {"rule_id": "CUSTOM001", "severity": "error", "message": "Custom rule fired", "resource_id": name}
725}
726"#;
727        let config = EngineConfig {
728            custom_rules: vec![ExternalRuleSource { name: "custom_test.rego".into(), content: custom_rego.into() }],
729            guard_rules: vec![],
730            ..Default::default()
731        };
732        let engine = RegoEngine::new(config).unwrap();
733        let model = make_model_from_yaml(
734            r#"
735AWSTemplateFormatVersion: "2010-09-09"
736Resources:
737  MyBucket:
738    Type: AWS::S3::Bucket
739"#,
740        );
741        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
742        let custom = diags.iter().find(|d| d.rule_id == "CUSTOM001");
743        assert!(custom.is_some(), "custom rule should fire");
744    }
745
746    #[test]
747    fn guard_rule_produces_diagnostics() {
748        let guard_source = r#"
749rule check_bucket_name {
750    AWS::S3::Bucket {
751        Properties.BucketName EXISTS
752        <<BucketName must be specified>>
753    }
754}
755"#;
756        let config = EngineConfig {
757            custom_rules: vec![],
758            guard_rules: vec![ExternalRuleSource { name: "test.guard".into(), content: guard_source.into() }],
759            ..Default::default()
760        };
761        let engine = RegoEngine::new(config).unwrap();
762        let model = make_model_from_yaml(
763            r#"
764AWSTemplateFormatVersion: "2010-09-09"
765Resources:
766  MyBucket:
767    Type: AWS::S3::Bucket
768"#,
769        );
770        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
771        let guard_diag = diags.iter().find(|d| d.rule_id == "check_bucket_name").expect("guard rule fires");
772        assert_eq!(guard_diag.source, RuleOrigin::Guard);
773        assert_eq!(guard_diag.category.as_deref(), Some("guard:test"));
774        assert_eq!(guard_diag.message, "BucketName must be specified");
775        assert_eq!(guard_diag.resource_logical_id(), Some("MyBucket"));
776    }
777
778    #[test]
779    fn guard_rule_exists_check_does_not_fire_when_property_is_present() {
780        let guard_source = r#"
781rule check_bucket_name {
782    AWS::S3::Bucket {
783        Properties.BucketName EXISTS
784        <<BucketName must be specified>>
785    }
786}
787"#;
788        let config = EngineConfig {
789            custom_rules: vec![],
790            guard_rules: vec![ExternalRuleSource { name: "test.guard".into(), content: guard_source.into() }],
791            ..Default::default()
792        };
793        let engine = RegoEngine::new(config).unwrap();
794        let model = make_model_from_yaml(
795            r#"
796AWSTemplateFormatVersion: "2010-09-09"
797Resources:
798  MyBucket:
799    Type: AWS::S3::Bucket
800    Properties:
801      BucketName: my-bucket
802"#,
803        );
804        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
805        let guard_diag = diags.iter().find(|d| d.rule_id == "check_bucket_name");
806        assert!(
807            guard_diag.is_none(),
808            "guard EXISTS must be satisfied when BucketName is present, so the rule must not fire; got: {:?}",
809            diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>()
810        );
811    }
812
813    #[test]
814    fn external_only_engine_evaluates_external_rules_but_skips_builtins() {
815        let custom_rego = r#"
816package external_only_test
817import rego.v1
818
819violation contains v if {
820    v := {"rule_id": "EXT_ONLY", "severity": "error", "message": "external rule fired", "resource_id": ""}
821}
822"#;
823        let config = EngineConfig {
824            custom_rules: vec![ExternalRuleSource {
825                name: "external_only_test.rego".into(),
826                content: custom_rego.into(),
827            }],
828            guard_rules: vec![],
829            ..Default::default()
830        };
831        let engine = RegoEngine::new_external_only(config).unwrap();
832        assert!(engine.rule_metadata().is_empty(), "an external-only engine must advertise no built-in registry rules");
833
834        // An empty Resources section makes a normal engine emit a built-in
835        // structural diagnostic, so its absence proves the built-ins were
836        // skipped rather than simply not triggered.
837        let model = make_model_from_yaml(
838            r#"
839AWSTemplateFormatVersion: "2010-09-09"
840Resources: {}
841"#,
842        );
843        let normal_diags = RegoEngine::new(EngineConfig::default())
844            .unwrap()
845            .evaluate_rules(&model, &ValidateConfig::default())
846            .unwrap();
847        assert!(
848            normal_diags.iter().any(|d| d.rule_id == "F0001"),
849            "a normal engine must emit the built-in F0001 for empty Resources"
850        );
851
852        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
853        let external = diags.iter().find(|d| d.rule_id == "EXT_ONLY").expect("the external rule must still evaluate");
854        assert_eq!(external.severity, Severity::Error);
855        assert_eq!(external.source, RuleOrigin::Custom);
856        assert!(
857            diags.iter().all(|d| d.rule_id == "EXT_ONLY"),
858            "external-only evaluation must produce no built-in diagnostics, got: {:?}",
859            diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>()
860        );
861        assert!(
862            !engine.list_rules().iter().any(|r| r.id == "F0001"),
863            "external-only list_rules must not include built-in rule IDs"
864        );
865    }
866
867    #[test]
868    fn external_only_engine_registers_custom_builtin_helpers() {
869        let custom_rego = r#"
870package external_builtin_test
871import rego.v1
872
873violation contains make_diag("EXT_BUILTINS", "error", name, "custom builtins available") if {
874    some name in resources_of_type("AWS::S3::Bucket")
875    has_property(name, "BucketName")
876    resolve(name, "Properties.BucketName") == "custom-builtins-bucket"
877    "BucketName" in schema_properties("AWS::S3::Bucket")
878}
879"#;
880        let engine = RegoEngine::new_external_only(EngineConfig {
881            custom_rules: vec![ExternalRuleSource {
882                name: "external_builtin_test.rego".into(),
883                content: custom_rego.into(),
884            }],
885            ..Default::default()
886        })
887        .expect("external-only engine must initialize with custom builtins");
888        let model = make_model_from_yaml(
889            r#"
890Resources:
891  Bucket:
892    Type: AWS::S3::Bucket
893    Properties:
894      BucketName: custom-builtins-bucket
895"#,
896        );
897
898        let diagnostics = engine.evaluate_rules(&model, &ValidateConfig::default()).expect("custom rule must evaluate");
899        let finding = diagnostics
900            .iter()
901            .find(|diagnostic| diagnostic.rule_id == "EXT_BUILTINS")
902            .expect("custom rule using resource, resolution, schema, and diagnostic helpers must fire");
903        assert_eq!(finding.source, RuleOrigin::Custom);
904        assert_eq!(finding.resource_logical_id(), Some("Bucket"));
905    }
906
907    #[test]
908    fn evaluate_rules_can_be_called_repeatedly() {
909        let engine = make_engine();
910        let model = make_model_from_yaml(
911            r#"
912AWSTemplateFormatVersion: "2010-09-09"
913Resources:
914  A:
915    Type: AWS::S3::Bucket
916"#,
917        );
918        // The per-thread evaluation context is installed and torn down each call,
919        // so repeated evaluations on one thread neither deadlock nor leak state.
920        let _ = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
921        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
922        assert!(
923            diags.iter().all(|d| d.severity != Severity::Fatal),
924            "rego engine should not produce Fatal diagnostics"
925        );
926    }
927
928    #[test]
929    fn build_rule_metadata_map_returns_entries() {
930        let index = build_rule_metadata_map();
931        assert!(!index.is_empty());
932    }
933
934    const BUILTIN_TEST_TEMPLATE: &str = r#"
935AWSTemplateFormatVersion: "2010-09-09"
936Parameters:
937  Env:
938    Type: String
939    AllowedValues:
940      - prod
941      - dev
942  Port:
943    Type: Number
944Conditions:
945  IsProd:
946    Fn::Equals:
947      - !Ref Env
948      - prod
949Mappings:
950  RegionMap:
951    us-east-1:
952      AMI: ami-12345678
953    us-west-2:
954      AMI: ami-abcdefgh
955Resources:
956  MyBucket:
957    Type: AWS::S3::Bucket
958    Properties:
959      BucketName: my-bucket
960      Tags:
961        - Key: Env
962          Value: !If [IsProd, "production", "development"]
963  MyFunc:
964    Type: AWS::Lambda::Function
965    DependsOn: MyBucket
966    Condition: IsProd
967    Properties:
968      FunctionName: my-func
969      Runtime: python3.12
970      Handler: index.handler
971      Code:
972        S3Bucket: !Ref MyBucket
973        S3Key: code.zip
974      VpcConfig:
975        SecurityGroupIds:
976          - sg-0123456789abcdef0
977        SubnetIds:
978          - !Ref MySubnet
979          - subnet-0123456789abcdef0
980  MyVpc:
981    Type: AWS::EC2::VPC
982    Properties:
983      CidrBlock: 10.0.0.0/16
984  MySubnet:
985    Type: AWS::EC2::Subnet
986    Properties:
987      VpcId: !Ref MyVpc
988      CidrBlock: 10.0.0.0/24
989  MyQueue:
990    Type: AWS::SQS::Queue
991    Properties:
992      QueueName: my-queue
993      MessageRetentionPeriod: !Ref Port
994Transform: AWS::Serverless-2016-10-31
995"#;
996
997    fn eval_builtin_policy_result(rego_source: &str) -> Result<Vec<Diagnostic>, validation_engine::ValidationError> {
998        let config = EngineConfig {
999            custom_rules: vec![ExternalRuleSource { name: "builtin_test.rego".into(), content: rego_source.into() }],
1000            guard_rules: vec![],
1001            ..Default::default()
1002        };
1003        let engine = RegoEngine::new(config).unwrap();
1004        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1005        engine.evaluate_rules(&model, &ValidateConfig::default())
1006    }
1007
1008    fn eval_builtin_policy(rego_source: &str, rule_id: &str) -> Vec<Diagnostic> {
1009        eval_builtin_policy_result(rego_source).unwrap().into_iter().filter(|d| d.rule_id == rule_id).collect()
1010    }
1011
1012    #[test]
1013    fn builtin_resolve_returns_property_value() {
1014        let diags = eval_builtin_policy(
1015            r#"
1016package builtin_test
1017import rego.v1
1018violation contains v if {
1019    val := resolve("MyBucket", "BucketName")
1020    val == "my-bucket"
1021    v := {"rule_id": "B_RESOLVE", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1022}
1023"#,
1024            "B_RESOLVE",
1025        );
1026        assert_eq!(diags.len(), 1, "resolve should return 'my-bucket'");
1027    }
1028
1029    #[test]
1030    fn builtin_resolve_all_returns_conditional_values() {
1031        let diags = eval_builtin_policy(
1032            r#"
1033package builtin_test
1034import rego.v1
1035violation contains v if {
1036    vals := resolve_all("MyBucket", "Tags.0.Value")
1037    count(vals) >= 1
1038    v := {"rule_id": "B_RESOLVE_ALL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1039}
1040"#,
1041            "B_RESOLVE_ALL",
1042        );
1043        assert_eq!(diags.len(), 1, "resolve_all should return at least one value");
1044    }
1045
1046    /// The contract custom rules were written against: `resolve` on a `Ref` or
1047    /// `Fn::GetAtt` yields the target's logical ID as a string, so a rule can look
1048    /// the target up in `input.resources` or compare it with another logical ID.
1049    const LEGACY_REFERENCE_CUSTOM_RULE: &str = r#"
1050package legacy_reference_test
1051import rego.v1
1052
1053violation contains make_diag("LEGACY_LOOKUP", "error", name, sprintf("code bucket is %s", [target])) if {
1054    some name in resources_of_type("AWS::Lambda::Function")
1055    target := resolve(name, "Properties.Code.S3Bucket")
1056    is_string(target)
1057    input.resources[target].resourceType == "AWS::S3::Bucket"
1058}
1059
1060violation contains make_diag("LEGACY_EQUALS", "error", name, "code lives in MyBucket") if {
1061    some name in resources_of_type("AWS::Lambda::Function")
1062    resolve(name, "Properties.Code.S3Bucket") == "MyBucket"
1063}
1064
1065violation contains make_diag("LEGACY_MARKER_SEEN", "error", name, "resolve returned the marker object") if {
1066    some name in resources_of_type("AWS::Lambda::Function")
1067    is_object(resolve(name, "Properties.Code.S3Bucket"))
1068}
1069"#;
1070
1071    fn assert_legacy_reference_contract(diags: &[Diagnostic]) {
1072        let lookup = diags
1073            .iter()
1074            .find(|d| d.rule_id == "LEGACY_LOOKUP")
1075            .expect("resolve() must hand back a logical ID a rule can look up in input.resources");
1076        assert_eq!(lookup.message, "code bucket is MyBucket");
1077        assert_eq!(lookup.resource_logical_id(), Some("MyFunc"));
1078        assert!(
1079            diags.iter().any(|d| d.rule_id == "LEGACY_EQUALS"),
1080            "resolve() must compare equal to the target's logical ID"
1081        );
1082        assert!(
1083            !diags.iter().any(|d| d.rule_id == "LEGACY_MARKER_SEEN"),
1084            "a custom rule must never see the marker object from resolve()"
1085        );
1086    }
1087
1088    #[test]
1089    fn custom_rule_resolve_keeps_the_legacy_target_id_string_for_a_reference() {
1090        let engine = RegoEngine::new(EngineConfig {
1091            custom_rules: vec![ExternalRuleSource {
1092                name: "legacy_reference_test.rego".into(),
1093                content: LEGACY_REFERENCE_CUSTOM_RULE.into(),
1094            }],
1095            ..Default::default()
1096        })
1097        .unwrap();
1098        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1099
1100        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1101
1102        assert_legacy_reference_contract(&diags);
1103    }
1104
1105    #[test]
1106    fn external_only_engine_keeps_the_legacy_target_id_string_for_a_reference() {
1107        let engine = RegoEngine::new_external_only(EngineConfig {
1108            custom_rules: vec![ExternalRuleSource {
1109                name: "legacy_reference_test.rego".into(),
1110                content: LEGACY_REFERENCE_CUSTOM_RULE.into(),
1111            }],
1112            ..Default::default()
1113        })
1114        .unwrap();
1115        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1116
1117        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1118
1119        assert_legacy_reference_contract(&diags);
1120    }
1121
1122    #[test]
1123    fn custom_rule_resolve_all_list_items_keep_the_legacy_target_id_string() {
1124        let diags = eval_builtin_policy(
1125            r#"
1126package builtin_test
1127import rego.v1
1128violation contains make_diag("LEGACY_LIST_ITEM", "error", "MyFunc", sprintf("subnets: %v", [subnets])) if {
1129    some subnets in resolve_all("MyFunc", "Properties.VpcConfig.SubnetIds")
1130    subnets == ["MySubnet", "subnet-0123456789abcdef0"]
1131}
1132"#,
1133            "LEGACY_LIST_ITEM",
1134        );
1135        assert_eq!(diags.len(), 1, "a referenced list item must come back as the target's logical ID");
1136    }
1137
1138    /// Both kinds of package evaluate on one Regorus engine instance, so the
1139    /// rendering must switch per package: the built-in policies fixed to skip a
1140    /// reference they would otherwise judge as a literal must keep doing so while
1141    /// the custom package beside them still receives the legacy string.
1142    #[test]
1143    fn builtin_policies_keep_the_marker_rendering_while_a_custom_rule_gets_the_legacy_string() {
1144        let engine = RegoEngine::new(EngineConfig {
1145            custom_rules: vec![ExternalRuleSource {
1146                name: "legacy_reference_test.rego".into(),
1147                content: LEGACY_REFERENCE_CUSTOM_RULE.into(),
1148            }],
1149            ..Default::default()
1150        })
1151        .unwrap();
1152        let model = make_model_from_yaml(
1153            r#"
1154AWSTemplateFormatVersion: "2010-09-09"
1155Resources:
1156  Store:
1157    Type: AWS::SSM::Parameter
1158    Properties:
1159      Type: String
1160      Value: placeholder
1161  MyBucket:
1162    Type: AWS::S3::Bucket
1163  Distribution:
1164    Type: AWS::CloudFront::Distribution
1165    Properties:
1166      DistributionConfig:
1167        Enabled: true
1168        Aliases:
1169          - !GetAtt Store.Value
1170        DefaultCacheBehavior:
1171          TargetOriginId: primary
1172          ViewerProtocolPolicy: redirect-to-https
1173          ForwardedValues:
1174            QueryString: false
1175        Origins:
1176          - Id: primary
1177            DomainName: origin.example.com
1178            CustomOriginConfig:
1179              OriginProtocolPolicy: https-only
1180  MyFunc:
1181    Type: AWS::Lambda::Function
1182    Properties:
1183      Runtime: python3.12
1184      Handler: index.handler
1185      Role: arn:aws:iam::123456789012:role/lambda-role
1186      Code:
1187        S3Bucket: !Ref MyBucket
1188        S3Key: code.zip
1189"#,
1190        );
1191
1192        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1193
1194        assert!(
1195            !diags.iter().any(|d| d.rule_id == "E3013"),
1196            "the built-in alias check must not judge the logical ID 'Store' as a domain name: {:?}",
1197            diags.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
1198        );
1199        assert_legacy_reference_contract(&diags);
1200    }
1201
1202    #[test]
1203    fn builtin_is_dynamic_on_conditional_property() {
1204        let diags = eval_builtin_policy(
1205            r#"
1206package builtin_test
1207import rego.v1
1208violation contains v if {
1209    is_dynamic("MyFunc", "Code.S3Bucket")
1210    v := {"rule_id": "B_IS_DYN", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1211}
1212"#,
1213            "B_IS_DYN",
1214        );
1215        assert_eq!(diags.len(), 1, "Code.S3Bucket is a Ref → dynamic");
1216    }
1217
1218    #[test]
1219    fn builtin_is_dynamic_false_for_static() {
1220        let diags = eval_builtin_policy(
1221            r#"
1222package builtin_test
1223import rego.v1
1224violation contains v if {
1225    not is_dynamic("MyBucket", "BucketName")
1226    v := {"rule_id": "B_NOT_DYN", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1227}
1228"#,
1229            "B_NOT_DYN",
1230        );
1231        assert_eq!(diags.len(), 1, "BucketName is static");
1232    }
1233
1234    #[test]
1235    fn builtin_property_can_be_absent_uses_known_absence() {
1236        let diags = eval_builtin_policy(
1237            r#"
1238package builtin_test
1239import rego.v1
1240violation contains v if {
1241    property_can_be_absent("MyQueue", "Properties.RedrivePolicy")
1242    not property_can_be_absent("MyBucket", "Properties.BucketName")
1243    not property_can_be_absent("MyQueue", "Properties.MessageRetentionPeriod")
1244    property_can_be_absent("MissingQueue", "Properties.MessageRetentionPeriod")
1245    v := {"rule_id": "B_PROPERTY_ABSENCE", "severity": "error", "message": "ok", "resource_id": "MyQueue"}
1246}
1247"#,
1248            "B_PROPERTY_ABSENCE",
1249        );
1250        assert_eq!(diags.len(), 1, "only known property absence should satisfy the builtin");
1251    }
1252
1253    #[test]
1254    fn builtin_resources_of_type() {
1255        let diags = eval_builtin_policy(
1256            r#"
1257package builtin_test
1258import rego.v1
1259violation contains v if {
1260    ids := resources_of_type("AWS::S3::Bucket")
1261    count(ids) == 1
1262    v := {"rule_id": "B_ROT", "severity": "error", "message": "ok", "resource_id": ""}
1263}
1264"#,
1265            "B_ROT",
1266        );
1267        assert_eq!(diags.len(), 1, "should find 1 S3 bucket");
1268    }
1269
1270    #[test]
1271    fn builtin_has_property_true() {
1272        let diags = eval_builtin_policy(
1273            r#"
1274package builtin_test
1275import rego.v1
1276violation contains v if {
1277    has_property("MyBucket", "BucketName")
1278    v := {"rule_id": "B_HAS_P", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1279}
1280"#,
1281            "B_HAS_P",
1282        );
1283        assert_eq!(diags.len(), 1, "MyBucket has BucketName");
1284    }
1285
1286    #[test]
1287    fn builtin_has_property_false() {
1288        let diags = eval_builtin_policy(
1289            r#"
1290package builtin_test
1291import rego.v1
1292violation contains v if {
1293    not has_property("MyBucket", "NonExistent")
1294    v := {"rule_id": "B_NO_P", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1295}
1296"#,
1297            "B_NO_P",
1298        );
1299        assert_eq!(diags.len(), 1, "MyBucket does not have NonExistent");
1300    }
1301
1302    #[test]
1303    fn builtin_has_transform() {
1304        let diags = eval_builtin_policy(
1305            r#"
1306package builtin_test
1307import rego.v1
1308violation contains v if {
1309    has_transform("AWS::Serverless-2016-10-31")
1310    v := {"rule_id": "B_HAS_T", "severity": "error", "message": "ok", "resource_id": ""}
1311}
1312"#,
1313            "B_HAS_T",
1314        );
1315        assert_eq!(diags.len(), 1, "template has Serverless transform");
1316    }
1317
1318    #[test]
1319    fn builtin_param_allowed_values() {
1320        let diags = eval_builtin_policy(
1321            r#"
1322package builtin_test
1323import rego.v1
1324violation contains v if {
1325    vals := param_allowed_values("Env")
1326    count(vals) == 2
1327    v := {"rule_id": "B_PAV", "severity": "error", "message": "ok", "resource_id": ""}
1328}
1329"#,
1330            "B_PAV",
1331        );
1332        assert_eq!(diags.len(), 1, "Env has 2 allowed values");
1333    }
1334
1335    #[test]
1336    fn builtin_param_type() {
1337        let diags = eval_builtin_policy(
1338            r#"
1339package builtin_test
1340import rego.v1
1341violation contains v if {
1342    t := param_type("Port")
1343    t == "Number"
1344    v := {"rule_id": "B_PT", "severity": "error", "message": "ok", "resource_id": ""}
1345}
1346"#,
1347            "B_PT",
1348        );
1349        assert_eq!(diags.len(), 1, "Port param type is Number");
1350    }
1351
1352    #[test]
1353    fn builtin_mapping_value() {
1354        let diags = eval_builtin_policy(
1355            r#"
1356package builtin_test
1357import rego.v1
1358violation contains v if {
1359    val := mapping_value("RegionMap", "us-east-1", "AMI")
1360    val == "ami-12345678"
1361    v := {"rule_id": "B_MAP", "severity": "error", "message": "ok", "resource_id": ""}
1362}
1363"#,
1364            "B_MAP",
1365        );
1366        assert_eq!(diags.len(), 1, "mapping lookup should return ami-12345678");
1367    }
1368
1369    #[test]
1370    fn builtin_resource_condition() {
1371        let diags = eval_builtin_policy(
1372            r#"
1373package builtin_test
1374import rego.v1
1375violation contains v if {
1376    c := resource_condition("MyFunc")
1377    c == "IsProd"
1378    v := {"rule_id": "B_RC", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1379}
1380"#,
1381            "B_RC",
1382        );
1383        assert_eq!(diags.len(), 1, "MyFunc has condition IsProd");
1384    }
1385
1386    #[test]
1387    fn builtin_resource_condition_null_when_none() {
1388        let diags = eval_builtin_policy(
1389            r#"
1390package builtin_test
1391import rego.v1
1392violation contains v if {
1393    c := resource_condition("MyBucket")
1394    is_null(c)
1395    v := {"rule_id": "B_RC_NULL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1396}
1397"#,
1398            "B_RC_NULL",
1399        );
1400        assert_eq!(diags.len(), 1, "MyBucket has no condition");
1401    }
1402
1403    #[test]
1404    fn builtin_get_resource() {
1405        let diags = eval_builtin_policy(
1406            r#"
1407package builtin_test
1408import rego.v1
1409violation contains v if {
1410    res := get_resource("MyBucket")
1411    res.resourceType == "AWS::S3::Bucket"
1412    v := {"rule_id": "B_GR", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1413}
1414"#,
1415            "B_GR",
1416        );
1417        assert_eq!(diags.len(), 1, "get_resource should return bucket data");
1418    }
1419
1420    #[test]
1421    fn builtin_depends_on() {
1422        let diags = eval_builtin_policy(
1423            r#"
1424package builtin_test
1425import rego.v1
1426violation contains v if {
1427    depends_on("MyFunc", "MyBucket")
1428    v := {"rule_id": "B_DEP", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1429}
1430"#,
1431            "B_DEP",
1432        );
1433        assert_eq!(diags.len(), 1, "MyFunc depends on MyBucket");
1434    }
1435
1436    #[test]
1437    fn builtin_ref_targets() {
1438        let diags = eval_builtin_policy(
1439            r#"
1440package builtin_test
1441import rego.v1
1442violation contains v if {
1443    targets := ref_targets("MyFunc")
1444    count(targets) > 0
1445    v := {"rule_id": "B_RT", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1446}
1447"#,
1448            "B_RT",
1449        );
1450        assert_eq!(diags.len(), 1, "MyFunc should have ref targets (MyBucket)");
1451    }
1452
1453    #[test]
1454    fn builtin_ref_sources() {
1455        let diags = eval_builtin_policy(
1456            r#"
1457package builtin_test
1458import rego.v1
1459violation contains v if {
1460    sources := ref_sources("MyBucket")
1461    count(sources) > 0
1462    v := {"rule_id": "B_RS", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1463}
1464"#,
1465            "B_RS",
1466        );
1467        assert_eq!(diags.len(), 1, "MyBucket should have ref sources (MyFunc)");
1468    }
1469
1470    #[test]
1471    fn builtin_edges_from() {
1472        let diags = eval_builtin_policy(
1473            r#"
1474package builtin_test
1475import rego.v1
1476violation contains v if {
1477    edges := edges_from("MyFunc")
1478    count(edges) > 0
1479    v := {"rule_id": "B_EF", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1480}
1481"#,
1482            "B_EF",
1483        );
1484        assert_eq!(diags.len(), 1, "MyFunc should have outgoing edges");
1485    }
1486
1487    #[test]
1488    fn builtin_edges_to() {
1489        let diags = eval_builtin_policy(
1490            r#"
1491package builtin_test
1492import rego.v1
1493violation contains v if {
1494    edges := edges_to("MyBucket")
1495    count(edges) > 0
1496    v := {"rule_id": "B_ET", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1497}
1498"#,
1499            "B_ET",
1500        );
1501        assert_eq!(diags.len(), 1, "MyBucket should have incoming edges");
1502    }
1503
1504    #[test]
1505    fn builtin_make_diag_at() {
1506        let diags = eval_builtin_policy(
1507            r#"
1508package builtin_test
1509import rego.v1
1510violation contains v if {
1511    v := make_diag_at("B_MDA", "error", "MyBucket", "BucketName", "test diag at")
1512}
1513"#,
1514            "B_MDA",
1515        );
1516        assert_eq!(diags.len(), 1);
1517        assert_eq!(diags[0].message, "test diag at");
1518    }
1519
1520    #[test]
1521    fn builtin_make_diag_full() {
1522        let diags = eval_builtin_policy(
1523            r#"
1524package builtin_test
1525import rego.v1
1526violation contains v if {
1527    v := make_diag_full("B_MDF", "error", "MyBucket", "BucketName", "full diag", "fix it", "https://example.com")
1528}
1529"#,
1530            "B_MDF",
1531        );
1532        assert_eq!(diags.len(), 1);
1533        assert_eq!(diags[0].message, "full diag");
1534    }
1535
1536    #[test]
1537    fn builtin_make_diag_at_source_conditional_rejects_non_object_conditions() {
1538        let evaluation = eval_builtin_policy_result(
1539            r#"
1540package builtin_test
1541import rego.v1
1542violation contains make_diag_at_source_conditional(
1543    "B_BAD_CONDITIONS", "error", "MyBucket", "BucketName", "BucketName",
1544    "bad conditions", "", ["not-an-object"]
1545) if {
1546    true
1547}
1548"#,
1549        );
1550
1551        let error = evaluation.expect_err("a non-object condition scenario must fail evaluation");
1552        assert!(error.to_string().contains("condition scenario must be an object"));
1553    }
1554
1555    #[test]
1556    fn builtin_schema_properties_known_type() {
1557        let diags = eval_builtin_policy(
1558            r#"
1559package builtin_test
1560import rego.v1
1561violation contains v if {
1562    props := schema_properties("AWS::S3::Bucket")
1563    # Just verify the builtin is callable and returns an array
1564    is_array(props)
1565    v := {"rule_id": "B_SP", "severity": "error", "message": "ok", "resource_id": ""}
1566}
1567"#,
1568            "B_SP",
1569        );
1570        assert_eq!(diags.len(), 1, "schema_properties should be callable and return array");
1571    }
1572
1573    #[test]
1574    fn builtin_schema_required_known_type() {
1575        let diags = eval_builtin_policy(
1576            r#"
1577package builtin_test
1578import rego.v1
1579violation contains v if {
1580    req := schema_required("AWS::Lambda::Function")
1581    is_array(req)
1582    v := {"rule_id": "B_SR", "severity": "error", "message": "ok", "resource_id": ""}
1583}
1584"#,
1585            "B_SR",
1586        );
1587        assert_eq!(diags.len(), 1, "schema_required should be callable and return array");
1588    }
1589
1590    #[test]
1591    fn builtin_conditions_compatible() {
1592        let diags = eval_builtin_policy(
1593            r#"
1594package builtin_test
1595import rego.v1
1596violation contains v if {
1597    conditions_compatible("MyBucket", "MyQueue")
1598    v := {"rule_id": "B_CC", "severity": "error", "message": "ok", "resource_id": ""}
1599}
1600"#,
1601            "B_CC",
1602        );
1603        assert_eq!(diags.len(), 1, "unconditional resources are always compatible");
1604    }
1605
1606    #[test]
1607    fn builtin_resolve_type() {
1608        let diags = eval_builtin_policy(
1609            r#"
1610package builtin_test
1611import rego.v1
1612violation contains v if {
1613    t := resolve_type("MyBucket", "BucketName")
1614    t == "string"
1615    v := {"rule_id": "B_RTYPE", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1616}
1617"#,
1618            "B_RTYPE",
1619        );
1620        assert_eq!(diags.len(), 1, "BucketName should resolve to string type");
1621    }
1622
1623    #[test]
1624    fn region_override_available_to_builtins() {
1625        let custom_rego = r#"
1626package region_test
1627import rego.v1
1628violation contains v if {
1629    r := input_region()
1630    r == "eu-west-1"
1631    v := {"rule_id": "B_REGION", "severity": "error", "message": "ok", "resource_id": ""}
1632}
1633"#;
1634        let config = EngineConfig {
1635            custom_rules: vec![ExternalRuleSource { name: "region_test.rego".into(), content: custom_rego.into() }],
1636            guard_rules: vec![],
1637            ..Default::default()
1638        };
1639        let engine = RegoEngine::new(config).unwrap();
1640        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1641        let mut vc = ValidateConfig::default();
1642        vc.pseudo_parameter_overrides.region = Some("eu-west-1".to_string());
1643        let diags = engine.evaluate_rules(&model, &vc).unwrap();
1644        let found = diags.iter().find(|d| d.rule_id == "B_REGION");
1645        assert!(found.is_some(), "input_region() should return eu-west-1");
1646    }
1647
1648    #[test]
1649    fn multiple_custom_packages_all_evaluated() {
1650        let pkg_a = r#"
1651package custom_a
1652import rego.v1
1653violation contains v if {
1654    v := {"rule_id": "PKG_A", "severity": "error", "message": "from A", "resource_id": ""}
1655}
1656"#;
1657        let pkg_b = r#"
1658package custom_b
1659import rego.v1
1660violation contains v if {
1661    v := {"rule_id": "PKG_B", "severity": "error", "message": "from B", "resource_id": ""}
1662}
1663"#;
1664        let config = EngineConfig {
1665            custom_rules: vec![
1666                ExternalRuleSource { name: "a.rego".into(), content: pkg_a.into() },
1667                ExternalRuleSource { name: "b.rego".into(), content: pkg_b.into() },
1668            ],
1669            guard_rules: vec![],
1670            ..Default::default()
1671        };
1672        let engine = RegoEngine::new(config).unwrap();
1673        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1674        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1675        assert!(diags.iter().any(|d| d.rule_id == "PKG_A"), "package A should fire");
1676        assert!(diags.iter().any(|d| d.rule_id == "PKG_B"), "package B should fire");
1677    }
1678
1679    #[test]
1680    fn duplicate_custom_package_deduplicated() {
1681        let source = r#"
1682package custom_dedup
1683import rego.v1
1684violation contains v if {
1685    v := {"rule_id": "DEDUP1", "severity": "error", "message": "first", "resource_id": ""}
1686}
1687"#;
1688        let config = EngineConfig {
1689            custom_rules: vec![
1690                ExternalRuleSource { name: "a.rego".into(), content: source.into() },
1691                ExternalRuleSource { name: "b.rego".into(), content: source.into() },
1692            ],
1693            guard_rules: vec![],
1694            ..Default::default()
1695        };
1696        let engine = RegoEngine::new(config).unwrap();
1697        assert_eq!(
1698            engine.custom_packages.iter().filter(|p| p.contains("custom_dedup")).count(),
1699            1,
1700            "duplicate package should be deduplicated"
1701        );
1702    }
1703
1704    #[test]
1705    fn builtin_follow_ref_returns_target_resource() {
1706        let diags = eval_builtin_policy(
1707            r#"
1708package builtin_test
1709import rego.v1
1710violation contains v if {
1711    target := follow_ref("MyFunc", "Code.S3Bucket")
1712    target == "MyBucket"
1713    v := {"rule_id": "B_FREF", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1714}
1715"#,
1716            "B_FREF",
1717        );
1718        assert_eq!(diags.len(), 1, "follow_ref should resolve Ref to MyBucket");
1719    }
1720
1721    #[test]
1722    fn builtin_is_from_parameter_true_for_ref_param() {
1723        let diags = eval_builtin_policy(
1724            r#"
1725package builtin_test
1726import rego.v1
1727violation contains v if {
1728    is_from_parameter("MyBucket", "Tags.0.Value")
1729    v := {"rule_id": "B_IFP", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1730}
1731"#,
1732            "B_IFP",
1733        );
1734        // Tags.0.Value uses Fn::If with a condition based on a parameter, so it's from a parameter
1735        assert!(diags.len() <= 1, "expected at most 1 diagnostic, got {}", diags.len());
1736    }
1737
1738    #[test]
1739    fn builtin_flatten_list_returns_items() {
1740        let diags = eval_builtin_policy(
1741            r#"
1742package builtin_test
1743import rego.v1
1744violation contains v if {
1745    items := flatten_list("MyBucket", "Tags")
1746    count(items) >= 1
1747    v := {"rule_id": "B_FL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1748}
1749"#,
1750            "B_FL",
1751        );
1752        assert_eq!(diags.len(), 1, "flatten_list should return tag items");
1753    }
1754
1755    #[test]
1756    fn builtin_resolve_scenarios_returns_values() {
1757        let diags = eval_builtin_policy(
1758            r#"
1759package builtin_test
1760import rego.v1
1761violation contains v if {
1762    scenarios := resolve_scenarios("MyBucket", "Tags.0.Value")
1763    count(scenarios) >= 1
1764    v := {"rule_id": "B_RS2", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1765}
1766"#,
1767            "B_RS2",
1768        );
1769        assert_eq!(diags.len(), 1, "resolve_scenarios should return at least one scenario");
1770    }
1771
1772    #[test]
1773    fn builtin_is_satisfiable_with_compatible_assumptions() {
1774        let diags = eval_builtin_policy(
1775            r#"
1776package builtin_test
1777import rego.v1
1778violation contains v if {
1779    is_satisfiable({"IsProd": true})
1780    v := {"rule_id": "B_SAT", "severity": "error", "message": "ok", "resource_id": ""}
1781}
1782"#,
1783            "B_SAT",
1784        );
1785        assert_eq!(diags.len(), 1, "IsProd=true should be satisfiable");
1786    }
1787
1788    #[test]
1789    fn builtin_make_diag_produces_diagnostic() {
1790        let diags = eval_builtin_policy(
1791            r#"
1792package builtin_test
1793import rego.v1
1794violation contains v if {
1795    v := make_diag("B_MD", "error", "MyBucket", "test message")
1796}
1797"#,
1798            "B_MD",
1799        );
1800        assert_eq!(diags.len(), 1);
1801        assert_eq!(diags[0].message, "test message");
1802    }
1803
1804    #[test]
1805    fn builtin_estimated_string_length_bounds_returns_both_bounds() {
1806        // `!If [IsProd, "production", "development"]` - the deployment picks one of
1807        // two known values, so the length is bounded but not fixed.
1808        let diags = eval_builtin_policy(
1809            r#"
1810package builtin_test
1811import rego.v1
1812violation contains v if {
1813    bounds := estimated_string_length_bounds("MyBucket", "Tags.0.Value")
1814    bounds.shortest == 10
1815    bounds.longest == 11
1816    v := {"rule_id": "B_ESL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1817}
1818"#,
1819            "B_ESL",
1820        );
1821        assert_eq!(diags.len(), 1, "a value chosen between two known strings must report both bounds");
1822    }
1823
1824    #[test]
1825    fn builtin_estimated_string_length_bounds_is_undefined_for_a_literal() {
1826        // Schema validation checks a literal against the constraint, so this rule
1827        // is not given one to estimate.
1828        let diags = eval_builtin_policy(
1829            r#"
1830package builtin_test
1831import rego.v1
1832violation contains v if {
1833    bounds := estimated_string_length_bounds("MyBucket", "BucketName")
1834    v := {"rule_id": "B_ESL_LITERAL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1835}
1836"#,
1837            "B_ESL_LITERAL",
1838        );
1839        assert!(diags.is_empty(), "a literal string must yield no bounds, got {diags:?}");
1840    }
1841
1842    #[test]
1843    fn regoengine_is_send_and_sync() {
1844        fn assert_send_sync<T: Send + Sync>() {}
1845        assert_send_sync::<RegoEngine>();
1846    }
1847
1848    #[test]
1849    fn evaluate_rules_isolates_model_and_region_across_threads() {
1850        // One engine, many threads, each with a distinct model and region. The
1851        // probes echo the region and the resource names the thread should see, so
1852        // any leakage between threads' evaluation contexts surfaces as a mismatch.
1853        let probe = r#"
1854package concurrency_probe
1855import rego.v1
1856violation contains v if {
1857    r := input_region()
1858    v := {"rule_id": "REGIONPROBE", "severity": "info", "message": r, "resource_id": ""}
1859}
1860violation contains v if {
1861    some name in resources_of_type("AWS::S3::Bucket")
1862    v := {"rule_id": "MODELPROBE", "severity": "info", "message": name, "resource_id": name}
1863}
1864violation contains v if {
1865    some name, _ in input.resources
1866    v := {"rule_id": "INPUTPROBE", "severity": "info", "message": name, "resource_id": name}
1867}
1868"#;
1869        let config = EngineConfig {
1870            custom_rules: vec![ExternalRuleSource { name: "concurrency_probe.rego".into(), content: probe.into() }],
1871            guard_rules: vec![],
1872            ..Default::default()
1873        };
1874        let engine = Arc::new(RegoEngine::new(config).unwrap());
1875
1876        const THREAD_COUNT: usize = 8;
1877        const ITERATIONS_PER_THREAD: usize = 128;
1878        let barrier = Arc::new(Barrier::new(THREAD_COUNT));
1879        let handles: Vec<_> = (0..THREAD_COUNT)
1880            .map(|i| {
1881                let engine = Arc::clone(&engine);
1882                let barrier = Arc::clone(&barrier);
1883                thread::spawn(move || {
1884                    let region = format!("region-{i}");
1885                    let bucket = format!("Bucket{i}");
1886                    let model = make_model_from_yaml(&format!(
1887                        "AWSTemplateFormatVersion: \"2010-09-09\"\nResources:\n  {bucket}:\n    Type: AWS::S3::Bucket\n"
1888                    ));
1889                    let mut config = ValidateConfig::default();
1890                    config.pseudo_parameter_overrides.region = Some(region.clone());
1891                    // Release every thread at once so their evaluations overlap.
1892                    barrier.wait();
1893                    let mut diagnostics = Vec::new();
1894                    for _ in 0..ITERATIONS_PER_THREAD {
1895                        diagnostics = engine.evaluate_rules(&model, &config).unwrap();
1896                        let observed_regions: Vec<&str> = diagnostics
1897                            .iter()
1898                            .filter(|diagnostic| diagnostic.rule_id == "REGIONPROBE")
1899                            .map(|diagnostic| diagnostic.message.as_str())
1900                            .collect();
1901                        assert_eq!(observed_regions, vec![region.as_str()]);
1902                        let observed_resources: Vec<&str> = diagnostics
1903                            .iter()
1904                            .filter(|diagnostic| diagnostic.rule_id == "MODELPROBE")
1905                            .map(|diagnostic| diagnostic.message.as_str())
1906                            .collect();
1907                        assert_eq!(observed_resources, vec![bucket.as_str()]);
1908                        let observed_input_resources: Vec<&str> = diagnostics
1909                            .iter()
1910                            .filter(|diagnostic| diagnostic.rule_id == "INPUTPROBE")
1911                            .map(|diagnostic| diagnostic.message.as_str())
1912                            .collect();
1913                        assert_eq!(observed_input_resources, vec![bucket.as_str()]);
1914                    }
1915                    (region, bucket, diagnostics)
1916                })
1917            })
1918            .collect();
1919
1920        for handle in handles {
1921            let (region, bucket, diags) = handle.join().unwrap();
1922            let regions: Vec<&str> =
1923                diags.iter().filter(|d| d.rule_id == "REGIONPROBE").map(|d| d.message.as_str()).collect();
1924            assert_eq!(regions, vec![region.as_str()], "a thread must observe only its own region");
1925            let resources: Vec<&str> =
1926                diags.iter().filter(|d| d.rule_id == "MODELPROBE").map(|d| d.message.as_str()).collect();
1927            assert_eq!(resources, vec![bucket.as_str()], "a thread must observe only its own model");
1928            let input_resources: Vec<&str> =
1929                diags.iter().filter(|d| d.rule_id == "INPUTPROBE").map(|d| d.message.as_str()).collect();
1930            assert_eq!(input_resources, vec![bucket.as_str()], "a thread must observe only its own Rego input");
1931        }
1932    }
1933
1934    #[test]
1935    fn shared_engine_keeps_thousand_sequential_reports_template_local() {
1936        const CUSTOM_RULE: &str = r#"
1937package sequential_isolation
1938import rego.v1
1939
1940violation contains v if {
1941    pattern := input.template.description
1942    some name, resource in input.resources
1943    regex.match(pattern, resource.properties.Value)
1944    region := input_region()
1945    message := sprintf("%s/%s", [name, region])
1946    v := {
1947        "rule_id": "SEQUENTIALPROBE",
1948        "severity": "info",
1949        "category": "isolation",
1950        "message": message,
1951        "resource_id": name,
1952    }
1953}
1954"#;
1955        let engine = RegoEngine::new(EngineConfig {
1956            custom_rules: vec![ExternalRuleSource {
1957                name: "sequential_isolation.rego".into(),
1958                content: CUSTOM_RULE.into(),
1959            }],
1960            ..Default::default()
1961        })
1962        .unwrap();
1963        let schema_validator = SchemaValidator::default();
1964
1965        for iteration in 0..1_000 {
1966            let (resource_name, region, pattern, value) = if iteration % 2 == 0 {
1967                ("AlphaResource", "alpha-region", "^alpha+$", "alpha")
1968            } else {
1969                ("BetaResource", "beta-region", "^beta+$", "beta")
1970            };
1971            let template = format!(
1972                r#"{{
1973                    "AWSTemplateFormatVersion": "2010-09-09",
1974                    "Description": "{pattern}",
1975                    "Resources": {{
1976                        "{resource_name}": {{
1977                            "Type": "Custom::Probe",
1978                            "Properties": {{"Value": "{value}"}}
1979                        }}
1980                    }}
1981                }}"#
1982            );
1983            let mut config = ValidateConfig {
1984                detail_level: DetailLevel::Detailed,
1985                severity_level: Severity::Debug,
1986                disable_builtin_rules: true,
1987                ..Default::default()
1988            };
1989            config.pseudo_parameter_overrides.region = Some(region.to_string());
1990
1991            let report = validate_bytes(&engine, &schema_validator, template.as_bytes(), config).unwrap();
1992            let probes: Vec<&Diagnostic> =
1993                report.diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "SEQUENTIALPROBE").collect();
1994            assert_eq!(probes.len(), 1, "iteration {iteration} must emit exactly one probe diagnostic");
1995            let expected_message = format!("{resource_name}/{region}");
1996            assert_eq!(probes[0].message, expected_message, "the current template supplies the diagnostic message");
1997            assert_eq!(
1998                probes[0].rule_description.as_deref(),
1999                Some(expected_message.as_str()),
2000                "the current template supplies its own detailed rule description"
2001            );
2002            assert_eq!(probes[0].category.as_deref(), Some("isolation"));
2003        }
2004    }
2005
2006    #[test]
2007    fn failed_evaluation_does_not_contaminate_the_next_call() {
2008        const CUSTOM_RULE: &str = r#"
2009package error_cleanup
2010import rego.v1
2011
2012violation contains v if {
2013    input.template.description == "error"
2014    ip_overlaps("not-a-cidr", "10.0.0.0/8")
2015    v := {"rule_id": "UNREACHABLE", "severity": "error", "message": "unreachable"}
2016}
2017
2018violation contains v if {
2019    input.template.description == "ok"
2020    some name in resources_of_type("Custom::Probe")
2021    region := input_region()
2022    v := {
2023        "rule_id": "RECOVERYPROBE",
2024        "severity": "info",
2025        "message": sprintf("%s/%s", [name, region]),
2026        "resource_id": name,
2027    }
2028}
2029"#;
2030        let engine = RegoEngine::new(EngineConfig {
2031            custom_rules: vec![ExternalRuleSource { name: "error_cleanup.rego".into(), content: CUSTOM_RULE.into() }],
2032            ..Default::default()
2033        })
2034        .unwrap();
2035        let error_model =
2036            make_model_from_yaml("Description: error\nResources:\n  ErrorResource:\n    Type: Custom::Probe\n");
2037        let valid_model =
2038            make_model_from_yaml("Description: ok\nResources:\n  ValidResource:\n    Type: Custom::Probe\n");
2039        let mut error_config = ValidateConfig { disable_builtin_rules: true, ..Default::default() };
2040        error_config.pseudo_parameter_overrides.region = Some("error-region".to_string());
2041        assert!(engine.evaluate_rules(&error_model, &error_config).is_err(), "the first evaluation must fail");
2042
2043        let mut valid_config = ValidateConfig { disable_builtin_rules: true, ..Default::default() };
2044        valid_config.pseudo_parameter_overrides.region = Some("valid-region".to_string());
2045        let diagnostics = engine.evaluate_rules(&valid_model, &valid_config).unwrap();
2046        let recovery: Vec<&Diagnostic> =
2047            diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "RECOVERYPROBE").collect();
2048        assert_eq!(recovery.len(), 1, "the next evaluation must recover after the prior error");
2049        assert_eq!(recovery[0].message, "ValidResource/valid-region");
2050    }
2051
2052    const SUPPRESSION_TEMPLATE: &str = r#"
2053AWSTemplateFormatVersion: "2010-09-09"
2054Resources:
2055  MyInstance:
2056    Type: AWS::EC2::Instance
2057    Properties:
2058      ImageId: ami-12345678
2059      InstanceType: !Sub "t3.micro"
2060"#;
2061
2062    fn exclude_ids(ids: &[&str]) -> ValidateConfig {
2063        ValidateConfig {
2064            filters: FilterConfig::new(
2065                RuleFilterConfig::default(),
2066                RuleFilterConfig { ids: ids.iter().map(|s| s.to_string()).collect(), ..Default::default() },
2067            ),
2068            ..Default::default()
2069        }
2070    }
2071
2072    #[test]
2073    fn exclude_id_suppresses_matching_builtin_and_preserves_other_findings() {
2074        let engine = make_engine();
2075        let model = make_model_from_yaml(SUPPRESSION_TEMPLATE);
2076
2077        let unfiltered = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
2078        assert!(unfiltered.iter().any(|d| d.rule_id == "W9010"), "hardcoded AMI should trigger W9010");
2079        let mut other_findings: Vec<(String, String)> = unfiltered
2080            .iter()
2081            .filter(|d| d.rule_id != "W9010")
2082            .map(|d| (d.rule_id.clone(), d.message.clone()))
2083            .collect();
2084        assert!(!other_findings.is_empty(), "template must also produce a non-W9010 finding worth preserving");
2085        other_findings.sort();
2086
2087        let filtered = engine.evaluate_rules(&model, &exclude_ids(&["W9010"])).unwrap();
2088        assert!(filtered.iter().all(|d| d.rule_id != "W9010"), "the excluded rule must not be emitted");
2089        let mut remaining: Vec<(String, String)> =
2090            filtered.iter().map(|d| (d.rule_id.clone(), d.message.clone())).collect();
2091        remaining.sort();
2092        assert_eq!(remaining, other_findings, "excluding one rule must not alter any other diagnostic");
2093
2094        let unfiltered_again = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
2095        assert!(
2096            unfiltered_again.iter().any(|diagnostic| diagnostic.rule_id == "W9010"),
2097            "a later unfiltered call must not inherit the previous call's suppressed-rule set"
2098        );
2099    }
2100
2101    #[test]
2102    fn include_id_globally_suppresses_every_other_builtin() {
2103        let engine = make_engine();
2104        let model = make_model_from_yaml(SUPPRESSION_TEMPLATE);
2105
2106        let config = ValidateConfig {
2107            filters: FilterConfig::new(
2108                RuleFilterConfig { ids: vec!["W9010".to_string()], ..Default::default() },
2109                RuleFilterConfig::default(),
2110            ),
2111            ..Default::default()
2112        };
2113        let filtered = engine.evaluate_rules(&model, &config).unwrap();
2114        assert!(filtered.iter().any(|d| d.rule_id == "W9010"), "the included rule must still fire");
2115        assert!(
2116            filtered.iter().all(|d| d.rule_id == "W9010"),
2117            "a non-empty include filter must globally suppress every rule it can never admit"
2118        );
2119    }
2120
2121    #[test]
2122    fn severity_level_prunes_lower_severity_builtins() {
2123        let engine = make_engine();
2124        let model = make_model_from_yaml(SUPPRESSION_TEMPLATE);
2125
2126        let default_run = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
2127        assert!(default_run.iter().any(|d| d.rule_id == "W9010"), "W9010 fires at the default Info level");
2128
2129        let config = ValidateConfig { severity_level: Severity::Error, ..Default::default() };
2130        let error_only = engine.evaluate_rules(&model, &config).unwrap();
2131        assert!(
2132            error_only.iter().all(|d| d.severity >= Severity::Error),
2133            "no sub-Error diagnostics should be produced when the level is Error"
2134        );
2135        assert!(
2136            error_only.iter().all(|d| d.rule_id != "W9010"),
2137            "the Warn rule W9010 must be pruned at the Error level"
2138        );
2139    }
2140
2141    #[test]
2142    fn cfn_rule_active_guard_short_circuits_a_suppressed_rule_body() {
2143        // Every built-in clause is shaped like this probe: cfn_rule_active guards
2144        // the body, so a globally suppressed rule stops before its body runs. The
2145        // body here would evaluate regex.match on an invalid pattern if reached;
2146        // the probe diagnostic is the observable signal that the body executed.
2147        let probe = r#"
2148package guard_probe
2149import rego.v1
2150violation contains v if {
2151    cfn_rule_active("W9010")
2152    not regex.match("(unterminated", "anything")
2153    v := {"rule_id": "GUARDPROBE", "severity": "error", "message": "guarded body ran", "resource_id": ""}
2154}
2155"#;
2156        let config = EngineConfig {
2157            custom_rules: vec![ExternalRuleSource { name: "guard_probe.rego".into(), content: probe.into() }],
2158            guard_rules: vec![],
2159            ..Default::default()
2160        };
2161        let engine = RegoEngine::new(config).unwrap();
2162        let model = make_model_from_yaml(
2163            r#"
2164AWSTemplateFormatVersion: "2010-09-09"
2165Resources:
2166  MyBucket:
2167    Type: AWS::S3::Bucket
2168"#,
2169        );
2170
2171        let active = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
2172        assert!(active.iter().any(|d| d.rule_id == "GUARDPROBE"), "an active rule's guarded body must run");
2173
2174        let suppressed = engine.evaluate_rules(&model, &exclude_ids(&["W9010"])).unwrap();
2175        assert!(
2176            suppressed.iter().all(|d| d.rule_id != "GUARDPROBE"),
2177            "a suppressed rule's guarded body - including its invalid regex - must not run"
2178        );
2179    }
2180}