1use crate::Severity;
2use log::warn;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6use std::sync::OnceLock;
7use template_model::EntityType;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
12#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
13#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
14#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
15#[serde(rename_all = "camelCase")]
16pub struct IdRange {
17 pub prefix: String,
18 pub start: u32,
19 pub end: u32,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
25#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
26#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
27#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
28#[serde(rename_all = "camelCase")]
29pub struct ResourceIdFilter {
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
32 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
33 pub rule_id: Option<String>,
34 pub resource_id: String,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
43#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
44#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
45#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
46#[serde(rename_all = "camelCase")]
47pub struct LogicalIdFilter {
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
50 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
51 pub rule_id: Option<String>,
52 pub logical_id: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
55 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
56 pub entity_type: Option<EntityType>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
62#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
63#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
64#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
65#[serde(rename_all = "camelCase")]
66pub struct ResourceTypeFilter {
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
69 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
70 pub rule_id: Option<String>,
71 pub resource_type: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
83#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
84#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
85#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
86#[serde(rename_all = "camelCase")]
87pub struct ServiceFilter {
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
90 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
91 pub rule_id: Option<String>,
92 pub service: String,
93}
94
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
98#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
99#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
100#[serde(rename_all = "camelCase")]
101pub struct RuleFilterConfig {
102 #[serde(default)]
103 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
104 pub ids: Vec<String>,
105 #[serde(default)]
106 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
107 pub categories: Vec<String>,
108 #[serde(default)]
109 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
110 pub id_ranges: Vec<IdRange>,
111 #[serde(default)]
113 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
114 pub id_patterns: Vec<String>,
115 #[serde(default)]
116 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
117 pub resource_ids: Vec<ResourceIdFilter>,
118 #[serde(default)]
119 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
120 pub logical_ids: Vec<LogicalIdFilter>,
121 #[serde(default)]
122 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
123 pub resource_types: Vec<ResourceTypeFilter>,
124 #[serde(default)]
125 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
126 pub services: Vec<ServiceFilter>,
127}
128
129impl RuleFilterConfig {
130 pub fn is_empty(&self) -> bool {
131 self.ids.is_empty()
132 && self.categories.is_empty()
133 && self.id_ranges.is_empty()
134 && self.id_patterns.is_empty()
135 && self.resource_ids.is_empty()
136 && self.logical_ids.is_empty()
137 && self.resource_types.is_empty()
138 && self.services.is_empty()
139 }
140
141 fn matches_globally(&self, rule_id: &str, category: Option<&str>, compiled: &CompiledPatterns) -> bool {
145 self.ids.iter().any(|id| id == rule_id)
146 || category.is_some_and(|cat| self.categories.iter().any(|c| c == cat))
147 || self.id_ranges.iter().any(|range| range_matches(rule_id, range))
148 || compiled.matches(rule_id)
149 }
150
151 fn could_match_rule(&self, rule_id: &str, category: Option<&str>, compiled: &CompiledPatterns) -> bool {
157 self.matches_globally(rule_id, category, compiled)
158 || self.resource_ids.iter().any(|f| rule_scope_matches(&f.rule_id, rule_id))
159 || self.logical_ids.iter().any(|f| rule_scope_matches(&f.rule_id, rule_id))
160 || self.resource_types.iter().any(|f| rule_scope_matches(&f.rule_id, rule_id))
161 || self.services.iter().any(|f| rule_scope_matches(&f.rule_id, rule_id))
162 }
163
164 fn matches(
165 &self,
166 rule_id: &str,
167 category: Option<&str>,
168 resource_id: Option<&str>,
169 resource_type: Option<&str>,
170 logical_id: Option<&str>,
171 entity_type: Option<EntityType>,
172 compiled: &CompiledPatterns,
173 ) -> bool {
174 if self.matches_globally(rule_id, category, compiled) {
175 return true;
176 }
177 if let Some(rid) = resource_id
178 && self.resource_ids.iter().any(|f| f.resource_id == rid && rule_scope_matches(&f.rule_id, rule_id))
179 {
180 return true;
181 }
182 if let Some(lid) = logical_id
183 && self.logical_ids.iter().any(|f| {
184 f.logical_id == lid
185 && rule_scope_matches(&f.rule_id, rule_id)
186 && f.entity_type.is_none_or(|scoped| entity_type == Some(scoped))
187 })
188 {
189 return true;
190 }
191 if let Some(rtype) = resource_type {
192 if self.resource_types.iter().any(|f| f.resource_type == rtype && rule_scope_matches(&f.rule_id, rule_id)) {
193 return true;
194 }
195 if let Some(service) = service_prefix(rtype)
196 && self.services.iter().any(|f| f.service == service && rule_scope_matches(&f.rule_id, rule_id))
197 {
198 return true;
199 }
200 }
201 false
202 }
203}
204
205fn rule_scope_matches(scope: &Option<String>, rule_id: &str) -> bool {
208 scope.as_deref().is_none_or(|id| id == rule_id)
209}
210
211fn service_prefix(resource_type: &str) -> Option<&str> {
216 let first = resource_type.find("::")?;
219 let service_name = &resource_type[first + 2..];
220 let second_relative = service_name.find("::")?;
221 if second_relative == 0 {
222 return None; }
224 Some(&resource_type[..first + 2 + second_relative])
225}
226
227#[derive(Debug, Default, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct FilterConfig {
234 #[serde(default)]
235 pub include: RuleFilterConfig,
236 #[serde(default)]
237 pub exclude: RuleFilterConfig,
238 #[serde(skip)]
239 compiled: OnceLock<CompiledFilters>,
240}
241
242impl Clone for FilterConfig {
243 fn clone(&self) -> Self {
244 FilterConfig { include: self.include.clone(), exclude: self.exclude.clone(), compiled: OnceLock::new() }
245 }
246}
247
248impl FilterConfig {
249 pub fn new(include: RuleFilterConfig, exclude: RuleFilterConfig) -> Self {
250 FilterConfig { include, exclude, compiled: OnceLock::new() }
251 }
252
253 pub fn is_empty(&self) -> bool {
254 self.include.is_empty() && self.exclude.is_empty()
255 }
256
257 fn compiled(&self) -> &CompiledFilters {
258 self.compiled.get_or_init(|| CompiledFilters {
259 include: CompiledPatterns::new(&self.include.id_patterns),
260 exclude: CompiledPatterns::new(&self.exclude.id_patterns),
261 })
262 }
263
264 #[must_use]
265 pub fn matches_rule(
266 &self,
267 rule_id: &str,
268 category: Option<&str>,
269 resource_id: Option<&str>,
270 resource_type: Option<&str>,
271 logical_id: Option<&str>,
272 entity_type: Option<EntityType>,
273 ) -> bool {
274 let compiled = self.compiled();
275 if !self.include.is_empty()
276 && !self.include.matches(
277 rule_id,
278 category,
279 resource_id,
280 resource_type,
281 logical_id,
282 entity_type,
283 &compiled.include,
284 )
285 {
286 return false;
287 }
288 if self.exclude.matches(
289 rule_id,
290 category,
291 resource_id,
292 resource_type,
293 logical_id,
294 entity_type,
295 &compiled.exclude,
296 ) {
297 return false;
298 }
299 true
300 }
301
302 pub fn excluded_categories(&self) -> HashSet<&str> {
303 let mut cats: HashSet<&str> = self.exclude.categories.iter().map(|s| s.as_str()).collect();
304 for c in &self.include.categories {
305 cats.remove(c.as_str());
306 }
307 cats
308 }
309
310 #[must_use]
315 pub fn invalid_patterns(&self) -> Vec<String> {
316 let compiled = self.compiled();
317 let mut invalid = compiled.include.invalid.clone();
318 invalid.extend(compiled.exclude.invalid.iter().cloned());
319 invalid
320 }
321
322 #[must_use]
336 pub fn globally_suppresses_builtin_rule(
337 &self,
338 rule_id: &str,
339 category: Option<&str>,
340 severity: Severity,
341 strict: bool,
342 severity_level: Severity,
343 ) -> bool {
344 let effective_severity = if strict && severity == Severity::Warn { Severity::Error } else { severity };
347 if effective_severity < severity_level {
348 return true;
349 }
350 let compiled = self.compiled();
351 if self.exclude.matches_globally(rule_id, category, &compiled.exclude) {
352 return true;
353 }
354 !self.include.is_empty() && !self.include.could_match_rule(rule_id, category, &compiled.include)
355 }
356}
357
358#[derive(Debug)]
359struct CompiledPatterns {
360 regexes: Vec<Regex>,
361 invalid: Vec<String>,
362}
363
364impl CompiledPatterns {
365 fn new(patterns: &[String]) -> Self {
366 let mut regexes = Vec::new();
367 let mut invalid = Vec::new();
368 for pattern in patterns {
369 match Regex::new(pattern) {
370 Ok(regex) => regexes.push(regex),
371 Err(error) => {
372 warn!("Invalid rule-id filter pattern '{}': {}", pattern, error);
373 invalid.push(pattern.clone());
374 }
375 }
376 }
377 CompiledPatterns { regexes, invalid }
378 }
379
380 fn matches(&self, rule_id: &str) -> bool {
381 self.regexes.iter().any(|r| r.is_match(rule_id))
382 }
383}
384
385#[derive(Debug)]
386struct CompiledFilters {
387 include: CompiledPatterns,
388 exclude: CompiledPatterns,
389}
390
391fn range_matches(rule_id: &str, range: &IdRange) -> bool {
392 let Some(suffix) = rule_id.strip_prefix(&range.prefix) else {
393 return false;
394 };
395 let Ok(num) = suffix.parse::<u32>() else {
396 return false;
397 };
398 num >= range.start && num <= range.end
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn empty_filter_matches_all_rules() {
407 let f = FilterConfig::default();
408 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
409 assert!(f.matches_rule("W9037", Some("security"), Some("Bucket"), Some("AWS::S3::Bucket"), None, None));
410 }
411
412 #[test]
413 fn include_by_id_accepts_matching_and_rejects_others() {
414 let f = FilterConfig {
415 include: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
416 ..Default::default()
417 };
418 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
419 assert!(!f.matches_rule("E3013", Some("schema"), None, None, None, None));
420 }
421
422 #[test]
423 fn exclude_by_id_rejects_matching_and_accepts_others() {
424 let f = FilterConfig {
425 exclude: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
426 ..Default::default()
427 };
428 assert!(!f.matches_rule("E3012", Some("schema"), None, None, None, None));
429 assert!(f.matches_rule("E3013", Some("schema"), None, None, None, None));
430 }
431
432 #[test]
433 fn include_by_category_accepts_matching_category_only() {
434 let f = FilterConfig {
435 include: RuleFilterConfig { categories: vec!["schema".into()], ..Default::default() },
436 ..Default::default()
437 };
438 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
439 assert!(!f.matches_rule("W9501", Some("security"), None, None, None, None));
440 }
441
442 #[test]
443 fn exclude_by_category_rejects_matching_category() {
444 let f = FilterConfig {
445 exclude: RuleFilterConfig { categories: vec!["best-practice".into()], ..Default::default() },
446 ..Default::default()
447 };
448 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
449 assert!(!f.matches_rule("I9040", Some("best-practice"), None, None, None, None));
450 }
451
452 #[test]
453 fn include_by_id_range_accepts_within_bounds_only() {
454 let f = FilterConfig {
455 include: RuleFilterConfig {
456 id_ranges: vec![IdRange { prefix: "E".into(), start: 3000, end: 3099 }],
457 ..Default::default()
458 },
459 ..Default::default()
460 };
461 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
462 assert!(f.matches_rule("E3099", Some("schema"), None, None, None, None));
463 assert!(!f.matches_rule("E3100", Some("schema"), None, None, None, None));
464 }
465
466 #[test]
467 fn include_by_regex_matches_pattern() {
468 let f = FilterConfig {
469 include: RuleFilterConfig { id_patterns: vec!["^E3\\d{3}$".into()], ..Default::default() },
470 ..Default::default()
471 };
472 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
473 assert!(!f.matches_rule("W9037", Some("security"), None, None, None, None));
474 }
475
476 #[test]
477 fn exclude_by_resource_id_suppresses_specific_resource() {
478 let f = FilterConfig {
479 exclude: RuleFilterConfig {
480 resource_ids: vec![ResourceIdFilter { rule_id: Some("E3012".into()), resource_id: "MyBucket".into() }],
481 ..Default::default()
482 },
483 ..Default::default()
484 };
485 assert!(!f.matches_rule("E3012", Some("schema"), Some("MyBucket"), Some("AWS::S3::Bucket"), None, None));
486 assert!(f.matches_rule("E3012", Some("schema"), Some("OtherBucket"), Some("AWS::S3::Bucket"), None, None));
487 }
488
489 #[test]
490 fn exclude_by_resource_id_without_rule_id_suppresses_every_rule_on_resource() {
491 let f = FilterConfig {
492 exclude: RuleFilterConfig {
493 resource_ids: vec![ResourceIdFilter { rule_id: None, resource_id: "MyBucket".into() }],
494 ..Default::default()
495 },
496 ..Default::default()
497 };
498 assert!(!f.matches_rule("E3012", Some("schema"), Some("MyBucket"), Some("AWS::S3::Bucket"), None, None));
500 assert!(!f.matches_rule("W3697", Some("best-practice"), Some("MyBucket"), Some("AWS::S3::Bucket"), None, None));
501 assert!(f.matches_rule("E3012", Some("schema"), Some("OtherBucket"), Some("AWS::S3::Bucket"), None, None));
503 }
504
505 #[test]
506 fn exclude_by_logical_id_suppresses_specific_rule_on_entity() {
507 let f = FilterConfig {
508 exclude: RuleFilterConfig {
509 logical_ids: vec![LogicalIdFilter {
510 rule_id: Some("W2001".into()),
511 logical_id: "MyParam".into(),
512 entity_type: None,
513 }],
514 ..Default::default()
515 },
516 ..Default::default()
517 };
518 assert!(!f.matches_rule("W2001", Some("best-practice"), None, None, Some("MyParam"), None));
521 assert!(f.matches_rule("F2002", Some("parameter"), None, None, Some("MyParam"), None));
522 assert!(f.matches_rule("W2001", Some("best-practice"), None, None, Some("OtherParam"), None));
523 }
524
525 #[test]
526 fn exclude_by_logical_id_without_rule_id_suppresses_every_rule_on_entity() {
527 let f = FilterConfig {
528 exclude: RuleFilterConfig {
529 logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyParam".into(), entity_type: None }],
530 ..Default::default()
531 },
532 ..Default::default()
533 };
534 assert!(!f.matches_rule("W2001", Some("best-practice"), None, None, Some("MyParam"), None));
535 assert!(!f.matches_rule("F2002", Some("parameter"), None, None, Some("MyParam"), None));
536 assert!(f.matches_rule("W2001", Some("best-practice"), None, None, Some("OtherParam"), None));
537 assert!(f.matches_rule("F0001", Some("structure"), None, None, None, None));
539 }
540
541 #[test]
542 fn include_by_logical_id_accepts_matching_entity_only() {
543 let f = FilterConfig {
544 include: RuleFilterConfig {
545 logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyParam".into(), entity_type: None }],
546 ..Default::default()
547 },
548 ..Default::default()
549 };
550 assert!(f.matches_rule("W2001", Some("best-practice"), None, None, Some("MyParam"), None));
551 assert!(!f.matches_rule("W2001", Some("best-practice"), None, None, Some("OtherParam"), None));
552 assert!(!f.matches_rule("F0001", Some("structure"), None, None, None, None));
553 }
554
555 #[test]
556 fn logical_id_filter_with_entity_type_matches_only_that_type() {
557 let f = FilterConfig {
560 exclude: RuleFilterConfig {
561 logical_ids: vec![LogicalIdFilter {
562 rule_id: None,
563 logical_id: "MyThing".into(),
564 entity_type: Some(EntityType::Parameter),
565 }],
566 ..Default::default()
567 },
568 ..Default::default()
569 };
570 assert!(!f.matches_rule(
571 "W2001",
572 Some("best-practice"),
573 None,
574 None,
575 Some("MyThing"),
576 Some(EntityType::Parameter)
577 ));
578 assert!(f.matches_rule(
579 "E3012",
580 Some("schema"),
581 Some("MyThing"),
582 Some("AWS::S3::Bucket"),
583 Some("MyThing"),
584 Some(EntityType::Resource),
585 ));
586 }
587
588 #[test]
589 fn logical_id_filter_without_entity_type_matches_every_type() {
590 let f = FilterConfig {
591 exclude: RuleFilterConfig {
592 logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyThing".into(), entity_type: None }],
593 ..Default::default()
594 },
595 ..Default::default()
596 };
597 assert!(!f.matches_rule("W2001", None, None, None, Some("MyThing"), Some(EntityType::Parameter)));
598 assert!(!f.matches_rule("E3012", None, Some("MyThing"), None, Some("MyThing"), Some(EntityType::Resource)));
599 }
600
601 #[test]
602 fn logical_id_filter_combines_entity_type_and_rule_scope() {
603 let f = FilterConfig {
604 exclude: RuleFilterConfig {
605 logical_ids: vec![LogicalIdFilter {
606 rule_id: Some("W2001".into()),
607 logical_id: "MyParam".into(),
608 entity_type: Some(EntityType::Parameter),
609 }],
610 ..Default::default()
611 },
612 ..Default::default()
613 };
614 assert!(!f.matches_rule("W2001", None, None, None, Some("MyParam"), Some(EntityType::Parameter)));
615 assert!(f.matches_rule("F2002", None, None, None, Some("MyParam"), Some(EntityType::Parameter)));
617 }
618
619 #[test]
620 fn logical_id_filter_matches_resources_alongside_resource_id_filter() {
621 let f = FilterConfig {
624 exclude: RuleFilterConfig {
625 logical_ids: vec![LogicalIdFilter { rule_id: None, logical_id: "MyBucket".into(), entity_type: None }],
626 ..Default::default()
627 },
628 ..Default::default()
629 };
630 assert!(!f.matches_rule(
631 "E3012",
632 Some("schema"),
633 Some("MyBucket"),
634 Some("AWS::S3::Bucket"),
635 Some("MyBucket"),
636 None
637 ));
638 }
639
640 #[test]
641 fn exclude_by_resource_type_suppresses_specific_type() {
642 let f = FilterConfig {
643 exclude: RuleFilterConfig {
644 resource_types: vec![ResourceTypeFilter {
645 rule_id: Some("E3012".into()),
646 resource_type: "AWS::S3::Bucket".into(),
647 }],
648 ..Default::default()
649 },
650 ..Default::default()
651 };
652 assert!(!f.matches_rule("E3012", Some("schema"), Some("B"), Some("AWS::S3::Bucket"), None, None));
653 assert!(f.matches_rule("E3012", Some("schema"), Some("I"), Some("AWS::EC2::Instance"), None, None));
654 }
655
656 #[test]
657 fn exclude_by_resource_type_without_rule_id_suppresses_every_rule_on_type() {
658 let f = FilterConfig {
659 exclude: RuleFilterConfig {
660 resource_types: vec![ResourceTypeFilter { rule_id: None, resource_type: "AWS::S3::Bucket".into() }],
661 ..Default::default()
662 },
663 ..Default::default()
664 };
665 assert!(!f.matches_rule("E3012", Some("schema"), Some("B"), Some("AWS::S3::Bucket"), None, None));
666 assert!(!f.matches_rule("W9037", Some("security"), Some("B"), Some("AWS::S3::Bucket"), None, None));
667 assert!(f.matches_rule("E3012", Some("schema"), Some("I"), Some("AWS::EC2::Instance"), None, None));
668 }
669
670 #[test]
671 fn exclude_by_service_suppresses_one_rule_across_the_whole_service() {
672 let f = FilterConfig {
676 exclude: RuleFilterConfig {
677 services: vec![ServiceFilter { rule_id: Some("W3697".into()), service: "AWS::AutoScaling".into() }],
678 ..Default::default()
679 },
680 ..Default::default()
681 };
682 assert!(!f.matches_rule(
683 "W3697",
684 Some("best-practice"),
685 Some("Lc"),
686 Some("AWS::AutoScaling::LaunchConfiguration"),
687 None,
688 None
689 ));
690 assert!(!f.matches_rule(
691 "W3697",
692 Some("best-practice"),
693 Some("Asg"),
694 Some("AWS::AutoScaling::AutoScalingGroup"),
695 None,
696 None
697 ));
698 assert!(f.matches_rule(
700 "E3012",
701 Some("schema"),
702 Some("Lc"),
703 Some("AWS::AutoScaling::LaunchConfiguration"),
704 None,
705 None
706 ));
707 assert!(f.matches_rule("W3697", Some("best-practice"), Some("Q"), Some("AWS::SQS::Queue"), None, None));
709 }
710
711 #[test]
712 fn exclude_by_service_without_rule_id_suppresses_every_rule_on_service() {
713 let f = FilterConfig {
714 exclude: RuleFilterConfig {
715 services: vec![ServiceFilter { rule_id: None, service: "AWS::AutoScaling".into() }],
716 ..Default::default()
717 },
718 ..Default::default()
719 };
720 assert!(!f.matches_rule(
721 "W3697",
722 Some("best-practice"),
723 Some("Lc"),
724 Some("AWS::AutoScaling::LaunchConfiguration"),
725 None,
726 None
727 ));
728 assert!(!f.matches_rule(
729 "E3012",
730 Some("schema"),
731 Some("Lc"),
732 Some("AWS::AutoScaling::LaunchConfiguration"),
733 None,
734 None
735 ));
736 assert!(f.matches_rule("E3012", Some("schema"), Some("Q"), Some("AWS::SQS::Queue"), None, None));
737 }
738
739 #[test]
740 fn include_by_service_accepts_matching_service_only() {
741 let f = FilterConfig {
742 include: RuleFilterConfig {
743 services: vec![ServiceFilter { rule_id: None, service: "AWS::AutoScaling".into() }],
744 ..Default::default()
745 },
746 ..Default::default()
747 };
748 assert!(f.matches_rule(
749 "W3697",
750 Some("best-practice"),
751 Some("Lc"),
752 Some("AWS::AutoScaling::LaunchConfiguration"),
753 None,
754 None
755 ));
756 assert!(!f.matches_rule("W3697", Some("best-practice"), Some("Q"), Some("AWS::SQS::Queue"), None, None));
757 }
758
759 #[test]
760 fn service_filter_requires_the_full_provider_and_service_prefix() {
761 let f = FilterConfig {
765 exclude: RuleFilterConfig {
766 services: vec![ServiceFilter { rule_id: None, service: "AutoScaling".into() }],
767 ..Default::default()
768 },
769 ..Default::default()
770 };
771 assert!(f.matches_rule(
772 "W3697",
773 Some("best-practice"),
774 Some("Lc"),
775 Some("AWS::AutoScaling::LaunchConfiguration"),
776 None,
777 None
778 ));
779 }
780
781 #[test]
782 fn service_filter_matches_non_aws_provider_prefix() {
783 let f = FilterConfig {
785 exclude: RuleFilterConfig {
786 services: vec![ServiceFilter { rule_id: None, service: "Alexa::ASK".into() }],
787 ..Default::default()
788 },
789 ..Default::default()
790 };
791 assert!(!f.matches_rule("E3012", Some("schema"), Some("S"), Some("Alexa::ASK::Skill"), None, None));
792 assert!(f.matches_rule(
793 "E3012",
794 Some("schema"),
795 Some("Lc"),
796 Some("AWS::AutoScaling::LaunchConfiguration"),
797 None,
798 None
799 ));
800 }
801
802 #[test]
803 fn service_filter_matches_by_string_equality_on_the_prefix() {
804 let f = FilterConfig {
807 exclude: RuleFilterConfig {
808 services: vec![ServiceFilter { rule_id: None, service: "AWS::MadeUpService".into() }],
809 ..Default::default()
810 },
811 ..Default::default()
812 };
813 assert!(!f.matches_rule("E3012", Some("schema"), Some("R"), Some("AWS::MadeUpService::Widget"), None, None));
814 assert!(f.matches_rule("E3012", Some("schema"), Some("R"), Some("Custom::MadeUpService::Thing"), None, None));
815 }
816
817 #[test]
818 fn service_filter_does_not_match_when_resource_type_has_no_service_prefix() {
819 let f = FilterConfig {
820 exclude: RuleFilterConfig {
821 services: vec![ServiceFilter { rule_id: None, service: "Bucket".into() }],
822 ..Default::default()
823 },
824 ..Default::default()
825 };
826 assert!(f.matches_rule("E3012", Some("schema"), Some("R"), Some("Bucket"), None, None));
828 }
829
830 #[test]
831 fn include_and_exclude_combined_applies_both() {
832 let f = FilterConfig {
833 include: RuleFilterConfig { categories: vec!["schema".into()], ..Default::default() },
834 exclude: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
835 ..Default::default()
836 };
837 assert!(!f.matches_rule("E3012", Some("schema"), None, None, None, None));
838 assert!(f.matches_rule("E3013", Some("schema"), None, None, None, None));
839 assert!(!f.matches_rule("W9501", Some("security"), None, None, None, None));
840 }
841
842 #[test]
843 fn excluded_categories_omits_categories_also_in_include() {
844 let f = FilterConfig {
845 exclude: RuleFilterConfig {
846 categories: vec!["best-practice".into(), "schema".into()],
847 ..Default::default()
848 },
849 include: RuleFilterConfig { categories: vec!["schema".into()], ..Default::default() },
850 ..Default::default()
851 };
852 let cats = f.excluded_categories();
853 assert!(cats.contains("best-practice"), "expected 'best-practice' in excluded categories");
854 assert!(!cats.contains("schema"), "'schema' should not be in excluded categories");
855 }
856
857 #[test]
858 fn invalid_regex_pattern_is_skipped_and_valid_patterns_still_match() {
859 let f = FilterConfig {
860 include: RuleFilterConfig { id_patterns: vec!["[invalid".into(), "^E3\\d+$".into()], ..Default::default() },
861 ..Default::default()
862 };
863 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
864 }
865
866 #[test]
867 fn invalid_patterns_are_reported_for_include_and_exclude() {
868 let f = FilterConfig {
869 include: RuleFilterConfig { id_patterns: vec!["[bad-include".into(), "^E3".into()], ..Default::default() },
870 exclude: RuleFilterConfig { id_patterns: vec!["(bad-exclude".into()], ..Default::default() },
871 ..Default::default()
872 };
873 let invalid = f.invalid_patterns();
874 assert!(invalid.contains(&"[bad-include".to_string()));
875 assert!(invalid.contains(&"(bad-exclude".to_string()));
876 assert!(!invalid.contains(&"^E3".to_string()), "valid patterns must not be reported");
877 }
878
879 #[test]
880 fn valid_patterns_report_no_invalid() {
881 let f = FilterConfig {
882 include: RuleFilterConfig { id_patterns: vec!["^E3\\d+$".into()], ..Default::default() },
883 ..Default::default()
884 };
885 assert!(f.invalid_patterns().is_empty());
886 }
887
888 #[test]
889 fn service_prefix_extracts_provider_and_service() {
890 assert_eq!(service_prefix("AWS::AutoScaling::LaunchConfiguration"), Some("AWS::AutoScaling"));
891 assert_eq!(service_prefix("AWS::S3::Bucket"), Some("AWS::S3"));
892 assert_eq!(service_prefix("Alexa::ASK::Skill"), Some("Alexa::ASK"));
893 assert_eq!(service_prefix("AWS::S3::Bucket::Tag"), Some("AWS::S3"));
895 }
896
897 #[test]
898 fn service_prefix_none_without_a_second_segment_or_empty_service_name() {
899 assert_eq!(service_prefix("Bucket"), None, "no `::` at all");
900 assert_eq!(service_prefix("Custom::MyResource"), None, "only a provider segment, no data-type segment");
901 assert_eq!(service_prefix("AWS::"), None, "provider then empty, no second `::`");
902 assert_eq!(service_prefix("AWS::::Widget"), None, "empty service-name segment");
903 assert_eq!(service_prefix(""), None);
904 }
905
906 #[test]
907 fn cloned_filter_recompiles_patterns_and_matches() {
908 let f = FilterConfig {
909 include: RuleFilterConfig { id_patterns: vec!["^E3\\d+$".into()], ..Default::default() },
910 ..Default::default()
911 };
912 assert!(f.matches_rule("E3012", Some("schema"), None, None, None, None));
913 let f2 = f.clone();
914 assert!(f2.matches_rule("E3012", Some("schema"), None, None, None, None));
915 }
916
917 #[test]
918 fn empty_filter_globally_suppresses_nothing_at_the_default_level() {
919 let f = FilterConfig::default();
920 assert!(!f.globally_suppresses_builtin_rule(
921 "W9010",
922 Some("best-practice"),
923 Severity::Warn,
924 false,
925 Severity::Info
926 ));
927 assert!(!f.globally_suppresses_builtin_rule("E3012", Some("schema"), Severity::Error, false, Severity::Info));
928 }
929
930 #[test]
931 fn severity_gate_suppresses_rules_below_the_minimum_level() {
932 let f = FilterConfig::default();
933 assert!(f.globally_suppresses_builtin_rule(
935 "W9010",
936 Some("best-practice"),
937 Severity::Warn,
938 false,
939 Severity::Error
940 ));
941 assert!(f.globally_suppresses_builtin_rule(
942 "I3011",
943 Some("best-practice"),
944 Severity::Info,
945 false,
946 Severity::Error
947 ));
948 assert!(!f.globally_suppresses_builtin_rule("E3012", Some("schema"), Severity::Error, false, Severity::Error));
949 }
950
951 #[test]
952 fn strict_promotion_keeps_a_warn_rule_alive_at_the_error_level() {
953 let f = FilterConfig::default();
954 assert!(!f.globally_suppresses_builtin_rule(
956 "W9010",
957 Some("best-practice"),
958 Severity::Warn,
959 true,
960 Severity::Error
961 ));
962 assert!(f.globally_suppresses_builtin_rule(
964 "I3011",
965 Some("best-practice"),
966 Severity::Info,
967 true,
968 Severity::Error
969 ));
970 }
971
972 #[test]
973 fn global_exclude_dimensions_suppress_a_rule() {
974 let by_id = FilterConfig {
975 exclude: RuleFilterConfig { ids: vec!["W9010".into()], ..Default::default() },
976 ..Default::default()
977 };
978 assert!(by_id.globally_suppresses_builtin_rule(
979 "W9010",
980 Some("best-practice"),
981 Severity::Warn,
982 false,
983 Severity::Info
984 ));
985
986 let by_category = FilterConfig {
987 exclude: RuleFilterConfig { categories: vec!["best-practice".into()], ..Default::default() },
988 ..Default::default()
989 };
990 assert!(by_category.globally_suppresses_builtin_rule(
991 "W9010",
992 Some("best-practice"),
993 Severity::Warn,
994 false,
995 Severity::Info
996 ));
997
998 let by_range = FilterConfig {
999 exclude: RuleFilterConfig {
1000 id_ranges: vec![IdRange { prefix: "W".into(), start: 9000, end: 9099 }],
1001 ..Default::default()
1002 },
1003 ..Default::default()
1004 };
1005 assert!(by_range.globally_suppresses_builtin_rule(
1006 "W9010",
1007 Some("best-practice"),
1008 Severity::Warn,
1009 false,
1010 Severity::Info
1011 ));
1012
1013 let by_pattern = FilterConfig {
1014 exclude: RuleFilterConfig { id_patterns: vec!["^W90".into()], ..Default::default() },
1015 ..Default::default()
1016 };
1017 assert!(by_pattern.globally_suppresses_builtin_rule(
1018 "W9010",
1019 Some("best-practice"),
1020 Severity::Warn,
1021 false,
1022 Severity::Info
1023 ));
1024 }
1025
1026 #[test]
1027 fn scoped_exclude_dimensions_never_globally_suppress() {
1028 let by_type = FilterConfig {
1031 exclude: RuleFilterConfig {
1032 resource_types: vec![ResourceTypeFilter {
1033 rule_id: Some("W9010".into()),
1034 resource_type: "AWS::EC2::Instance".into(),
1035 }],
1036 ..Default::default()
1037 },
1038 ..Default::default()
1039 };
1040 assert!(!by_type.globally_suppresses_builtin_rule(
1041 "W9010",
1042 Some("best-practice"),
1043 Severity::Warn,
1044 false,
1045 Severity::Info
1046 ));
1047
1048 let by_service = FilterConfig {
1049 exclude: RuleFilterConfig {
1050 services: vec![ServiceFilter { rule_id: None, service: "AWS::EC2".into() }],
1051 ..Default::default()
1052 },
1053 ..Default::default()
1054 };
1055 assert!(!by_service.globally_suppresses_builtin_rule(
1056 "W9010",
1057 Some("best-practice"),
1058 Severity::Warn,
1059 false,
1060 Severity::Info
1061 ));
1062
1063 let by_resource_id = FilterConfig {
1064 exclude: RuleFilterConfig {
1065 resource_ids: vec![ResourceIdFilter { rule_id: None, resource_id: "MyInstance".into() }],
1066 ..Default::default()
1067 },
1068 ..Default::default()
1069 };
1070 assert!(!by_resource_id.globally_suppresses_builtin_rule(
1071 "W9010",
1072 Some("best-practice"),
1073 Severity::Warn,
1074 false,
1075 Severity::Info
1076 ));
1077 }
1078
1079 #[test]
1080 fn nonempty_include_globally_suppresses_rules_it_can_never_admit() {
1081 let f = FilterConfig {
1082 include: RuleFilterConfig { ids: vec!["E3012".into()], ..Default::default() },
1083 ..Default::default()
1084 };
1085 assert!(f.globally_suppresses_builtin_rule(
1087 "W9010",
1088 Some("best-practice"),
1089 Severity::Warn,
1090 false,
1091 Severity::Info
1092 ));
1093 assert!(!f.globally_suppresses_builtin_rule("E3012", Some("schema"), Severity::Error, false, Severity::Info));
1094 }
1095
1096 #[test]
1097 fn nonempty_include_with_scoped_dimension_keeps_a_coverable_rule_alive() {
1098 let f = FilterConfig {
1101 include: RuleFilterConfig {
1102 resource_types: vec![ResourceTypeFilter { rule_id: None, resource_type: "AWS::EC2::Instance".into() }],
1103 ..Default::default()
1104 },
1105 ..Default::default()
1106 };
1107 assert!(!f.globally_suppresses_builtin_rule(
1108 "W9010",
1109 Some("best-practice"),
1110 Severity::Warn,
1111 false,
1112 Severity::Info
1113 ));
1114
1115 let scoped_to_other = FilterConfig {
1117 include: RuleFilterConfig {
1118 resource_types: vec![ResourceTypeFilter {
1119 rule_id: Some("E3012".into()),
1120 resource_type: "AWS::EC2::Instance".into(),
1121 }],
1122 ..Default::default()
1123 },
1124 ..Default::default()
1125 };
1126 assert!(scoped_to_other.globally_suppresses_builtin_rule(
1127 "W9010",
1128 Some("best-practice"),
1129 Severity::Warn,
1130 false,
1131 Severity::Info
1132 ));
1133 assert!(!scoped_to_other.globally_suppresses_builtin_rule(
1134 "E3012",
1135 Some("schema"),
1136 Severity::Error,
1137 false,
1138 Severity::Info
1139 ));
1140 }
1141}