Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CUSTOM_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ do not fetch regional data themselves.
| `subnets_outside_vpc_findings` | `() -> [{subnetId, message}]` | Subnets whose IPv4 CIDR, in some deployment scenario, lies outside every IPv4 network of their VPC (own `CidrBlock` plus `VPCCidrBlock` attachments); silent when any of those networks is only known at deployment. |
| `overlapping_subnet_findings` | `() -> [{subnetId, message, earlierSubnetId, earlierSubnetMessage}]` | Pairs of subnets of provably the same VPC whose IPv4 CIDRs overlap in coexisting deployment scenarios, attributed to the later subnet. |
| `sqs_event_source_timeout_violations` | `() -> [{queueId, visibilityTimeout, functionTimeout, message, suggestedFix}]` | SQS/Lambda event-source timeout violations across jointly reachable scenarios; skips values controlled by parameters or macros until deployment. |
| `event_pattern_violations` | `() -> [{resourceId, propertyPath, message, suggestedFix}]` | Matchers in `AWS::Events::Rule` and `AWS::Events::Archive` `EventPattern` (object or JSON string) that EventBridge rejects; skips only values controlled by overridable parameters or otherwise unknown until deployment. |
| `pipeline_artifacts` | `(resource_id) -> {issues: [{message}]}` | Finds duplicate CodePipeline outputs and inputs that do not reference a previously defined output. |
| `pipeline_artifact_count_issues` | `(resource_id) -> {issues: [{message}]}` | Checks CodePipeline action input/output artifact counts, including conditional branches, against embedded action bounds. |
| `estimated_string_length_bounds` | `(resource_id, path) -> {shortest, longest}` | Shortest and longest resolved length for literals and supported string intrinsics; undefined when the length cannot be pinned for every possibility. |
Expand Down
18 changes: 18 additions & 0 deletions src/cel-engine/src/rules/resources_extra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use template_model::consts::{
KEY_CREATION_POLICY, KEY_PROPERTIES, KEY_UPDATE_POLICY, PARAM_TYPE_STRING, TRANSFORM_SERVERLESS,
};
use template_model::dynamodb::analyze_dynamodb_table_scenarios;
use template_model::event_pattern::event_pattern_violations;
use template_model::fargate::{CPU_UNIT_LABELS, VCPU_SIZES, cpu_is_offered};
use template_model::iam_policy::{inline_identity_policy_document_paths, validate_identity_policy_scenarios};
use template_model::lambda_sqs::sqs_event_source_timeout_violations;
Expand Down Expand Up @@ -378,6 +379,22 @@ fn sqs_event_source_timeout_diagnostics(model: &SemanticModel) -> Vec<Diagnostic
.collect()
}

fn event_pattern_diagnostics(model: &SemanticModel) -> Vec<Diagnostic> {
event_pattern_violations(model)
.into_iter()
.map(|violation| {
make_resource_diagnostic(
"E9014",
&violation.message,
model,
&violation.resource_id,
&violation.property_path,
Some(&violation.suggested_fix),
)
})
.collect()
}

fn resolve_concrete(m: &SemanticModel, rid: &str, path: &str) -> Option<serde_json::Value> {
if let Some(resolved) = m.resolve_deep(rid, path).or_else(|| m.resolve(rid, path).cloned()) {
return match resolved {
Expand Down Expand Up @@ -2036,6 +2053,7 @@ pub fn eval_extra_resources(ctx: &EvalContext) -> Vec<Diagnostic> {
}

out.extend(sqs_event_source_timeout_diagnostics(m));
out.extend(event_pattern_diagnostics(m));

for name in m.resources_of_type("AWS::SSM::Document") {
if let Some(content) = resolve_concrete(m, name, "Properties.Content")
Expand Down
13 changes: 13 additions & 0 deletions src/cel-engine/tests/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ Resources:
assert!(!ids.iter().any(|id| id.starts_with("F0")), "No structure (fatal) errors expected, got: {:?}", ids);
}

#[test]
fn invalid_event_patterns_trigger_e9014_once_per_rejected_matcher() {
let ids = validate_file("bad/E9014_invalid_event_patterns.yaml");
let event_pattern_findings = ids.iter().filter(|rule_id| rule_id.as_str() == "E9014").count();
assert_eq!(event_pattern_findings, 29, "Expected one E9014 per rejected pattern shape, got: {ids:?}");
}

#[test]
fn valid_and_unresolved_event_patterns_do_not_trigger_e9014() {
let ids = validate_file("good/E9014_valid_event_patterns.yaml");
assert!(!ids.contains(&"E9014".to_string()), "Expected no E9014, got: {ids:?}");
}

#[test]
fn sqs_default_visibility_timeout_below_function_timeout_triggers_e3505() {
let ids = validate_file("bad/E3505_sqs_default_visibility_timeout.yaml");
Expand Down
90 changes: 90 additions & 0 deletions src/cfn-validate/tests/resource_rule_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2373,6 +2373,96 @@ fn icmp_type_code_pairs_and_ascending_ranges_are_not_inverted() {
assert!(rego_findings.is_empty(), "type/code pairs are not port ranges: {rego_findings:?}");
}

#[test]
fn invalid_event_patterns_have_identical_precise_diagnostics() {
let template = include_str!("../../resources/templates/bad/E9014_invalid_event_patterns.yaml");
let (rego, cel) = engines();
let rego_findings = selected_findings_with_spans(&rego, template, &["E9014"]);
let cel_findings = selected_findings_with_spans(&cel, template, &["E9014"]);
assert_eq!(rego_findings, cel_findings, "event pattern diagnostics must be identical across engines");
let expected = [
(
"UnknownOperatorRule",
"Properties.EventPattern.detail.name.0.startsWith",
"'startsWith' in 'EventPattern.detail.name[0]' is not an event pattern operator",
),
(
"NumericComparisonRule",
"Properties.EventPattern.detail.price.0.numeric.0",
"'=>' in 'EventPattern.detail.price[0]' is not a numeric comparison operator",
),
("AnythingButNestedRule", "Properties.EventPattern.detail.state.0.anything-but.exists", "cannot use 'exists'"),
("PrefixNumberRule", "Properties.EventPattern.detail.code.0.prefix", "must be a string"),
("ExistsStringRule", "Properties.EventPattern.detail.error.0.exists", "must be true or false"),
(
"ConsecutiveWildcardRule",
"Properties.EventPattern.detail.path.0.wildcard",
"has consecutive wildcard characters",
),
("OrSingleBranchRule", "Properties.EventPattern.$or", "must contain at least 2 branch objects"),
("EmptyArrayRule", "Properties.EventPattern.source", "is an empty array"),
("ScalarValueStringRule", "Properties.EventPattern", "must be an array or an object"),
("MalformedJsonRule", "Properties.EventPattern", "EventPattern string is not valid JSON"),
("NonObjectJsonRule", "Properties.EventPattern", "EventPattern must be a JSON object"),
("InvalidPrefixObjectRule", "Properties.EventPattern.detail.name.0.prefix", "must be a string"),
(
"InvalidNumericShapeRule",
"Properties.EventPattern.detail.price.0.numeric",
"must contain two or four elements",
),
("EqualNumericBoundsRule", "Properties.EventPattern.detail.price.0.numeric", "cannot match any value"),
(
"AnythingButMixedValuesRule",
"Properties.EventPattern.detail.state.0.anything-but",
"cannot mix strings and numbers",
),
("WildcardNumberRule", "Properties.EventPattern.detail.path.0.wildcard", "must be a string"),
(
"InvalidWildcardEscapeRule",
"Properties.EventPattern.detail.path.0.wildcard",
"uses an unsupported escape sequence",
),
("InvalidCidrRule", "Properties.EventPattern.detail.address.0.cidr", "must be a valid IPv4 or IPv6 network"),
("InvalidEqualsIgnoreCaseRule", "Properties.EventPattern.detail.name.0.equals-ignore-case", "must be a string"),
("MultipleOperatorsRule", "Properties.EventPattern.detail.name.0", "must contain exactly one operator"),
("EmptyMatcherRule", "Properties.EventPattern.detail.name.0", "must contain exactly one operator"),
("OrWrongTypeRule", "Properties.EventPattern.$or", "must be an array of branch objects"),
("OrInvalidBranchRule", "Properties.EventPattern.$or.0", "must be an object"),
(
"OrReservedFieldRule",
"Properties.EventPattern.$or.0.prefix",
"is reserved and cannot be a field name inside $or",
),
("BareNullFieldRule", "Properties.EventPattern.detail", "must be an array or an object"),
("EmptyObjectRule", "Properties.EventPattern.detail", "must not be an empty object"),
("NestedMatchArrayRule", "Properties.EventPattern.source.0", "must be a scalar value or a matcher object"),
("ParameterWithInvalidSiblingRule", "Properties.EventPattern.detail.state", "is an empty array"),
("NestedScalarArchive", "Properties.EventPattern.detail.a.b.c", "must be an array or an object"),
];
assert_eq!(rego_findings.len(), expected.len(), "one finding per rejected pattern shape: {rego_findings:?}");
assert!(
rego_findings.iter().all(|finding| finding.contains("|Some(SourceSpan")),
"every event pattern finding must carry a source span: {rego_findings:?}"
);
for (resource, property_path, message) in expected {
let identity_prefix = format!("|{resource}|{property_path}|");
assert!(
rego_findings.iter().any(|finding| finding.contains(&identity_prefix) && finding.contains(message)),
"missing {resource} finding: {rego_findings:?}"
);
}
}

#[test]
fn valid_and_unresolved_event_patterns_are_not_reported() {
let template = include_str!("../../resources/templates/good/E9014_valid_event_patterns.yaml");
let (rego, cel) = engines();
let rego_findings = selected_findings(&rego, template, &["E9014"]);
let cel_findings = selected_findings(&cel, template, &["E9014"]);
assert_eq!(rego_findings, cel_findings);
assert!(rego_findings.is_empty(), "every matcher here is accepted by EventBridge: {rego_findings:?}");
}

#[test]
fn conditional_value_constraints_report_under_their_owning_rules() {
let template = include_str!("../../resources/templates/bad/E3717_E3718_E3719_E3723_conditional_constraints.yaml");
Expand Down
2 changes: 1 addition & 1 deletion src/cfn-validate/tests/snapshot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ fn composite_standard_matches_snapshot() {
check_standard("composite", &engine);
}

const EXPECTED_RULES_EVALUATED: u64 = 317;
const EXPECTED_RULES_EVALUATED: u64 = 318;

#[test]
fn rules_evaluated_is_full_rule_count() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package resources

import rego.v1

violation contains make_diag_full("E9014", "ERROR", finding.resourceId,
finding.propertyPath,
finding.message,
finding.suggestedFix,
"") if {
cfn_rule_active("E9014")
some finding in event_pattern_violations()
}
26 changes: 26 additions & 0 deletions src/rego-engine/src/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use template_model::consts::{
FIELD_SOURCE_PATH, FIELD_TARGET, FN_IF, KEY_PROPERTIES, effective_deployed_resource_type,
};
use template_model::dynamodb::analyze_dynamodb_table_scenarios;
use template_model::event_pattern::event_pattern_violations;
use template_model::fargate::{cpu_is_offered, task_size_is_offered};
use template_model::iam_policy::{
inline_identity_policy_document_paths, policy_has_allow_not_action_scenarios, validate_identity_policy_scenarios,
Expand Down Expand Up @@ -60,6 +61,7 @@ pub(crate) fn register_all(
register_subnets_outside_vpc_findings(rego);
register_overlapping_subnet_findings(rego);
register_sqs_event_source_timeout_violations(rego);
register_event_pattern_violations(rego);
register_hardcoded_azs(rego);
register_ref_targets(rego);
register_ref_sources(rego);
Expand Down Expand Up @@ -1079,6 +1081,30 @@ fn register_sqs_event_source_timeout_violations(rego: &mut regorus::Engine) {
);
}

fn register_event_pattern_violations(rego: &mut regorus::Engine) {
let _ = rego.add_extension(
"event_pattern_violations".into(),
0,
Box::new(move |_params: Vec<Value>| {
let Some(model) = current_model() else {
return Ok(Value::from(Vec::<Value>::new()));
};
let violations: Vec<Value> = event_pattern_violations(&model)
.into_iter()
.map(|violation| {
json_to_value(&serde_json::json!({
"resourceId": violation.resource_id,
"propertyPath": violation.property_path,
"message": violation.message,
"suggestedFix": violation.suggested_fix,
}))
})
.collect();
Ok(Value::from(violations))
}),
);
}

/// `subnets_outside_vpc_findings()`: the shared subnet placement analysis, one
/// finding per subnet CIDR that lies outside every IPv4 network of its VPC.
fn register_subnets_outside_vpc_findings(rego: &mut regorus::Engine) {
Expand Down
17 changes: 17 additions & 0 deletions src/rego-engine/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,23 @@ fn e2e_e3061_s3_tiering_bad_days() {
);
}

#[test]
fn e2e_e9014_invalid_event_patterns() {
let report = validate_fixture("bad/E9014_invalid_event_patterns.yaml");
let event_pattern_findings = report.diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "E9014").count();
assert_eq!(
event_pattern_findings, 29,
"Expected one E9014 per rejected pattern shape, got: {:?}",
report.diagnostics
);
}

#[test]
fn e2e_e9014_valid_and_unresolved_event_patterns() {
let report = validate_fixture("good/E9014_valid_event_patterns.yaml");
assert!(!has_rule(&report, "E9014"), "Expected no E9014, got: {:?}", report.diagnostics);
}

#[test]
fn e2e_e3505_sqs_default_visibility_timeout() {
let report = validate_fixture("bad/E3505_sqs_default_visibility_timeout.yaml");
Expand Down
Loading
Loading