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