1use log::info;
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use diagnostics::{Diagnostic, Entity, PhaseMetric, phase_metric};
6use guard_translator::{ensure_translatable, pack_name_from_path, parse_guard};
7use rules::{RuleInfo, RuleMetadataEntry, RuleOrigin, Severity, build_rule_metadata_map, is_valid_custom_rule_id};
8use schema_validator::{OverlayCatalog, SchemaValidator};
9use template_model::{SemanticModel, UNKNOWN_SPAN};
10use validation_engine::{
11 EngineConfig, ValidateConfig, ValidationEngine, ValidationError, build_rule_list, semantic_model_to_input_json,
12};
13
14use crate::rule_evaluator::GeneratedRuleRegistry;
15use crate::rules::{CachedData, EvalContext, NativeRuleRegistry};
16
17use serde::Deserialize;
18
19use cel_interpreter::{Context, Program, Value as CelValue};
20
21#[derive(Deserialize)]
22struct CustomRuleFile {
23 rules: Vec<CustomRuleDef>,
24}
25
26#[derive(Deserialize)]
27struct CustomRuleDef {
28 rule_id: String,
29 severity: Severity,
30 #[serde(default)]
31 category: Option<String>,
32 resource_type: Option<String>,
33 expression: String,
34 message: String,
35 #[serde(default)]
36 prop_path: Option<String>,
37 #[serde(default)]
38 suggested_fix: Option<String>,
39}
40
41pub struct CelEngine {
42 native_rules: NativeRuleRegistry,
43 generated_rules: GeneratedRuleRegistry,
44 custom_rules: Vec<CustomRule>,
45 registry_metadata: HashMap<String, RuleMetadataEntry>,
47 external_rule_metadata: HashMap<String, RuleMetadataEntry>,
49 cached_data: CachedData,
50 init_metric: PhaseMetric,
51}
52
53#[derive(Debug)]
54struct CustomRule {
55 rule_id: String,
56 severity: Severity,
57 category: Option<String>,
58 resource_type: Option<String>,
59 program: Program,
60 message: String,
61 prop_path: Option<String>,
62 suggested_fix: Option<String>,
63 source: RuleOrigin,
64}
65
66impl CelEngine {
67 pub fn new(config: EngineConfig) -> anyhow::Result<Self> {
68 let catalog =
69 config.build_overlay_catalog().map_err(|e| anyhow::anyhow!("Failed to build overlay catalog: {e}"))?;
70 Self::new_from_catalog(config, &catalog)
71 }
72
73 #[doc(hidden)]
80 pub fn new_with_schema_validator(config: EngineConfig, validator: &SchemaValidator) -> anyhow::Result<Self> {
81 Self::new_from_catalog(config, validator.overlay_catalog())
82 }
83
84 fn new_from_catalog(config: EngineConfig, overlay_catalog: &OverlayCatalog) -> anyhow::Result<Self> {
86 let start = web_time::Instant::now();
87
88 let native_rules = NativeRuleRegistry::new();
89 let generated_rules = GeneratedRuleRegistry::new()?;
90
91 let registry_metadata = build_rule_metadata_map();
92 let mut external_rule_metadata: HashMap<String, RuleMetadataEntry> = HashMap::new();
93
94 let mut translated_guard_sources = Vec::new();
95 for entry in &config.guard_rules {
96 let guard_file = parse_guard(&entry.content, &entry.name)
97 .map_err(|e| anyhow::anyhow!("Failed to parse guard file '{}': {}", entry.name, e))?;
98 ensure_translatable(&guard_file)
99 .map_err(|e| anyhow::anyhow!("Unsupported guard rule in '{}': {}", entry.name, e))?;
100 let pack = pack_name_from_path(&entry.name);
101 let translated = crate::guard_to_cel::translate_to_cel(&guard_file, &pack, &[]);
102 let json = crate::guard_to_cel::to_custom_rule_json(&translated)
103 .map_err(|e| anyhow::anyhow!("Failed to translate guard file '{}' to CEL: {}", entry.name, e))?;
104 translated_guard_sources.push((entry.name.clone(), json));
105 }
106
107 let mut custom_rules = Vec::new();
108 for entry in &config.custom_rules {
109 match load_custom_rules(&entry.content, RuleOrigin::Custom) {
110 Ok(rules) => {
111 info!("Loaded {} custom rules from {}", rules.len(), entry.name);
112 for r in &rules {
113 external_rule_metadata.entry(r.rule_id.clone()).or_insert_with(|| RuleMetadataEntry {
114 category: r.category.clone(),
115 description: r.message.clone(),
116 severity: r.severity,
117 origin: RuleOrigin::Custom,
118 });
119 }
120 custom_rules.extend(rules);
121 }
122 Err(e) => {
123 return Err(anyhow::anyhow!("Failed to load rules from {}: {}", entry.name, e));
124 }
125 }
126 }
127 for (path, source) in &translated_guard_sources {
128 match load_custom_rules(source, RuleOrigin::Guard) {
129 Ok(rules) => {
130 info!("Loaded {} guard rules from {}", rules.len(), path);
131 for r in &rules {
132 external_rule_metadata.entry(r.rule_id.clone()).or_insert_with(|| RuleMetadataEntry {
133 category: r.category.clone(),
134 description: r.message.clone(),
135 severity: r.severity,
136 origin: RuleOrigin::Guard,
137 });
138 }
139 custom_rules.extend(rules);
140 }
141 Err(e) => {
142 return Err(anyhow::anyhow!("Failed to load guard rules from {}: {}", path, e));
143 }
144 }
145 }
146
147 info!(
148 "CelEngine initialized: {} native rule fns, {} custom rules, {} registry + {} external metadata entries",
149 native_rules.rules.len(),
150 custom_rules.len(),
151 registry_metadata.len(),
152 external_rule_metadata.len()
153 );
154 let mut cached_data = CachedData::load()?;
155 if !overlay_catalog.is_empty() {
159 cached_data.merge_overlay_catalog(overlay_catalog)?;
160 }
161 let init_metric = phase_metric(start);
162 Ok(CelEngine {
163 native_rules,
164 generated_rules,
165 custom_rules,
166 registry_metadata,
167 external_rule_metadata,
168 cached_data,
169 init_metric,
170 })
171 }
172}
173
174impl ValidationEngine for CelEngine {
175 fn engine_name(&self) -> &str {
176 "cel"
177 }
178
179 fn evaluate_rules(
180 &self,
181 model: &Arc<SemanticModel>,
182 config: &ValidateConfig,
183 ) -> Result<Vec<Diagnostic>, ValidationError> {
184 let input_json = semantic_model_to_input_json(model)?;
185 let excluded_cats = config.filters.excluded_categories();
186 let region = config.pseudo_parameter_overrides.region.clone();
187
188 let mut diagnostics = if config.disable_builtin_rules {
189 Vec::new()
190 } else {
191 let ctx = EvalContext { model, input: &input_json, region: ®ion, cached_data: &self.cached_data };
192 let mut diags = self.native_rules.evaluate(&ctx, &excluded_cats);
193 let gen_diags = self.generated_rules.evaluate(model, &input_json, &excluded_cats);
194 diags.extend(gen_diags);
195 diags
196 };
197
198 for rule in &self.custom_rules {
199 if rule.category.as_deref().is_some_and(|c| excluded_cats.contains(c)) {
200 continue;
201 }
202 if let Some(ref rtype) = rule.resource_type {
203 for rid in model.resources_of_type(rtype) {
204 let cel_ctx = crate::functions::build_custom_context(&input_json, Some(rid), Some(model));
205 if execute_custom_rule(rule, &cel_ctx)? {
206 let msg = rule.message.replace("{name}", rid);
207 emit_custom_diagnostic(&mut diagnostics, rule, model, rid, &msg);
208 }
209 }
210 } else {
211 let cel_ctx = crate::functions::build_custom_context(&input_json, None, Some(model));
212 if execute_custom_rule(rule, &cel_ctx)? {
213 emit_custom_diagnostic(&mut diagnostics, rule, model, "", &rule.message);
214 }
215 }
216 }
217
218 Ok(diagnostics)
219 }
220
221 fn list_rules(&self) -> Vec<RuleInfo> {
222 build_rule_list(&self.registry_metadata, &self.external_rule_metadata)
223 }
224
225 fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
226 &self.registry_metadata
227 }
228
229 fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
230 self.external_rule_metadata.clone()
231 }
232
233 fn init_metric(&self) -> &PhaseMetric {
234 &self.init_metric
235 }
236}
237
238fn execute_custom_rule(rule: &CustomRule, cel_ctx: &Context<'static>) -> Result<bool, ValidationError> {
239 match rule.program.execute(cel_ctx) {
240 Ok(CelValue::Bool(fired)) => Ok(fired),
241 Ok(_) => Err(ValidationError::Engine(format!(
246 "Custom rule '{}' expression must evaluate to a boolean, but produced a non-boolean value",
247 rule.rule_id
248 ))),
249 Err(error) if matches!(rule.source, RuleOrigin::Guard) => {
254 log::error!("Guard rule '{}' failed to evaluate (tolerated): {error}", rule.rule_id);
255 Ok(false)
256 }
257 Err(error) => {
258 Err(ValidationError::Engine(format!("Custom rule '{}' failed to evaluate: {error}", rule.rule_id)))
259 }
260 }
261}
262
263fn emit_custom_diagnostic(
267 out: &mut Vec<Diagnostic>,
268 rule: &CustomRule,
269 model: &Arc<SemanticModel>,
270 rid: &str,
271 msg: &str,
272) {
273 let span =
274 if rid.is_empty() { UNKNOWN_SPAN } else { model.resource_span(rid, rule.prop_path.as_deref().unwrap_or("")) };
275 out.push(Diagnostic {
276 rule_id: rule.rule_id.clone(),
277 severity: rule.severity,
278 message: msg.to_string(),
279 entity: Entity::resource(rid, model.resources.get(rid).map(|r| r.resource_type.clone())),
280 property_path: rule.prop_path.clone(),
281 suggested_fix: rule.suggested_fix.clone(),
282 documentation_url: None,
283 category: rule.category.clone(),
284 location: if span == UNKNOWN_SPAN { None } else { Some(span) },
285 related_resources: None,
286 condition_scenario: None,
287 rule_description: None,
288 phase: None,
289 context: None,
290 source: rule.source,
291 });
292}
293
294fn load_custom_rules(source: &str, origin: RuleOrigin) -> anyhow::Result<Vec<CustomRule>> {
295 let file: CustomRuleFile = serde_json::from_str(source)?;
296 let mut rules = Vec::new();
297 for def in file.rules {
298 if def.rule_id.trim().is_empty() {
302 return Err(anyhow::anyhow!("Custom rule has an empty 'rule_id'"));
303 }
304 if !is_valid_custom_rule_id(&def.rule_id) {
309 return Err(anyhow::anyhow!(
310 "Custom rule '{}' has an invalid 'rule_id': only letters, digits, and the separators '_', '.', '-' \
311 are allowed",
312 def.rule_id
313 ));
314 }
315 if def.message.trim().is_empty() {
316 return Err(anyhow::anyhow!("Custom rule '{}' has an empty 'message'", def.rule_id));
317 }
318 if def.expression.trim().is_empty() {
319 return Err(anyhow::anyhow!("Custom rule '{}' has an empty 'expression'", def.rule_id));
320 }
321 let program = Program::compile(&def.expression)
322 .map_err(|e| anyhow::anyhow!("Failed to compile CEL expression for rule '{}': {}", def.rule_id, e))?;
323 for function in program.references().functions() {
328 if !crate::functions::is_supported_function(function) {
329 return Err(anyhow::anyhow!(
330 "Custom rule '{}' references unknown function '{}'",
331 def.rule_id,
332 function
333 ));
334 }
335 }
336 rules.push(CustomRule {
337 rule_id: def.rule_id,
338 severity: def.severity,
339 category: def.category,
340 resource_type: def.resource_type,
341 program,
342 message: def.message,
343 prop_path: def.prop_path,
344 suggested_fix: def.suggested_fix,
345 source: origin,
346 });
347 }
348 Ok(rules)
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn load_custom_rules_valid_json() {
357 let json = r#"{"rules": [
358 {
359 "rule_id": "CUSTOM001",
360 "severity": "ERROR",
361 "resource_type": "AWS::S3::Bucket",
362 "expression": "has(properties.BucketEncryption)",
363 "message": "Encryption required"
364 }
365 ]}"#;
366 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
367 assert_eq!(rules.len(), 1);
368 assert_eq!(rules[0].rule_id, "CUSTOM001");
369 assert!(matches!(rules[0].severity, Severity::Error));
370 assert_eq!(rules[0].resource_type, Some("AWS::S3::Bucket".into()));
371 assert_eq!(rules[0].category, None);
372 }
373
374 #[test]
375 fn load_custom_rules_severity_mapping() {
376 let make = |sev: &str| -> String {
377 format!(
378 r#"{{"rules": [{{"rule_id": "R1", "severity": "{}", "expression": "true", "message": "m"}}]}}"#,
379 sev
380 )
381 };
382
383 let error_rules = load_custom_rules(&make(Severity::Error.as_str()), RuleOrigin::Custom).unwrap();
384 assert_eq!(error_rules[0].severity, Severity::Error);
385
386 let warn_rules = load_custom_rules(&make(Severity::Warn.as_str()), RuleOrigin::Custom).unwrap();
387 assert_eq!(warn_rules[0].severity, Severity::Warn);
388
389 let info_rules = load_custom_rules(&make(Severity::Info.as_str()), RuleOrigin::Custom).unwrap();
390 assert_eq!(info_rules[0].severity, Severity::Info);
391 }
392
393 #[test]
394 fn load_custom_rules_custom_category() {
395 let json = r#"{"rules": [{
396 "rule_id": "R1",
397 "severity": "ERROR",
398 "category": "security",
399 "expression": "true",
400 "message": "m"
401 }]}"#;
402 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
403 assert_eq!(rules[0].category, Some("security".into()));
404 }
405
406 #[test]
407 fn load_custom_rules_optional_fields() {
408 let json = r#"{"rules": [{
409 "rule_id": "R1",
410 "severity": "ERROR",
411 "expression": "true",
412 "message": "m",
413 "prop_path": "Properties.X",
414 "suggested_fix": "Add X"
415 }]}"#;
416 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
417 assert_eq!(rules[0].prop_path, Some("Properties.X".into()));
418 assert_eq!(rules[0].suggested_fix, Some("Add X".into()));
419 }
420
421 #[test]
422 fn load_custom_rules_global_rule_no_resource_type() {
423 let json = r#"{"rules": [{
424 "rule_id": "R1",
425 "severity": "ERROR",
426 "expression": "true",
427 "message": "m"
428 }]}"#;
429 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
430 assert_eq!(rules[0].resource_type, None);
431 }
432
433 #[test]
434 fn load_custom_rules_invalid_json() {
435 let result = load_custom_rules("not json", RuleOrigin::Custom);
436 result.unwrap_err();
437 }
438
439 #[test]
440 fn load_custom_rules_bad_cel_expression() {
441 let json = r#"{"rules": [{
442 "rule_id": "R1",
443 "severity": "ERROR",
444 "expression": "((( invalid cel",
445 "message": "m"
446 }]}"#;
447 let result = load_custom_rules(json, RuleOrigin::Custom);
448 let err_msg = format!("{}", result.unwrap_err());
449 assert!(err_msg.contains("R1"), "Error should mention rule_id, got: {}", err_msg);
450 }
451
452 #[test]
453 fn load_custom_rules_empty_rules_array() {
454 let json = r#"{"rules": []}"#;
455 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
456 assert!(rules.is_empty());
457 }
458
459 #[test]
460 fn load_custom_rules_empty_rule_id_is_rejected() {
461 let json = r#"{"rules": [{"rule_id": "", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
462 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
463 assert!(err.contains("empty 'rule_id'"), "an empty rule_id must be rejected, got: {err}");
464 }
465
466 #[test]
467 fn load_custom_rules_blank_rule_id_is_rejected() {
468 let json = r#"{"rules": [{"rule_id": " ", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
469 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
470 assert!(err.contains("empty 'rule_id'"), "a whitespace-only rule_id must be rejected, got: {err}");
471 }
472
473 #[test]
474 fn load_custom_rules_arbitrary_alphanumeric_id_with_separators_is_accepted() {
475 let json = r#"{"rules": [{"rule_id": "s3.encryption-required_1", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
478 let rules = load_custom_rules(json, RuleOrigin::Custom).expect("an alphanumeric+separator id must load");
479 assert_eq!(rules[0].rule_id, "s3.encryption-required_1");
480 }
481
482 #[test]
483 fn load_custom_rules_rule_id_with_space_or_punctuation_is_rejected() {
484 let json = r#"{"rules": [{"rule_id": "bad id", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
485 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
486 assert!(err.contains("invalid 'rule_id'"), "a rule_id with a space must be rejected, got: {err}");
487
488 let json = r#"{"rules": [{"rule_id": "bad/id", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
489 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
490 assert!(err.contains("invalid 'rule_id'"), "a rule_id with punctuation must be rejected, got: {err}");
491 }
492
493 #[test]
494 fn load_custom_rules_empty_message_is_rejected() {
495 let json = r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "true", "message": ""}]}"#;
496 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
497 assert!(err.contains("R1") && err.contains("empty 'message'"), "an empty message must be rejected, got: {err}");
498 }
499
500 #[test]
501 fn load_custom_rules_empty_expression_is_rejected() {
502 let json = r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": " ", "message": "m"}]}"#;
503 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
504 assert!(
505 err.contains("R1") && err.contains("empty 'expression'"),
506 "a blank expression must be rejected, got: {err}"
507 );
508 }
509
510 #[test]
511 fn load_custom_rules_unknown_function_is_rejected_at_load() {
512 let json = r#"{"rules": [{
515 "rule_id": "R1",
516 "severity": "ERROR",
517 "resource_type": "AWS::S3::Bucket",
518 "expression": "totally_unknown_fn(properties.BucketName)",
519 "message": "m"
520 }]}"#;
521 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
522 assert!(
523 err.contains("R1") && err.contains("unknown function") && err.contains("totally_unknown_fn"),
524 "an unknown function reference must be rejected at load, got: {err}"
525 );
526 }
527
528 #[test]
529 fn load_custom_rules_standard_functions_and_macros_are_accepted() {
530 let json = r#"{"rules": [{
533 "rule_id": "R1",
534 "severity": "ERROR",
535 "expression": "has(resource.Properties) && size(resources) > 0 && [1, 2].all(x, x > 0)",
536 "message": "m"
537 }]}"#;
538 let rules = load_custom_rules(json, RuleOrigin::Custom).expect("standard functions and macros must load");
539 assert_eq!(rules.len(), 1);
540 }
541
542 #[test]
543 fn load_custom_rules_type_function_is_accepted() {
544 let json = r#"{"rules": [{
547 "rule_id": "R1",
548 "severity": "ERROR",
549 "resource_type": "AWS::S3::Bucket",
550 "expression": "type(resource) == \"map\"",
551 "message": "m"
552 }]}"#;
553 let rules = load_custom_rules(json, RuleOrigin::Custom).expect("type() must be an accepted function");
554 assert_eq!(rules.len(), 1);
555 }
556
557 #[test]
558 fn load_custom_rules_multiple_rules() {
559 let json = r#"{"rules": [
560 {"rule_id": "R1", "severity": "ERROR", "expression": "true", "message": "m1"},
561 {"rule_id": "R2", "severity": "WARN", "expression": "false", "message": "m2"}
562 ]}"#;
563 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
564 assert_eq!(rules.len(), 2);
565 assert_eq!(rules[0].rule_id, "R1");
566 assert_eq!(rules[1].rule_id, "R2");
567 }
568
569 fn single_rule(source: &str, origin: RuleOrigin) -> CustomRule {
570 load_custom_rules(source, origin).expect("rule should load").pop().expect("one rule")
571 }
572
573 #[test]
574 fn execute_custom_rule_boolean_true_fires() {
575 let rule = single_rule(
576 r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "true", "message": "m"}]}"#,
577 RuleOrigin::Custom,
578 );
579 let ctx = crate::functions::build_custom_context(&serde_json::json!({}), None, None);
580 assert!(execute_custom_rule(&rule, &ctx).expect("boolean expression evaluates"));
581 }
582
583 #[test]
584 fn execute_custom_rule_boolean_false_does_not_fire() {
585 let rule = single_rule(
586 r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "false", "message": "m"}]}"#,
587 RuleOrigin::Custom,
588 );
589 let ctx = crate::functions::build_custom_context(&serde_json::json!({}), None, None);
590 assert!(!execute_custom_rule(&rule, &ctx).expect("boolean expression evaluates"));
591 }
592
593 #[test]
594 fn execute_custom_rule_non_boolean_result_is_error() {
595 let rule = single_rule(
598 r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "\"a string\"", "message": "m"}]}"#,
599 RuleOrigin::Custom,
600 );
601 let ctx = crate::functions::build_custom_context(&serde_json::json!({}), None, None);
602 let err = execute_custom_rule(&rule, &ctx).expect_err("a non-boolean result must error");
603 match err {
604 ValidationError::Engine(message) => {
605 assert!(message.contains("R1") && message.contains("boolean"), "got: {message}");
606 }
607 other => panic!("expected Engine error, got {other:?}"),
608 }
609 }
610
611 #[test]
612 fn execute_custom_rule_evaluation_error_is_fatal_for_custom_origin() {
613 let rule = single_rule(
616 r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "resource.Missing.Deep == \"x\"", "message": "m"}]}"#,
617 RuleOrigin::Custom,
618 );
619 let ctx = crate::functions::build_custom_context(&serde_json::json!({"resources": {}}), Some("Bucket"), None);
620 execute_custom_rule(&rule, &ctx).expect_err("a custom-rule evaluation error must be fatal");
621 }
622
623 #[test]
624 fn execute_custom_rule_evaluation_error_is_tolerated_for_guard_origin() {
625 let rule = single_rule(
628 r#"{"rules": [{"rule_id": "G1", "severity": "ERROR", "expression": "resource.Missing.Deep == \"x\"", "message": "m"}]}"#,
629 RuleOrigin::Guard,
630 );
631 let ctx = crate::functions::build_custom_context(&serde_json::json!({"resources": {}}), Some("Bucket"), None);
632 assert!(!execute_custom_rule(&rule, &ctx).expect("guard evaluation error is tolerated as non-firing"));
633 }
634}