1use crate::policies;
2use data_source::embedded;
3use data_source::types::KnownResourceTypes;
4use diagnostics::{Diagnostic, PhaseMetric, phase_metric};
5use log::{debug, info, warn};
6use rules::{Category, RuleInfo, RuleMetadataEntry, RuleOrigin, build_rule_metadata_map};
7use schema_validator::{OverlayCatalog, SchemaMetadataCatalog, SchemaValidator, schema_metadata_catalog_with_overlays};
8use std::collections::{HashMap, HashSet};
9use std::str::from_utf8;
10use std::sync::{Arc, LazyLock, Mutex};
11use template_model::SemanticModel;
12use validation_engine::{
13 EngineConfig, GuardRuleSet, ValidateConfig, ValidationEngine, ValidationError, build_rule_list,
14 extract_diagnostics_from_value, semantic_model_to_input_json,
15};
16
17static REGORUS_DATA: LazyLock<Vec<(&str, &[u8])>> = LazyLock::new(|| {
18 vec![
19 (KNOWN_RESOURCE_TYPES_PATH, &*embedded::KNOWN_RESOURCE_TYPES_BYTES),
20 (PRIMARY_IDENTIFIERS_PATH, &*embedded::PRIMARY_IDENTIFIERS_BYTES),
21 ("data/iam_action_resource_patterns", &*embedded::IAM_ACTION_RESOURCE_PATTERNS_BYTES),
22 ("data/stateful_resource_types", &*embedded::STATEFUL_RESOURCE_TYPES_BYTES),
23 ("data/aws_rds_dbinstance_dbinstanceclass_enum", &*embedded::AWS_RDS_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES),
24 ("data/aws_ec2_instance_instancetype_enum", &*embedded::AWS_EC2_INSTANCE_INSTANCETYPE_ENUM_BYTES),
25 (
26 "data/aws_emr_cluster_instancetypeconfig_instancetype_enum",
27 &*embedded::AWS_EMR_CLUSTER_INSTANCETYPECONFIG_INSTANCETYPE_ENUM_BYTES,
28 ),
29 ("data/aws_gamelift_fleet_ec2instancetype_enum", &*embedded::AWS_GAMELIFT_FLEET_EC2INSTANCETYPE_ENUM_BYTES),
30 ("data/aws_appstream_fleet_instancetype_enum", &*embedded::AWS_APPSTREAM_FLEET_INSTANCETYPE_ENUM_BYTES),
31 ("data/aws_dax_cluster_nodetype_enum", &*embedded::AWS_DAX_CLUSTER_NODETYPE_ENUM_BYTES),
32 ("data/aws_docdb_dbinstance_dbinstanceclass_enum", &*embedded::AWS_DOCDB_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES),
33 (
34 "data/aws_elasticache_cachecluster_cachenodetype_enum",
35 &*embedded::AWS_ELASTICACHE_CACHECLUSTER_CACHENODETYPE_ENUM_BYTES,
36 ),
37 (
38 "data/aws_managedblockchain_node_nodeconfiguration_instancetype_enum",
39 &*embedded::AWS_MANAGEDBLOCKCHAIN_NODE_NODECONFIGURATION_INSTANCETYPE_ENUM_BYTES,
40 ),
41 (
42 "data/aws_neptune_dbinstance_dbinstanceclass_enum",
43 &*embedded::AWS_NEPTUNE_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES,
44 ),
45 (
46 "data/aws_rds_dbcluster_dbclusterinstanceclass_enum",
47 &*embedded::AWS_RDS_DBCLUSTER_DBCLUSTERINSTANCECLASS_ENUM_BYTES,
48 ),
49 ("data/aws_rds_dbinstance_db_instance_class", &*embedded::AWS_RDS_DBINSTANCE_DB_INSTANCE_CLASS_BYTES),
50 ("data/aws_redshift_cluster_nodetype_enum", &*embedded::AWS_REDSHIFT_CLUSTER_NODETYPE_ENUM_BYTES),
51 ("data/aws_amazonmq_broker_instancetype_enum", &*embedded::AWS_AMAZONMQ_BROKER_INSTANCETYPE_ENUM_BYTES),
52 (
53 "data/aws_sagemaker_processing_instancetype_enum",
54 &*embedded::AWS_SAGEMAKER_PROCESSING_INSTANCETYPE_ENUM_BYTES,
55 ),
56 ("data/aws_sagemaker_hosting_instancetype_enum", &*embedded::AWS_SAGEMAKER_HOSTING_INSTANCETYPE_ENUM_BYTES),
57 ("data/aws_sagemaker_transform_instancetype_enum", &*embedded::AWS_SAGEMAKER_TRANSFORM_INSTANCETYPE_ENUM_BYTES),
58 ("data/aws_sagemaker_cluster_instancetype_enum", &*embedded::AWS_SAGEMAKER_CLUSTER_INSTANCETYPE_ENUM_BYTES),
59 (
60 "data/aws_elasticsearch_domain_elasticsearchclusterconfig_instancetype_enum",
61 &*embedded::AWS_ELASTICSEARCH_DOMAIN_ELASTICSEARCHCLUSTERCONFIG_INSTANCETYPE_ENUM_BYTES,
62 ),
63 (
64 "data/aws_opensearchservice_domain_clusterconfig_instancetype_enum",
65 &*embedded::AWS_OPENSEARCHSERVICE_DOMAIN_CLUSTERCONFIG_INSTANCETYPE_ENUM_BYTES,
66 ),
67 (GETATT_ATTRIBUTES_PATH, &*embedded::GETATT_ATTRIBUTES_BYTES),
68 ("data/codepipeline_action_artifact_counts", &*embedded::CODEPIPELINE_ACTION_ARTIFACT_COUNTS_BYTES),
69 ("data/deprecated_resource_types", &*embedded::DEPRECATED_RESOURCE_TYPES_BYTES),
70 ("data/retention_period_requirements", &*embedded::RETENTION_PERIOD_REQUIREMENTS_BYTES),
71 ("data/sensitive_ports", &*embedded::SENSITIVE_PORTS_BYTES),
72 ("data/secretsmanager_arn_fields", &*embedded::SECRETSMANAGER_ARN_FIELDS_BYTES),
73 ("data/rule_data", &*embedded::RULE_DATA_BYTES),
74 ("data/rule_tables", &*embedded::RULE_TABLES_BYTES),
75 ]
76});
77
78const CORE_PACKAGES: &[(Category, &str)] = &[
79 (Category::Structure, "data.structure.violation"),
80 (Category::Intrinsic, "data.intrinsics.violation"),
81 (Category::Reference, "data.references.violation"),
82 (Category::BestPractice, "data.best_practices.violation"),
83 (Category::Resource, "data.resources.violation"),
84];
85
86const KNOWN_RESOURCE_TYPES_PATH: &str = "data/known_resource_types";
89
90const GETATT_ATTRIBUTES_PATH: &str = "data/getatt_attributes";
92
93const PRIMARY_IDENTIFIERS_PATH: &str = "data/primary_identifiers";
95
96fn extend_known_resource_types(extra_types: &[String]) -> anyhow::Result<Option<String>> {
100 if extra_types.is_empty() {
101 return Ok(None);
102 }
103 let mut catalog: KnownResourceTypes = serde_json::from_slice(&embedded::KNOWN_RESOURCE_TYPES_BYTES)
104 .map_err(|e| anyhow::anyhow!("Failed to parse the embedded known_resource_types data: {e}"))?;
105 anyhow::ensure!(!catalog.known_resource_types.is_empty(), "Embedded known_resource_types data must not be empty");
106 for type_name in extra_types {
107 if !catalog.known_resource_types.contains(type_name) {
108 catalog.known_resource_types.push(type_name.clone());
109 }
110 }
111 Ok(Some(serde_json::to_string(&catalog)?))
112}
113
114fn extend_getatt_data(catalog: &OverlayCatalog) -> anyhow::Result<Option<String>> {
117 if catalog.getatt_attributes.is_empty() && catalog.getatt_attribute_types.is_empty() {
118 return Ok(None);
119 }
120 let mut data: serde_json::Value = serde_json::from_slice(&embedded::GETATT_ATTRIBUTES_BYTES)
121 .map_err(|e| anyhow::anyhow!("Failed to parse the embedded getatt_attributes data: {e}"))?;
122
123 if let Some(attrs_obj) = data.get_mut("getatt_attributes").and_then(|v| v.as_object_mut()) {
125 for (type_name, attrs) in &catalog.getatt_attributes {
126 let entry = attrs_obj.entry(type_name.clone()).or_insert_with(|| serde_json::Value::Array(Vec::new()));
127 if let Some(arr) = entry.as_array_mut() {
128 for attr in attrs {
129 let val = serde_json::Value::String(attr.clone());
130 if !arr.contains(&val) {
131 arr.push(val);
132 }
133 }
134 arr.sort_by(|a, b| a.as_str().unwrap_or("").cmp(b.as_str().unwrap_or("")));
135 arr.dedup();
136 }
137 }
138 }
139
140 if let Some(types_obj) = data.get_mut("getatt_attribute_types").and_then(|v| v.as_object_mut()) {
142 for (type_name, attr_types) in &catalog.getatt_attribute_types {
143 let entry =
144 types_obj.entry(type_name.clone()).or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
145 if let Some(obj) = entry.as_object_mut() {
146 for (attr, atype) in attr_types {
147 obj.insert(attr.clone(), serde_json::Value::String(atype.clone()));
148 }
149 }
150 }
151 }
152
153 Ok(Some(serde_json::to_string(&data)?))
154}
155
156fn extend_primary_identifiers_data(catalog: &OverlayCatalog) -> anyhow::Result<Option<String>> {
159 if catalog.primary_identifiers.is_empty() {
160 return Ok(None);
161 }
162 let mut data: serde_json::Value = serde_json::from_slice(&embedded::PRIMARY_IDENTIFIERS_BYTES)
163 .map_err(|e| anyhow::anyhow!("Failed to parse the embedded primary_identifiers data: {e}"))?;
164
165 if let Some(pids_obj) = data.get_mut("primary_identifiers").and_then(|v| v.as_object_mut()) {
166 for (type_name, pids) in &catalog.primary_identifiers {
167 pids_obj.insert(type_name.clone(), serde_json::json!(pids));
168 }
169 }
170
171 Ok(Some(serde_json::to_string(&data)?))
172}
173
174#[derive(Clone, Copy, PartialEq, Eq)]
182enum BuiltinRuleMode {
183 Enabled,
184 ExternalOnly,
185}
186
187pub struct RegoEngine {
188 base_rego: regorus::Engine,
189 builtin_mode: BuiltinRuleMode,
192 registry_metadata: HashMap<String, RuleMetadataEntry>,
194 discovered_custom_metadata: Mutex<HashMap<String, RuleMetadataEntry>>,
198 custom_packages: Vec<String>,
199 guard_rules: Option<GuardRuleSet>,
202 init_metric: PhaseMetric,
203}
204
205impl RegoEngine {
206 pub fn new(config: EngineConfig) -> anyhow::Result<Self> {
207 let overlay_catalog =
208 config.build_overlay_catalog().map_err(|e| anyhow::anyhow!("Failed to build overlay catalog: {e}"))?;
209 let start = web_time::Instant::now();
210 let schema_metadata = schema_metadata_catalog_with_overlays(&overlay_catalog)?;
211 Self::new_from_parts(config, &overlay_catalog, schema_metadata, BuiltinRuleMode::Enabled, start)
212 }
213
214 #[doc(hidden)]
225 pub fn new_with_schema_validator(config: EngineConfig, validator: &SchemaValidator) -> anyhow::Result<Self> {
226 let start = web_time::Instant::now();
227 let schema_metadata = validator.schema_metadata_catalog()?;
228 Self::new_from_parts(config, validator.overlay_catalog(), schema_metadata, BuiltinRuleMode::Enabled, start)
229 }
230
231 pub fn new_external_only(config: EngineConfig) -> anyhow::Result<Self> {
239 let overlay_catalog =
240 config.build_overlay_catalog().map_err(|e| anyhow::anyhow!("Failed to build overlay catalog: {e}"))?;
241 let start = web_time::Instant::now();
242 let schema_metadata = schema_metadata_catalog_with_overlays(&overlay_catalog)?;
243 Self::new_from_parts(config, &overlay_catalog, schema_metadata, BuiltinRuleMode::ExternalOnly, start)
244 }
245
246 #[doc(hidden)]
250 pub fn new_external_only_with_schema_validator(
251 config: EngineConfig,
252 validator: &SchemaValidator,
253 ) -> anyhow::Result<Self> {
254 let start = web_time::Instant::now();
255 let schema_metadata = validator.schema_metadata_catalog()?;
256 Self::new_from_parts(config, validator.overlay_catalog(), schema_metadata, BuiltinRuleMode::ExternalOnly, start)
257 }
258
259 fn new_from_parts(
262 config: EngineConfig,
263 overlay_catalog: &OverlayCatalog,
264 schema_metadata: Arc<SchemaMetadataCatalog>,
265 builtin_mode: BuiltinRuleMode,
266 start: web_time::Instant,
267 ) -> anyhow::Result<Self> {
268 let mut rego = regorus::Engine::new();
269 rego.set_strict_builtin_errors(false);
270
271 {
273 let overlay_types: Vec<String> = overlay_catalog.type_names.clone();
277 let extended_known_types = extend_known_resource_types(&overlay_types)?;
278
279 let extended_getatt = extend_getatt_data(overlay_catalog)?;
281 let extended_primary_ids = extend_primary_identifiers_data(overlay_catalog)?;
283
284 let merged_capacity: usize = REGORUS_DATA.iter().map(|(_, json_bytes)| json_bytes.len()).sum();
287 let mut merged = String::with_capacity(merged_capacity);
288 merged.push('{');
289 for (i, (path, json_bytes)) in REGORUS_DATA.iter().enumerate() {
290 let json_str = match (
291 *path,
292 extended_known_types.as_deref(),
293 extended_getatt.as_deref(),
294 extended_primary_ids.as_deref(),
295 ) {
296 (KNOWN_RESOURCE_TYPES_PATH, Some(extended), _, _) => extended,
297 (GETATT_ATTRIBUTES_PATH, _, Some(extended), _) => extended,
298 (PRIMARY_IDENTIFIERS_PATH, _, _, Some(extended)) => extended,
299 _ => from_utf8(json_bytes).map_err(|error| {
300 anyhow::anyhow!("Embedded JSON data '{}' is not valid UTF-8: {}", path, error)
301 })?,
302 };
303 let inner = json_str
304 .trim()
305 .strip_prefix('{')
306 .and_then(|value| value.strip_suffix('}'))
307 .ok_or_else(|| anyhow::anyhow!("Embedded JSON data '{}' must be a top-level object", path))?;
308 anyhow::ensure!(!inner.trim().is_empty(), "Embedded JSON data '{}' must not be empty", path);
309 if i > 0 {
310 merged.push(',');
311 }
312 merged.push_str(inner);
313 }
314 merged.push('}');
315 rego.add_data(regorus::Value::from_json_str(&merged)?)?;
316 }
317
318 let handwritten_rule_count = match builtin_mode {
323 BuiltinRuleMode::Enabled => {
324 for (path, source) in policies::HANDWRITTEN_REGO_POLICIES {
325 rego.add_policy(path.to_string(), source.to_string())?;
326 }
327 policies::HANDWRITTEN_REGO_POLICIES.len()
328 }
329 BuiltinRuleMode::ExternalOnly => 0,
330 };
331 debug!("Loaded {} data files, {} handwritten rules", REGORUS_DATA.len(), handwritten_rule_count);
332
333 let guard_rules = GuardRuleSet::compile(&config.guard_rules).map_err(|error| anyhow::anyhow!(error))?;
334
335 for entry in &config.custom_rules {
336 rego.add_policy(entry.name.clone(), entry.content.clone())?;
337 }
338 let mut custom_packages = Vec::new();
339 for entry in &config.custom_rules {
340 for line in entry.content.lines() {
341 let trimmed = line.trim();
342 if let Some(pkg) = trimmed.strip_prefix("package ") {
343 let eval_path = format!("data.{}.violation", pkg.trim());
344 if !custom_packages.contains(&eval_path) {
345 custom_packages.push(eval_path);
346 }
347 }
348 }
349 }
350
351 crate::builtins::register_all(&mut rego, overlay_catalog, schema_metadata)?;
352
353 let registry_metadata = match builtin_mode {
356 BuiltinRuleMode::Enabled => build_rule_metadata_map(),
357 BuiltinRuleMode::ExternalOnly => HashMap::new(),
358 };
359
360 if matches!(builtin_mode, BuiltinRuleMode::Enabled) {
364 rego.set_input(regorus::Value::new_object());
365 let _ = rego.eval_rule("data.all_violations.violation".to_string());
366 }
367
368 info!(
369 "RegoEngine initialized: {} handwritten rules, {} data files, {} registry + {} Guard metadata entries",
370 handwritten_rule_count,
371 REGORUS_DATA.len(),
372 registry_metadata.len(),
373 guard_rules.as_ref().map_or(0, |guard| guard.rule_metadata().len())
374 );
375 let init_metric = phase_metric(start);
376 Ok(RegoEngine {
377 base_rego: rego,
378 builtin_mode,
379 registry_metadata,
380 discovered_custom_metadata: Mutex::new(HashMap::new()),
381 custom_packages,
382 guard_rules,
383 init_metric,
384 })
385 }
386
387 fn eval_package_into(
394 &self,
395 rego: &mut regorus::Engine,
396 package: &str,
397 source_label: &str,
398 model: &SemanticModel,
399 origin: Option<&RuleOrigin>,
400 out: &mut Vec<Diagnostic>,
401 ) -> Result<(), ValidationError> {
402 let value = rego.eval_rule(package.to_string()).map_err(|e| {
403 ValidationError::Engine(format!("{source_label} rule package '{package}' failed to evaluate: {e}"))
404 })?;
405 let diagnostics_json = serde_json::to_value(&value).map_err(|e| {
406 ValidationError::Engine(format!(
407 "{source_label} rule package '{package}' produced a result that could not be \
408 serialized to JSON: {e}"
409 ))
410 })?;
411 extract_diagnostics_from_value(&diagnostics_json, model, out, origin).map_err(ValidationError::from)
412 }
413
414 fn globally_suppressed_builtin_rules(&self, config: &ValidateConfig) -> Arc<HashSet<String>> {
419 let suppressed: HashSet<String> = self
420 .registry_metadata
421 .iter()
422 .filter(|(rule_id, entry)| {
423 config.filters.globally_suppresses_builtin_rule(
424 rule_id,
425 entry.category.as_deref(),
426 entry.severity,
427 config.strict,
428 config.severity_level,
429 )
430 })
431 .map(|(rule_id, _)| rule_id.clone())
432 .collect();
433 Arc::new(suppressed)
434 }
435}
436
437impl ValidationEngine for RegoEngine {
438 fn engine_name(&self) -> &str {
439 "rego"
440 }
441
442 fn evaluate_rules(
443 &self,
444 model: &Arc<SemanticModel>,
445 config: &ValidateConfig,
446 ) -> Result<Vec<Diagnostic>, ValidationError> {
447 let context = crate::eval_context::EvaluationContext::new(
448 model.clone(),
449 config.pseudo_parameter_overrides.region.clone(),
450 self.globally_suppressed_builtin_rules(config),
451 );
452 let _scope = crate::eval_context::EvaluationScope::enter(context);
453
454 let mut rego = self.base_rego.clone();
455
456 let input_json = semantic_model_to_input_json(model)?;
457 let input_value = crate::builtins::serde_json_to_rego_value(&input_json);
458 rego.set_input(input_value);
459
460 let mut diagnostics = Vec::new();
461
462 let evaluate_builtins = matches!(self.builtin_mode, BuiltinRuleMode::Enabled) && !config.disable_builtin_rules;
466 if evaluate_builtins {
467 let excluded_cats = config.filters.excluded_categories();
468
469 let needed_core: Vec<&str> = CORE_PACKAGES
470 .iter()
471 .filter(|(cat, _)| !excluded_cats.contains(cat.as_str()))
472 .map(|(_, pkg)| *pkg)
473 .collect();
474
475 if needed_core.len() == CORE_PACKAGES.len() {
476 match rego.eval_rule("data.all_violations.violation".to_string()) {
477 Ok(val) => {
478 let diagnostics_json = serde_json::to_value(&val).map_err(|e| {
479 ValidationError::Engine(format!(
480 "Aggregated rule evaluation produced a result that could not be \
481 serialized to JSON: {e}"
482 ))
483 })?;
484 extract_diagnostics_from_value(&diagnostics_json, model, &mut diagnostics, None)
485 .map_err(ValidationError::from)?;
486 }
487 Err(e) => {
488 warn!("Aggregated eval failed ({}), falling back to individual packages", e);
489 for pkg in &needed_core {
490 self.eval_package_into(&mut rego, pkg, "Core", model, None, &mut diagnostics)?;
491 }
492 }
493 }
494 } else {
495 debug!("Skipping excluded categories: {:?}", excluded_cats);
496 for pkg in &needed_core {
497 self.eval_package_into(&mut rego, pkg, "Core", model, None, &mut diagnostics)?;
498 }
499 }
500 }
501
502 for pkg in &self.custom_packages {
503 self.eval_package_into(&mut rego, pkg, "Custom", model, Some(&RuleOrigin::Custom), &mut diagnostics)?;
504 }
505
506 if let Some(guard_rules) = &self.guard_rules {
507 diagnostics.extend(guard_rules.evaluate(model)?);
508 }
509
510 if !self.custom_packages.is_empty() {
511 let mut discovered = self.discovered_custom_metadata.lock().unwrap_or_else(|e| e.into_inner());
512 for d in &diagnostics {
513 if d.source == RuleOrigin::Custom {
514 discovered.entry(d.rule_id.clone()).or_insert_with(|| RuleMetadataEntry {
515 category: d.category.clone(),
516 description: d.message.clone(),
517 severity: d.severity,
518 origin: RuleOrigin::Custom,
519 });
520 }
521 }
522 }
523
524 Ok(diagnostics)
525 }
526
527 fn list_rules(&self) -> Vec<RuleInfo> {
528 build_rule_list(&self.registry_metadata, &self.external_rule_metadata())
529 }
530
531 fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
532 &self.registry_metadata
533 }
534
535 fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
540 let mut merged = self.guard_rules.as_ref().map(|guard| guard.rule_metadata().clone()).unwrap_or_default();
541 if !self.custom_packages.is_empty() {
542 let discovered = self.discovered_custom_metadata.lock().unwrap_or_else(|e| e.into_inner());
543 merged.extend(discovered.iter().map(|(k, v)| (k.clone(), v.clone())));
544 }
545 merged
546 }
547
548 fn init_metric(&self) -> &PhaseMetric {
549 &self.init_metric
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use diagnostics::DetailLevel;
557 use rules::{FilterConfig, RuleFilterConfig, Severity};
558 use std::sync::Barrier;
559 use std::thread;
560 use template_model::SemanticModel;
561 use validation_engine::{EngineConfig, ExternalRuleSource, ValidateConfig, ValidationEngine, validate_bytes};
562
563 fn make_engine() -> RegoEngine {
564 RegoEngine::new(EngineConfig::default()).unwrap()
565 }
566
567 fn make_model_from_yaml(yaml: &str) -> Arc<SemanticModel> {
568 let model = SemanticModel::from_bytes(yaml.as_bytes()).unwrap();
569 Arc::new(model)
570 }
571
572 #[test]
573 fn new_default_config_succeeds() {
574 let engine = make_engine();
575 assert_eq!(engine.engine_name(), "rego");
576 }
577
578 #[test]
579 fn list_rules_returns_nonempty() {
580 let engine = make_engine();
581 let rules = engine.list_rules();
582 assert!(!rules.is_empty(), "should have built-in rules");
583 }
584
585 #[test]
586 fn rule_metadata_contains_entries() {
587 let engine = make_engine();
588 assert!(!engine.rule_metadata().is_empty());
589 }
590
591 #[test]
592 fn init_metric_has_positive_duration() {
593 let engine = make_engine();
594 let metric = engine.init_metric();
595 assert!(metric.duration_ms > 0.0, "init duration should be > 0, got {}", metric.duration_ms);
596 }
597
598 #[test]
599 fn evaluate_minimal_template_no_errors() {
600 let engine = make_engine();
601 let model = make_model_from_yaml(
602 r#"
603AWSTemplateFormatVersion: "2010-09-09"
604Resources:
605 MyBucket:
606 Type: AWS::S3::Bucket
607"#,
608 );
609 let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
610 assert!(
611 diags.iter().all(|d| d.severity != Severity::Fatal),
612 "rego engine should not produce Fatal diagnostics"
613 );
614 }
615
616 #[test]
617 fn evaluate_empty_resources_does_not_panic() {
618 let engine = make_engine();
619 let model = make_model_from_yaml(
620 r#"
621AWSTemplateFormatVersion: "2010-09-09"
622Resources: {}
623"#,
624 );
625 let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
627 assert!(
628 diags.iter().any(|d| d.rule_id == "F0001"),
629 "expected F0001 Fatal for empty Resources section, got: {:?}",
630 diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>(),
631 );
632 }
633
634 #[test]
635 fn evaluate_with_excluded_categories() {
636 let engine = make_engine();
637 let model = make_model_from_yaml(
638 r#"
639AWSTemplateFormatVersion: "2010-09-09"
640Resources:
641 MyBucket:
642 Type: AWS::S3::Bucket
643"#,
644 );
645 let config = ValidateConfig {
646 filters: FilterConfig::new(
647 RuleFilterConfig::default(),
648 RuleFilterConfig { categories: vec!["best_practices".to_string()], ..Default::default() },
649 ),
650 ..Default::default()
651 };
652 let diags_filtered = engine.evaluate_rules(&model, &config).unwrap();
653
654 let diags_all = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
655
656 assert!(
657 diags_filtered.len() <= diags_all.len(),
658 "filtered count {} should be <= unfiltered count {}",
659 diags_filtered.len(),
660 diags_all.len()
661 );
662 }
663
664 #[test]
665 fn custom_rego_rule_produces_diagnostics() {
666 let custom_rego = r#"
667package custom_test
668import rego.v1
669
670violation contains v if {
671 some name, res in input.resources
672 res.resourceType == "AWS::S3::Bucket"
673 v := {"rule_id": "CUSTOM001", "severity": "error", "message": "Custom rule fired", "resource_id": name}
674}
675"#;
676 let config = EngineConfig {
677 custom_rules: vec![ExternalRuleSource { name: "custom_test.rego".into(), content: custom_rego.into() }],
678 guard_rules: vec![],
679 ..Default::default()
680 };
681 let engine = RegoEngine::new(config).unwrap();
682 let model = make_model_from_yaml(
683 r#"
684AWSTemplateFormatVersion: "2010-09-09"
685Resources:
686 MyBucket:
687 Type: AWS::S3::Bucket
688"#,
689 );
690 let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
691 let custom = diags.iter().find(|d| d.rule_id == "CUSTOM001");
692 assert!(custom.is_some(), "custom rule should fire");
693 }
694
695 #[test]
696 fn guard_rule_produces_diagnostics() {
697 let guard_source = r#"
698rule check_bucket_name {
699 AWS::S3::Bucket {
700 Properties.BucketName EXISTS
701 <<BucketName must be specified>>
702 }
703}
704"#;
705 let config = EngineConfig {
706 custom_rules: vec![],
707 guard_rules: vec![ExternalRuleSource { name: "test.guard".into(), content: guard_source.into() }],
708 ..Default::default()
709 };
710 let engine = RegoEngine::new(config).unwrap();
711 let model = make_model_from_yaml(
712 r#"
713AWSTemplateFormatVersion: "2010-09-09"
714Resources:
715 MyBucket:
716 Type: AWS::S3::Bucket
717"#,
718 );
719 let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
720 let guard_diag = diags.iter().find(|d| d.rule_id == "check_bucket_name").expect("guard rule fires");
721 assert_eq!(guard_diag.source, RuleOrigin::Guard);
722 assert_eq!(guard_diag.category.as_deref(), Some("guard:test"));
723 assert_eq!(guard_diag.message, "BucketName must be specified");
724 assert_eq!(guard_diag.resource_logical_id(), Some("MyBucket"));
725 }
726
727 #[test]
728 fn guard_rule_exists_check_does_not_fire_when_property_is_present() {
729 let guard_source = r#"
730rule check_bucket_name {
731 AWS::S3::Bucket {
732 Properties.BucketName EXISTS
733 <<BucketName must be specified>>
734 }
735}
736"#;
737 let config = EngineConfig {
738 custom_rules: vec![],
739 guard_rules: vec![ExternalRuleSource { name: "test.guard".into(), content: guard_source.into() }],
740 ..Default::default()
741 };
742 let engine = RegoEngine::new(config).unwrap();
743 let model = make_model_from_yaml(
744 r#"
745AWSTemplateFormatVersion: "2010-09-09"
746Resources:
747 MyBucket:
748 Type: AWS::S3::Bucket
749 Properties:
750 BucketName: my-bucket
751"#,
752 );
753 let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
754 let guard_diag = diags.iter().find(|d| d.rule_id == "check_bucket_name");
755 assert!(
756 guard_diag.is_none(),
757 "guard EXISTS must be satisfied when BucketName is present, so the rule must not fire; got: {:?}",
758 diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>()
759 );
760 }
761
762 #[test]
763 fn external_only_engine_evaluates_external_rules_but_skips_builtins() {
764 let custom_rego = r#"
765package external_only_test
766import rego.v1
767
768violation contains v if {
769 v := {"rule_id": "EXT_ONLY", "severity": "error", "message": "external rule fired", "resource_id": ""}
770}
771"#;
772 let config = EngineConfig {
773 custom_rules: vec![ExternalRuleSource {
774 name: "external_only_test.rego".into(),
775 content: custom_rego.into(),
776 }],
777 guard_rules: vec![],
778 ..Default::default()
779 };
780 let engine = RegoEngine::new_external_only(config).unwrap();
781 assert!(engine.rule_metadata().is_empty(), "an external-only engine must advertise no built-in registry rules");
782
783 let model = make_model_from_yaml(
787 r#"
788AWSTemplateFormatVersion: "2010-09-09"
789Resources: {}
790"#,
791 );
792 let normal_diags = RegoEngine::new(EngineConfig::default())
793 .unwrap()
794 .evaluate_rules(&model, &ValidateConfig::default())
795 .unwrap();
796 assert!(
797 normal_diags.iter().any(|d| d.rule_id == "F0001"),
798 "a normal engine must emit the built-in F0001 for empty Resources"
799 );
800
801 let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
802 let external = diags.iter().find(|d| d.rule_id == "EXT_ONLY").expect("the external rule must still evaluate");
803 assert_eq!(external.severity, Severity::Error);
804 assert_eq!(external.source, RuleOrigin::Custom);
805 assert!(
806 diags.iter().all(|d| d.rule_id == "EXT_ONLY"),
807 "external-only evaluation must produce no built-in diagnostics, got: {:?}",
808 diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>()
809 );
810 assert!(
811 !engine.list_rules().iter().any(|r| r.id == "F0001"),
812 "external-only list_rules must not include built-in rule IDs"
813 );
814 }
815
816 #[test]
817 fn external_only_engine_registers_custom_builtin_helpers() {
818 let custom_rego = r#"
819package external_builtin_test
820import rego.v1
821
822violation contains make_diag("EXT_BUILTINS", "error", name, "custom builtins available") if {
823 some name in resources_of_type("AWS::S3::Bucket")
824 has_property(name, "BucketName")
825 resolve(name, "Properties.BucketName") == "custom-builtins-bucket"
826 "BucketName" in schema_properties("AWS::S3::Bucket")
827}
828"#;
829 let engine = RegoEngine::new_external_only(EngineConfig {
830 custom_rules: vec![ExternalRuleSource {
831 name: "external_builtin_test.rego".into(),
832 content: custom_rego.into(),
833 }],
834 ..Default::default()
835 })
836 .expect("external-only engine must initialize with custom builtins");
837 let model = make_model_from_yaml(
838 r#"
839Resources:
840 Bucket:
841 Type: AWS::S3::Bucket
842 Properties:
843 BucketName: custom-builtins-bucket
844"#,
845 );
846
847 let diagnostics = engine.evaluate_rules(&model, &ValidateConfig::default()).expect("custom rule must evaluate");
848 let finding = diagnostics
849 .iter()
850 .find(|diagnostic| diagnostic.rule_id == "EXT_BUILTINS")
851 .expect("custom rule using resource, resolution, schema, and diagnostic helpers must fire");
852 assert_eq!(finding.source, RuleOrigin::Custom);
853 assert_eq!(finding.resource_logical_id(), Some("Bucket"));
854 }
855
856 #[test]
857 fn evaluate_rules_can_be_called_repeatedly() {
858 let engine = make_engine();
859 let model = make_model_from_yaml(
860 r#"
861AWSTemplateFormatVersion: "2010-09-09"
862Resources:
863 A:
864 Type: AWS::S3::Bucket
865"#,
866 );
867 let _ = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
870 let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
871 assert!(
872 diags.iter().all(|d| d.severity != Severity::Fatal),
873 "rego engine should not produce Fatal diagnostics"
874 );
875 }
876
877 #[test]
878 fn build_rule_metadata_map_returns_entries() {
879 let index = build_rule_metadata_map();
880 assert!(!index.is_empty());
881 }
882
883 const BUILTIN_TEST_TEMPLATE: &str = r#"
884AWSTemplateFormatVersion: "2010-09-09"
885Parameters:
886 Env:
887 Type: String
888 AllowedValues:
889 - prod
890 - dev
891 Port:
892 Type: Number
893Conditions:
894 IsProd:
895 Fn::Equals:
896 - !Ref Env
897 - prod
898Mappings:
899 RegionMap:
900 us-east-1:
901 AMI: ami-12345678
902 us-west-2:
903 AMI: ami-abcdefgh
904Resources:
905 MyBucket:
906 Type: AWS::S3::Bucket
907 Properties:
908 BucketName: my-bucket
909 Tags:
910 - Key: Env
911 Value: !If [IsProd, "production", "development"]
912 MyFunc:
913 Type: AWS::Lambda::Function
914 DependsOn: MyBucket
915 Condition: IsProd
916 Properties:
917 FunctionName: my-func
918 Runtime: python3.12
919 Handler: index.handler
920 Code:
921 S3Bucket: !Ref MyBucket
922 S3Key: code.zip
923 MyQueue:
924 Type: AWS::SQS::Queue
925 Properties:
926 QueueName: my-queue
927Transform: AWS::Serverless-2016-10-31
928"#;
929
930 fn eval_builtin_policy(rego_source: &str, rule_id: &str) -> Vec<Diagnostic> {
931 let config = EngineConfig {
932 custom_rules: vec![ExternalRuleSource { name: "builtin_test.rego".into(), content: rego_source.into() }],
933 guard_rules: vec![],
934 ..Default::default()
935 };
936 let engine = RegoEngine::new(config).unwrap();
937 let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
938 let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
939 diags.into_iter().filter(|d| d.rule_id == rule_id).collect()
940 }
941
942 #[test]
943 fn builtin_resolve_returns_property_value() {
944 let diags = eval_builtin_policy(
945 r#"
946package builtin_test
947import rego.v1
948violation contains v if {
949 val := resolve("MyBucket", "BucketName")
950 val == "my-bucket"
951 v := {"rule_id": "B_RESOLVE", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
952}
953"#,
954 "B_RESOLVE",
955 );
956 assert_eq!(diags.len(), 1, "resolve should return 'my-bucket'");
957 }
958
959 #[test]
960 fn builtin_resolve_all_returns_conditional_values() {
961 let diags = eval_builtin_policy(
962 r#"
963package builtin_test
964import rego.v1
965violation contains v if {
966 vals := resolve_all("MyBucket", "Tags.0.Value")
967 count(vals) >= 1
968 v := {"rule_id": "B_RESOLVE_ALL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
969}
970"#,
971 "B_RESOLVE_ALL",
972 );
973 assert_eq!(diags.len(), 1, "resolve_all should return at least one value");
974 }
975
976 #[test]
977 fn builtin_is_dynamic_on_conditional_property() {
978 let diags = eval_builtin_policy(
979 r#"
980package builtin_test
981import rego.v1
982violation contains v if {
983 is_dynamic("MyFunc", "Code.S3Bucket")
984 v := {"rule_id": "B_IS_DYN", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
985}
986"#,
987 "B_IS_DYN",
988 );
989 assert_eq!(diags.len(), 1, "Code.S3Bucket is a Ref → dynamic");
990 }
991
992 #[test]
993 fn builtin_is_dynamic_false_for_static() {
994 let diags = eval_builtin_policy(
995 r#"
996package builtin_test
997import rego.v1
998violation contains v if {
999 not is_dynamic("MyBucket", "BucketName")
1000 v := {"rule_id": "B_NOT_DYN", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1001}
1002"#,
1003 "B_NOT_DYN",
1004 );
1005 assert_eq!(diags.len(), 1, "BucketName is static");
1006 }
1007
1008 #[test]
1009 fn builtin_resources_of_type() {
1010 let diags = eval_builtin_policy(
1011 r#"
1012package builtin_test
1013import rego.v1
1014violation contains v if {
1015 ids := resources_of_type("AWS::S3::Bucket")
1016 count(ids) == 1
1017 v := {"rule_id": "B_ROT", "severity": "error", "message": "ok", "resource_id": ""}
1018}
1019"#,
1020 "B_ROT",
1021 );
1022 assert_eq!(diags.len(), 1, "should find 1 S3 bucket");
1023 }
1024
1025 #[test]
1026 fn builtin_has_property_true() {
1027 let diags = eval_builtin_policy(
1028 r#"
1029package builtin_test
1030import rego.v1
1031violation contains v if {
1032 has_property("MyBucket", "BucketName")
1033 v := {"rule_id": "B_HAS_P", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1034}
1035"#,
1036 "B_HAS_P",
1037 );
1038 assert_eq!(diags.len(), 1, "MyBucket has BucketName");
1039 }
1040
1041 #[test]
1042 fn builtin_has_property_false() {
1043 let diags = eval_builtin_policy(
1044 r#"
1045package builtin_test
1046import rego.v1
1047violation contains v if {
1048 not has_property("MyBucket", "NonExistent")
1049 v := {"rule_id": "B_NO_P", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1050}
1051"#,
1052 "B_NO_P",
1053 );
1054 assert_eq!(diags.len(), 1, "MyBucket does not have NonExistent");
1055 }
1056
1057 #[test]
1058 fn builtin_has_transform() {
1059 let diags = eval_builtin_policy(
1060 r#"
1061package builtin_test
1062import rego.v1
1063violation contains v if {
1064 has_transform("AWS::Serverless-2016-10-31")
1065 v := {"rule_id": "B_HAS_T", "severity": "error", "message": "ok", "resource_id": ""}
1066}
1067"#,
1068 "B_HAS_T",
1069 );
1070 assert_eq!(diags.len(), 1, "template has Serverless transform");
1071 }
1072
1073 #[test]
1074 fn builtin_param_allowed_values() {
1075 let diags = eval_builtin_policy(
1076 r#"
1077package builtin_test
1078import rego.v1
1079violation contains v if {
1080 vals := param_allowed_values("Env")
1081 count(vals) == 2
1082 v := {"rule_id": "B_PAV", "severity": "error", "message": "ok", "resource_id": ""}
1083}
1084"#,
1085 "B_PAV",
1086 );
1087 assert_eq!(diags.len(), 1, "Env has 2 allowed values");
1088 }
1089
1090 #[test]
1091 fn builtin_param_type() {
1092 let diags = eval_builtin_policy(
1093 r#"
1094package builtin_test
1095import rego.v1
1096violation contains v if {
1097 t := param_type("Port")
1098 t == "Number"
1099 v := {"rule_id": "B_PT", "severity": "error", "message": "ok", "resource_id": ""}
1100}
1101"#,
1102 "B_PT",
1103 );
1104 assert_eq!(diags.len(), 1, "Port param type is Number");
1105 }
1106
1107 #[test]
1108 fn builtin_mapping_value() {
1109 let diags = eval_builtin_policy(
1110 r#"
1111package builtin_test
1112import rego.v1
1113violation contains v if {
1114 val := mapping_value("RegionMap", "us-east-1", "AMI")
1115 val == "ami-12345678"
1116 v := {"rule_id": "B_MAP", "severity": "error", "message": "ok", "resource_id": ""}
1117}
1118"#,
1119 "B_MAP",
1120 );
1121 assert_eq!(diags.len(), 1, "mapping lookup should return ami-12345678");
1122 }
1123
1124 #[test]
1125 fn builtin_resource_condition() {
1126 let diags = eval_builtin_policy(
1127 r#"
1128package builtin_test
1129import rego.v1
1130violation contains v if {
1131 c := resource_condition("MyFunc")
1132 c == "IsProd"
1133 v := {"rule_id": "B_RC", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1134}
1135"#,
1136 "B_RC",
1137 );
1138 assert_eq!(diags.len(), 1, "MyFunc has condition IsProd");
1139 }
1140
1141 #[test]
1142 fn builtin_resource_condition_null_when_none() {
1143 let diags = eval_builtin_policy(
1144 r#"
1145package builtin_test
1146import rego.v1
1147violation contains v if {
1148 c := resource_condition("MyBucket")
1149 is_null(c)
1150 v := {"rule_id": "B_RC_NULL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1151}
1152"#,
1153 "B_RC_NULL",
1154 );
1155 assert_eq!(diags.len(), 1, "MyBucket has no condition");
1156 }
1157
1158 #[test]
1159 fn builtin_get_resource() {
1160 let diags = eval_builtin_policy(
1161 r#"
1162package builtin_test
1163import rego.v1
1164violation contains v if {
1165 res := get_resource("MyBucket")
1166 res.resourceType == "AWS::S3::Bucket"
1167 v := {"rule_id": "B_GR", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1168}
1169"#,
1170 "B_GR",
1171 );
1172 assert_eq!(diags.len(), 1, "get_resource should return bucket data");
1173 }
1174
1175 #[test]
1176 fn builtin_depends_on() {
1177 let diags = eval_builtin_policy(
1178 r#"
1179package builtin_test
1180import rego.v1
1181violation contains v if {
1182 depends_on("MyFunc", "MyBucket")
1183 v := {"rule_id": "B_DEP", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1184}
1185"#,
1186 "B_DEP",
1187 );
1188 assert_eq!(diags.len(), 1, "MyFunc depends on MyBucket");
1189 }
1190
1191 #[test]
1192 fn builtin_ref_targets() {
1193 let diags = eval_builtin_policy(
1194 r#"
1195package builtin_test
1196import rego.v1
1197violation contains v if {
1198 targets := ref_targets("MyFunc")
1199 count(targets) > 0
1200 v := {"rule_id": "B_RT", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1201}
1202"#,
1203 "B_RT",
1204 );
1205 assert_eq!(diags.len(), 1, "MyFunc should have ref targets (MyBucket)");
1206 }
1207
1208 #[test]
1209 fn builtin_ref_sources() {
1210 let diags = eval_builtin_policy(
1211 r#"
1212package builtin_test
1213import rego.v1
1214violation contains v if {
1215 sources := ref_sources("MyBucket")
1216 count(sources) > 0
1217 v := {"rule_id": "B_RS", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1218}
1219"#,
1220 "B_RS",
1221 );
1222 assert_eq!(diags.len(), 1, "MyBucket should have ref sources (MyFunc)");
1223 }
1224
1225 #[test]
1226 fn builtin_edges_from() {
1227 let diags = eval_builtin_policy(
1228 r#"
1229package builtin_test
1230import rego.v1
1231violation contains v if {
1232 edges := edges_from("MyFunc")
1233 count(edges) > 0
1234 v := {"rule_id": "B_EF", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1235}
1236"#,
1237 "B_EF",
1238 );
1239 assert_eq!(diags.len(), 1, "MyFunc should have outgoing edges");
1240 }
1241
1242 #[test]
1243 fn builtin_edges_to() {
1244 let diags = eval_builtin_policy(
1245 r#"
1246package builtin_test
1247import rego.v1
1248violation contains v if {
1249 edges := edges_to("MyBucket")
1250 count(edges) > 0
1251 v := {"rule_id": "B_ET", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1252}
1253"#,
1254 "B_ET",
1255 );
1256 assert_eq!(diags.len(), 1, "MyBucket should have incoming edges");
1257 }
1258
1259 #[test]
1260 fn builtin_make_diag_at() {
1261 let diags = eval_builtin_policy(
1262 r#"
1263package builtin_test
1264import rego.v1
1265violation contains v if {
1266 v := make_diag_at("B_MDA", "error", "MyBucket", "BucketName", "test diag at")
1267}
1268"#,
1269 "B_MDA",
1270 );
1271 assert_eq!(diags.len(), 1);
1272 assert_eq!(diags[0].message, "test diag at");
1273 }
1274
1275 #[test]
1276 fn builtin_make_diag_full() {
1277 let diags = eval_builtin_policy(
1278 r#"
1279package builtin_test
1280import rego.v1
1281violation contains v if {
1282 v := make_diag_full("B_MDF", "error", "MyBucket", "BucketName", "full diag", "fix it", "https://example.com")
1283}
1284"#,
1285 "B_MDF",
1286 );
1287 assert_eq!(diags.len(), 1);
1288 assert_eq!(diags[0].message, "full diag");
1289 }
1290
1291 #[test]
1292 fn builtin_schema_properties_known_type() {
1293 let diags = eval_builtin_policy(
1294 r#"
1295package builtin_test
1296import rego.v1
1297violation contains v if {
1298 props := schema_properties("AWS::S3::Bucket")
1299 # Just verify the builtin is callable and returns an array
1300 is_array(props)
1301 v := {"rule_id": "B_SP", "severity": "error", "message": "ok", "resource_id": ""}
1302}
1303"#,
1304 "B_SP",
1305 );
1306 assert_eq!(diags.len(), 1, "schema_properties should be callable and return array");
1307 }
1308
1309 #[test]
1310 fn builtin_schema_required_known_type() {
1311 let diags = eval_builtin_policy(
1312 r#"
1313package builtin_test
1314import rego.v1
1315violation contains v if {
1316 req := schema_required("AWS::Lambda::Function")
1317 is_array(req)
1318 v := {"rule_id": "B_SR", "severity": "error", "message": "ok", "resource_id": ""}
1319}
1320"#,
1321 "B_SR",
1322 );
1323 assert_eq!(diags.len(), 1, "schema_required should be callable and return array");
1324 }
1325
1326 #[test]
1327 fn builtin_conditions_compatible() {
1328 let diags = eval_builtin_policy(
1329 r#"
1330package builtin_test
1331import rego.v1
1332violation contains v if {
1333 conditions_compatible("MyBucket", "MyQueue")
1334 v := {"rule_id": "B_CC", "severity": "error", "message": "ok", "resource_id": ""}
1335}
1336"#,
1337 "B_CC",
1338 );
1339 assert_eq!(diags.len(), 1, "unconditional resources are always compatible");
1340 }
1341
1342 #[test]
1343 fn builtin_resolve_type() {
1344 let diags = eval_builtin_policy(
1345 r#"
1346package builtin_test
1347import rego.v1
1348violation contains v if {
1349 t := resolve_type("MyBucket", "BucketName")
1350 t == "string"
1351 v := {"rule_id": "B_RTYPE", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1352}
1353"#,
1354 "B_RTYPE",
1355 );
1356 assert_eq!(diags.len(), 1, "BucketName should resolve to string type");
1357 }
1358
1359 #[test]
1360 fn region_override_available_to_builtins() {
1361 let custom_rego = r#"
1362package region_test
1363import rego.v1
1364violation contains v if {
1365 r := input_region()
1366 r == "eu-west-1"
1367 v := {"rule_id": "B_REGION", "severity": "error", "message": "ok", "resource_id": ""}
1368}
1369"#;
1370 let config = EngineConfig {
1371 custom_rules: vec![ExternalRuleSource { name: "region_test.rego".into(), content: custom_rego.into() }],
1372 guard_rules: vec![],
1373 ..Default::default()
1374 };
1375 let engine = RegoEngine::new(config).unwrap();
1376 let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1377 let mut vc = ValidateConfig::default();
1378 vc.pseudo_parameter_overrides.region = Some("eu-west-1".to_string());
1379 let diags = engine.evaluate_rules(&model, &vc).unwrap();
1380 let found = diags.iter().find(|d| d.rule_id == "B_REGION");
1381 assert!(found.is_some(), "input_region() should return eu-west-1");
1382 }
1383
1384 #[test]
1385 fn multiple_custom_packages_all_evaluated() {
1386 let pkg_a = r#"
1387package custom_a
1388import rego.v1
1389violation contains v if {
1390 v := {"rule_id": "PKG_A", "severity": "error", "message": "from A", "resource_id": ""}
1391}
1392"#;
1393 let pkg_b = r#"
1394package custom_b
1395import rego.v1
1396violation contains v if {
1397 v := {"rule_id": "PKG_B", "severity": "error", "message": "from B", "resource_id": ""}
1398}
1399"#;
1400 let config = EngineConfig {
1401 custom_rules: vec![
1402 ExternalRuleSource { name: "a.rego".into(), content: pkg_a.into() },
1403 ExternalRuleSource { name: "b.rego".into(), content: pkg_b.into() },
1404 ],
1405 guard_rules: vec![],
1406 ..Default::default()
1407 };
1408 let engine = RegoEngine::new(config).unwrap();
1409 let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1410 let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1411 assert!(diags.iter().any(|d| d.rule_id == "PKG_A"), "package A should fire");
1412 assert!(diags.iter().any(|d| d.rule_id == "PKG_B"), "package B should fire");
1413 }
1414
1415 #[test]
1416 fn duplicate_custom_package_deduplicated() {
1417 let source = r#"
1418package custom_dedup
1419import rego.v1
1420violation contains v if {
1421 v := {"rule_id": "DEDUP1", "severity": "error", "message": "first", "resource_id": ""}
1422}
1423"#;
1424 let config = EngineConfig {
1425 custom_rules: vec![
1426 ExternalRuleSource { name: "a.rego".into(), content: source.into() },
1427 ExternalRuleSource { name: "b.rego".into(), content: source.into() },
1428 ],
1429 guard_rules: vec![],
1430 ..Default::default()
1431 };
1432 let engine = RegoEngine::new(config).unwrap();
1433 assert_eq!(
1434 engine.custom_packages.iter().filter(|p| p.contains("custom_dedup")).count(),
1435 1,
1436 "duplicate package should be deduplicated"
1437 );
1438 }
1439
1440 #[test]
1441 fn builtin_follow_ref_returns_target_resource() {
1442 let diags = eval_builtin_policy(
1443 r#"
1444package builtin_test
1445import rego.v1
1446violation contains v if {
1447 target := follow_ref("MyFunc", "Code.S3Bucket")
1448 target == "MyBucket"
1449 v := {"rule_id": "B_FREF", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1450}
1451"#,
1452 "B_FREF",
1453 );
1454 assert_eq!(diags.len(), 1, "follow_ref should resolve Ref to MyBucket");
1455 }
1456
1457 #[test]
1458 fn builtin_is_from_parameter_true_for_ref_param() {
1459 let diags = eval_builtin_policy(
1460 r#"
1461package builtin_test
1462import rego.v1
1463violation contains v if {
1464 is_from_parameter("MyBucket", "Tags.0.Value")
1465 v := {"rule_id": "B_IFP", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1466}
1467"#,
1468 "B_IFP",
1469 );
1470 assert!(diags.len() <= 1, "expected at most 1 diagnostic, got {}", diags.len());
1472 }
1473
1474 #[test]
1475 fn builtin_flatten_list_returns_items() {
1476 let diags = eval_builtin_policy(
1477 r#"
1478package builtin_test
1479import rego.v1
1480violation contains v if {
1481 items := flatten_list("MyBucket", "Tags")
1482 count(items) >= 1
1483 v := {"rule_id": "B_FL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1484}
1485"#,
1486 "B_FL",
1487 );
1488 assert_eq!(diags.len(), 1, "flatten_list should return tag items");
1489 }
1490
1491 #[test]
1492 fn builtin_resolve_scenarios_returns_values() {
1493 let diags = eval_builtin_policy(
1494 r#"
1495package builtin_test
1496import rego.v1
1497violation contains v if {
1498 scenarios := resolve_scenarios("MyBucket", "Tags.0.Value")
1499 count(scenarios) >= 1
1500 v := {"rule_id": "B_RS2", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1501}
1502"#,
1503 "B_RS2",
1504 );
1505 assert_eq!(diags.len(), 1, "resolve_scenarios should return at least one scenario");
1506 }
1507
1508 #[test]
1509 fn builtin_is_satisfiable_with_compatible_assumptions() {
1510 let diags = eval_builtin_policy(
1511 r#"
1512package builtin_test
1513import rego.v1
1514violation contains v if {
1515 is_satisfiable({"IsProd": true})
1516 v := {"rule_id": "B_SAT", "severity": "error", "message": "ok", "resource_id": ""}
1517}
1518"#,
1519 "B_SAT",
1520 );
1521 assert_eq!(diags.len(), 1, "IsProd=true should be satisfiable");
1522 }
1523
1524 #[test]
1525 fn builtin_make_diag_produces_diagnostic() {
1526 let diags = eval_builtin_policy(
1527 r#"
1528package builtin_test
1529import rego.v1
1530violation contains v if {
1531 v := make_diag("B_MD", "error", "MyBucket", "test message")
1532}
1533"#,
1534 "B_MD",
1535 );
1536 assert_eq!(diags.len(), 1);
1537 assert_eq!(diags[0].message, "test message");
1538 }
1539
1540 #[test]
1541 fn builtin_estimated_string_length_bounds_returns_both_bounds() {
1542 let diags = eval_builtin_policy(
1545 r#"
1546package builtin_test
1547import rego.v1
1548violation contains v if {
1549 bounds := estimated_string_length_bounds("MyBucket", "Tags.0.Value")
1550 bounds.shortest == 10
1551 bounds.longest == 11
1552 v := {"rule_id": "B_ESL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1553}
1554"#,
1555 "B_ESL",
1556 );
1557 assert_eq!(diags.len(), 1, "a value chosen between two known strings must report both bounds");
1558 }
1559
1560 #[test]
1561 fn builtin_estimated_string_length_bounds_is_undefined_for_a_literal() {
1562 let diags = eval_builtin_policy(
1565 r#"
1566package builtin_test
1567import rego.v1
1568violation contains v if {
1569 bounds := estimated_string_length_bounds("MyBucket", "BucketName")
1570 v := {"rule_id": "B_ESL_LITERAL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1571}
1572"#,
1573 "B_ESL_LITERAL",
1574 );
1575 assert!(diags.is_empty(), "a literal string must yield no bounds, got {diags:?}");
1576 }
1577
1578 #[test]
1579 fn regoengine_is_send_and_sync() {
1580 fn assert_send_sync<T: Send + Sync>() {}
1581 assert_send_sync::<RegoEngine>();
1582 }
1583
1584 #[test]
1585 fn evaluate_rules_isolates_model_and_region_across_threads() {
1586 let probe = r#"
1590package concurrency_probe
1591import rego.v1
1592violation contains v if {
1593 r := input_region()
1594 v := {"rule_id": "REGIONPROBE", "severity": "info", "message": r, "resource_id": ""}
1595}
1596violation contains v if {
1597 some name in resources_of_type("AWS::S3::Bucket")
1598 v := {"rule_id": "MODELPROBE", "severity": "info", "message": name, "resource_id": name}
1599}
1600violation contains v if {
1601 some name, _ in input.resources
1602 v := {"rule_id": "INPUTPROBE", "severity": "info", "message": name, "resource_id": name}
1603}
1604"#;
1605 let config = EngineConfig {
1606 custom_rules: vec![ExternalRuleSource { name: "concurrency_probe.rego".into(), content: probe.into() }],
1607 guard_rules: vec![],
1608 ..Default::default()
1609 };
1610 let engine = Arc::new(RegoEngine::new(config).unwrap());
1611
1612 const THREAD_COUNT: usize = 8;
1613 const ITERATIONS_PER_THREAD: usize = 128;
1614 let barrier = Arc::new(Barrier::new(THREAD_COUNT));
1615 let handles: Vec<_> = (0..THREAD_COUNT)
1616 .map(|i| {
1617 let engine = Arc::clone(&engine);
1618 let barrier = Arc::clone(&barrier);
1619 thread::spawn(move || {
1620 let region = format!("region-{i}");
1621 let bucket = format!("Bucket{i}");
1622 let model = make_model_from_yaml(&format!(
1623 "AWSTemplateFormatVersion: \"2010-09-09\"\nResources:\n {bucket}:\n Type: AWS::S3::Bucket\n"
1624 ));
1625 let mut config = ValidateConfig::default();
1626 config.pseudo_parameter_overrides.region = Some(region.clone());
1627 barrier.wait();
1629 let mut diagnostics = Vec::new();
1630 for _ in 0..ITERATIONS_PER_THREAD {
1631 diagnostics = engine.evaluate_rules(&model, &config).unwrap();
1632 let observed_regions: Vec<&str> = diagnostics
1633 .iter()
1634 .filter(|diagnostic| diagnostic.rule_id == "REGIONPROBE")
1635 .map(|diagnostic| diagnostic.message.as_str())
1636 .collect();
1637 assert_eq!(observed_regions, vec![region.as_str()]);
1638 let observed_resources: Vec<&str> = diagnostics
1639 .iter()
1640 .filter(|diagnostic| diagnostic.rule_id == "MODELPROBE")
1641 .map(|diagnostic| diagnostic.message.as_str())
1642 .collect();
1643 assert_eq!(observed_resources, vec![bucket.as_str()]);
1644 let observed_input_resources: Vec<&str> = diagnostics
1645 .iter()
1646 .filter(|diagnostic| diagnostic.rule_id == "INPUTPROBE")
1647 .map(|diagnostic| diagnostic.message.as_str())
1648 .collect();
1649 assert_eq!(observed_input_resources, vec![bucket.as_str()]);
1650 }
1651 (region, bucket, diagnostics)
1652 })
1653 })
1654 .collect();
1655
1656 for handle in handles {
1657 let (region, bucket, diags) = handle.join().unwrap();
1658 let regions: Vec<&str> =
1659 diags.iter().filter(|d| d.rule_id == "REGIONPROBE").map(|d| d.message.as_str()).collect();
1660 assert_eq!(regions, vec![region.as_str()], "a thread must observe only its own region");
1661 let resources: Vec<&str> =
1662 diags.iter().filter(|d| d.rule_id == "MODELPROBE").map(|d| d.message.as_str()).collect();
1663 assert_eq!(resources, vec![bucket.as_str()], "a thread must observe only its own model");
1664 let input_resources: Vec<&str> =
1665 diags.iter().filter(|d| d.rule_id == "INPUTPROBE").map(|d| d.message.as_str()).collect();
1666 assert_eq!(input_resources, vec![bucket.as_str()], "a thread must observe only its own Rego input");
1667 }
1668 }
1669
1670 #[test]
1671 fn shared_engine_keeps_thousand_sequential_reports_template_local() {
1672 const CUSTOM_RULE: &str = r#"
1673package sequential_isolation
1674import rego.v1
1675
1676violation contains v if {
1677 pattern := input.template.description
1678 some name, resource in input.resources
1679 regex.match(pattern, resource.properties.Value)
1680 region := input_region()
1681 message := sprintf("%s/%s", [name, region])
1682 v := {
1683 "rule_id": "SEQUENTIALPROBE",
1684 "severity": "info",
1685 "category": "isolation",
1686 "message": message,
1687 "resource_id": name,
1688 }
1689}
1690"#;
1691 let engine = RegoEngine::new(EngineConfig {
1692 custom_rules: vec![ExternalRuleSource {
1693 name: "sequential_isolation.rego".into(),
1694 content: CUSTOM_RULE.into(),
1695 }],
1696 ..Default::default()
1697 })
1698 .unwrap();
1699 let schema_validator = SchemaValidator::default();
1700
1701 for iteration in 0..1_000 {
1702 let (resource_name, region, pattern, value) = if iteration % 2 == 0 {
1703 ("AlphaResource", "alpha-region", "^alpha+$", "alpha")
1704 } else {
1705 ("BetaResource", "beta-region", "^beta+$", "beta")
1706 };
1707 let template = format!(
1708 r#"{{
1709 "AWSTemplateFormatVersion": "2010-09-09",
1710 "Description": "{pattern}",
1711 "Resources": {{
1712 "{resource_name}": {{
1713 "Type": "Custom::Probe",
1714 "Properties": {{"Value": "{value}"}}
1715 }}
1716 }}
1717 }}"#
1718 );
1719 let mut config = ValidateConfig {
1720 detail_level: DetailLevel::Detailed,
1721 severity_level: Severity::Debug,
1722 disable_builtin_rules: true,
1723 ..Default::default()
1724 };
1725 config.pseudo_parameter_overrides.region = Some(region.to_string());
1726
1727 let report = validate_bytes(&engine, &schema_validator, template.as_bytes(), config).unwrap();
1728 let probes: Vec<&Diagnostic> =
1729 report.diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "SEQUENTIALPROBE").collect();
1730 assert_eq!(probes.len(), 1, "iteration {iteration} must emit exactly one probe diagnostic");
1731 let expected_message = format!("{resource_name}/{region}");
1732 assert_eq!(probes[0].message, expected_message, "the current template supplies the diagnostic message");
1733 assert_eq!(
1734 probes[0].rule_description.as_deref(),
1735 Some(expected_message.as_str()),
1736 "the current template supplies its own detailed rule description"
1737 );
1738 assert_eq!(probes[0].category.as_deref(), Some("isolation"));
1739 }
1740 }
1741
1742 #[test]
1743 fn failed_evaluation_does_not_contaminate_the_next_call() {
1744 const CUSTOM_RULE: &str = r#"
1745package error_cleanup
1746import rego.v1
1747
1748violation contains v if {
1749 input.template.description == "error"
1750 ip_overlaps("not-a-cidr", "10.0.0.0/8")
1751 v := {"rule_id": "UNREACHABLE", "severity": "error", "message": "unreachable"}
1752}
1753
1754violation contains v if {
1755 input.template.description == "ok"
1756 some name in resources_of_type("Custom::Probe")
1757 region := input_region()
1758 v := {
1759 "rule_id": "RECOVERYPROBE",
1760 "severity": "info",
1761 "message": sprintf("%s/%s", [name, region]),
1762 "resource_id": name,
1763 }
1764}
1765"#;
1766 let engine = RegoEngine::new(EngineConfig {
1767 custom_rules: vec![ExternalRuleSource { name: "error_cleanup.rego".into(), content: CUSTOM_RULE.into() }],
1768 ..Default::default()
1769 })
1770 .unwrap();
1771 let error_model =
1772 make_model_from_yaml("Description: error\nResources:\n ErrorResource:\n Type: Custom::Probe\n");
1773 let valid_model =
1774 make_model_from_yaml("Description: ok\nResources:\n ValidResource:\n Type: Custom::Probe\n");
1775 let mut error_config = ValidateConfig { disable_builtin_rules: true, ..Default::default() };
1776 error_config.pseudo_parameter_overrides.region = Some("error-region".to_string());
1777 assert!(engine.evaluate_rules(&error_model, &error_config).is_err(), "the first evaluation must fail");
1778
1779 let mut valid_config = ValidateConfig { disable_builtin_rules: true, ..Default::default() };
1780 valid_config.pseudo_parameter_overrides.region = Some("valid-region".to_string());
1781 let diagnostics = engine.evaluate_rules(&valid_model, &valid_config).unwrap();
1782 let recovery: Vec<&Diagnostic> =
1783 diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "RECOVERYPROBE").collect();
1784 assert_eq!(recovery.len(), 1, "the next evaluation must recover after the prior error");
1785 assert_eq!(recovery[0].message, "ValidResource/valid-region");
1786 }
1787
1788 const SUPPRESSION_TEMPLATE: &str = r#"
1789AWSTemplateFormatVersion: "2010-09-09"
1790Resources:
1791 MyInstance:
1792 Type: AWS::EC2::Instance
1793 Properties:
1794 ImageId: ami-12345678
1795 InstanceType: !Sub "t3.micro"
1796"#;
1797
1798 fn exclude_ids(ids: &[&str]) -> ValidateConfig {
1799 ValidateConfig {
1800 filters: FilterConfig::new(
1801 RuleFilterConfig::default(),
1802 RuleFilterConfig { ids: ids.iter().map(|s| s.to_string()).collect(), ..Default::default() },
1803 ),
1804 ..Default::default()
1805 }
1806 }
1807
1808 #[test]
1809 fn exclude_id_suppresses_matching_builtin_and_preserves_other_findings() {
1810 let engine = make_engine();
1811 let model = make_model_from_yaml(SUPPRESSION_TEMPLATE);
1812
1813 let unfiltered = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1814 assert!(unfiltered.iter().any(|d| d.rule_id == "W9010"), "hardcoded AMI should trigger W9010");
1815 let mut other_findings: Vec<(String, String)> = unfiltered
1816 .iter()
1817 .filter(|d| d.rule_id != "W9010")
1818 .map(|d| (d.rule_id.clone(), d.message.clone()))
1819 .collect();
1820 assert!(!other_findings.is_empty(), "template must also produce a non-W9010 finding worth preserving");
1821 other_findings.sort();
1822
1823 let filtered = engine.evaluate_rules(&model, &exclude_ids(&["W9010"])).unwrap();
1824 assert!(filtered.iter().all(|d| d.rule_id != "W9010"), "the excluded rule must not be emitted");
1825 let mut remaining: Vec<(String, String)> =
1826 filtered.iter().map(|d| (d.rule_id.clone(), d.message.clone())).collect();
1827 remaining.sort();
1828 assert_eq!(remaining, other_findings, "excluding one rule must not alter any other diagnostic");
1829
1830 let unfiltered_again = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1831 assert!(
1832 unfiltered_again.iter().any(|diagnostic| diagnostic.rule_id == "W9010"),
1833 "a later unfiltered call must not inherit the previous call's suppressed-rule set"
1834 );
1835 }
1836
1837 #[test]
1838 fn include_id_globally_suppresses_every_other_builtin() {
1839 let engine = make_engine();
1840 let model = make_model_from_yaml(SUPPRESSION_TEMPLATE);
1841
1842 let config = ValidateConfig {
1843 filters: FilterConfig::new(
1844 RuleFilterConfig { ids: vec!["W9010".to_string()], ..Default::default() },
1845 RuleFilterConfig::default(),
1846 ),
1847 ..Default::default()
1848 };
1849 let filtered = engine.evaluate_rules(&model, &config).unwrap();
1850 assert!(filtered.iter().any(|d| d.rule_id == "W9010"), "the included rule must still fire");
1851 assert!(
1852 filtered.iter().all(|d| d.rule_id == "W9010"),
1853 "a non-empty include filter must globally suppress every rule it can never admit"
1854 );
1855 }
1856
1857 #[test]
1858 fn severity_level_prunes_lower_severity_builtins() {
1859 let engine = make_engine();
1860 let model = make_model_from_yaml(SUPPRESSION_TEMPLATE);
1861
1862 let default_run = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1863 assert!(default_run.iter().any(|d| d.rule_id == "W9010"), "W9010 fires at the default Info level");
1864
1865 let config = ValidateConfig { severity_level: Severity::Error, ..Default::default() };
1866 let error_only = engine.evaluate_rules(&model, &config).unwrap();
1867 assert!(
1868 error_only.iter().all(|d| d.severity >= Severity::Error),
1869 "no sub-Error diagnostics should be produced when the level is Error"
1870 );
1871 assert!(
1872 error_only.iter().all(|d| d.rule_id != "W9010"),
1873 "the Warn rule W9010 must be pruned at the Error level"
1874 );
1875 }
1876
1877 #[test]
1878 fn cfn_rule_active_guard_short_circuits_a_suppressed_rule_body() {
1879 let probe = r#"
1884package guard_probe
1885import rego.v1
1886violation contains v if {
1887 cfn_rule_active("W9010")
1888 not regex.match("(unterminated", "anything")
1889 v := {"rule_id": "GUARDPROBE", "severity": "error", "message": "guarded body ran", "resource_id": ""}
1890}
1891"#;
1892 let config = EngineConfig {
1893 custom_rules: vec![ExternalRuleSource { name: "guard_probe.rego".into(), content: probe.into() }],
1894 guard_rules: vec![],
1895 ..Default::default()
1896 };
1897 let engine = RegoEngine::new(config).unwrap();
1898 let model = make_model_from_yaml(
1899 r#"
1900AWSTemplateFormatVersion: "2010-09-09"
1901Resources:
1902 MyBucket:
1903 Type: AWS::S3::Bucket
1904"#,
1905 );
1906
1907 let active = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1908 assert!(active.iter().any(|d| d.rule_id == "GUARDPROBE"), "an active rule's guarded body must run");
1909
1910 let suppressed = engine.evaluate_rules(&model, &exclude_ids(&["W9010"])).unwrap();
1911 assert!(
1912 suppressed.iter().all(|d| d.rule_id != "GUARDPROBE"),
1913 "a suppressed rule's guarded body - including its invalid regex - must not run"
1914 );
1915 }
1916}