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
4 changes: 2 additions & 2 deletions src/CUSTOM_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,9 @@ v := {

| Builtin | Signature | Behavior |
|-----------------------------------|----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `resolve` | `(resource_id, path) -> value` | Deep-resolves a property. Chooses the first enum value and true conditional branch; a `Ref`/`Fn::GetAtt` becomes the marker object `{"__ref": target}` rather than a string, so guard literal checks with `is_string`; unresolved dynamic values are undefined. Use `follow_ref` for the target ID. |
| `resolve` | `(resource_id, path) -> value` | Deep-resolves a property. Chooses the first enum value and true conditional branch; a `Ref`/`Fn::GetAtt` to a template resource becomes that resource's logical ID string (also inside a resolved list); unresolved dynamic values are undefined. A rule that validates literal content should exclude a logical ID with `not input.resources[value]`, or read the reference explicitly with `follow_ref`/`authored_form`. |
| `resolve_preserving_conditionals` | `(resource_id, path) -> value` | Resolves while retaining each conditional as `{"Fn::If": [condition, true_value, false_value]}`. |
| `resolve_all` | `(resource_id, path) -> [value]` | Returns all concrete enum and conditional outcomes. References and dynamic values contribute no values. |
| `resolve_all` | `(resource_id, path) -> [value]` | Returns all concrete enum and conditional outcomes. A property that is itself a reference or dynamic value contributes no values; a reference inside a resolved list is rendered as in `resolve`. |
| `resolve_scenarios` | `(resource_id, path) -> [{value, conditions, path?}]` | Returns values with their condition assignments. A `.{}.` path segment expands array indices and adds the concrete `path`. |
| `properties_scenarios` | `(resource_id, [property_name]) -> [{properties, conditions}]` | Returns satisfiable property scenarios projected to the requested top-level fields; null fields are omitted. |
| `is_dynamic` | `(resource_id, path) -> bool` | Whether the value or a nested value contains a dynamic value or unresolved reference; missing paths return `false`. |
Expand Down
55 changes: 55 additions & 0 deletions src/cfn-validate/tests/cross_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,61 @@ fn custom_rule_list_rules_and_validate_match_between_engines() {
}
}

/// A custom Rego rule reads `resolve()` under the contract it was written
/// against - a `Ref`/`Fn::GetAtt` to a template resource comes back as that
/// resource's logical ID string - while the built-in policies evaluated beside it
/// must keep treating such a reference as a non-literal. The template is the
/// built-in regression fixture for that second half, so a single run proves both
/// halves on every engine that hosts custom Rego.
#[test]
fn custom_rego_resolve_keeps_the_target_logical_id_while_builtins_stay_silent_on_references() {
const TEMPLATE: &str = "good/reference_values_are_not_literals.yaml";
let legacy_reference_rule = ExternalRuleSource {
name: "legacy_reference.rego".into(),
content: r#"
package legacy_reference
import rego.v1

violation contains make_diag("LEGACY_ROLE_TARGET", "warn", name, sprintf("role comes from %s", [target])) if {
some name in resources_of_type("AWS::Lambda::Function")
target := resolve(name, "Properties.Role")
is_string(target)
input.resources[target].resourceType == "AWS::SSM::Parameter"
}
"#
.into(),
};
let rego = RegoEngine::new(EngineConfig {
custom_rules: vec![legacy_reference_rule.clone()],
guard_rules: vec![],
..Default::default()
})
.unwrap();
let composite =
CompositeEngine::new(CompositeEngineConfig::new().with_rego_rules([legacy_reference_rule])).unwrap();
let builtin_baseline: Vec<String> =
validate_template(&*COMPOSITE, TEMPLATE).into_iter().map(|d| d.rule_id).collect();

for (engine_name, diags) in
[("rego", validate_template(&rego, TEMPLATE)), ("composite", validate_template(&composite, TEMPLATE))]
{
let legacy = diags
.iter()
.find(|d| d.rule_id == "LEGACY_ROLE_TARGET")
.unwrap_or_else(|| panic!("[{engine_name}] the custom rule must see the referenced logical ID"));
assert_eq!(legacy.message, "role comes from Store", "[{engine_name}] resolve() yields the target logical ID");
assert_eq!(legacy.resource_logical_id(), Some("Function"), "[{engine_name}] resource_id");
assert_eq!(legacy.source, RuleOrigin::Custom, "[{engine_name}] origin");

let builtins: Vec<String> =
diags.iter().filter(|d| d.source != RuleOrigin::Custom).map(|d| d.rule_id.clone()).collect();
assert_eq!(
builtins, builtin_baseline,
"[{engine_name}] loading a custom rule must not change what the built-in rules report on a reference"
);
}
}

#[test]
fn arbitrary_f_prefixed_custom_id_keeps_declared_severity_in_both_engines() {
// A custom rule ID is arbitrary (here: `Firewall.check-1`, WARN). The built-in
Expand Down
52 changes: 52 additions & 0 deletions src/composite-engine/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,4 +458,56 @@ Resources:
"the composite init metric must span the external engine's construction, not replace it"
);
}

/// Custom Rego rules were written against `resolve` returning the logical ID
/// of a `Ref`/`Fn::GetAtt` target as a string. The external-only engine the
/// composite layers on must honor that contract, so a rule pack that resolves
/// a reference into a resource lookup keeps firing after an engine upgrade.
#[test]
fn custom_rego_resolve_yields_the_referenced_target_logical_id_as_a_string() {
let legacy_reference_rule = ExternalRuleSource {
name: "legacy_reference.rego".into(),
content: r#"
package legacy_reference
import rego.v1

violation contains make_diag("LEGACY_TARGET_LOOKUP", "error", name, sprintf("code bucket is %s", [target])) if {
some name in resources_of_type("AWS::Lambda::Function")
target := resolve(name, "Properties.Code.S3Bucket")
is_string(target)
input.resources[target].resourceType == "AWS::S3::Bucket"
}
"#
.into(),
};
let composite = CompositeEngine::new(CompositeEngineConfig::new().with_rego_rules([legacy_reference_rule]))
.expect("composite builds");
let model = model(
r#"
AWSTemplateFormatVersion: "2010-09-09"
Resources:
ArtifactsBucket:
Type: AWS::S3::Bucket
Handler:
Type: AWS::Lambda::Function
Properties:
Runtime: python3.12
Handler: index.handler
Role: arn:aws:iam::123456789012:role/lambda-role
Code:
S3Bucket: !Ref ArtifactsBucket
S3Key: code.zip
"#,
);

let diags = composite.evaluate_rules(&model, &ValidateConfig::default()).expect("composite evaluates");

let legacy = diags
.iter()
.find(|d| d.rule_id == "LEGACY_TARGET_LOOKUP")
.expect("a custom rule resolving a Ref into a resource lookup must still fire");
assert_eq!(legacy.message, "code bucket is ArtifactsBucket");
assert_eq!(legacy.source, RuleOrigin::Custom);
assert_eq!(legacy.resource_logical_id(), Some("Handler"));
}
}
18 changes: 17 additions & 1 deletion src/rego-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ namespace prefix. For example, a policy calls `resolve(name, "Properties.BucketN

| Builtin | Signature | Purpose |
|----------------------|---------------------------------------------------------------|------------------------------------------------------|
| `resolve` | `(resource_id, path) → value` | Resolve a property value through intrinsic functions; a reference is a `{"__ref": target}` marker, never a bare string |
| `resolve` | `(resource_id, path) → value` | Resolve a property value through intrinsic functions; a reference is rendered per the evaluating package, see below |
| `resolve_all` | `(resource_id, path) → [values]` | Resolve all scenario values for a property |
| `resolve_scenarios` | `(resource_id, path) → [{value, conditions}]` | Resolve all (value, condition_map) pairs |
| `resolve_ref_target` | `(resource_id, path) → {resourceType, condition, properties}` | Resolve the target of a reference |
Expand All @@ -42,6 +42,22 @@ namespace prefix. For example, a policy calls `resolve(name, "Properties.BucketN
| `follow_ref` | `(resource_id, path) → target_id` | Follow a Ref/GetAtt to its target resource |
| `flatten_list` | `(resource_id, path) → [{value, index}]` | Flatten nested arrays |

#### Reference rendering

A `Ref`/`Fn::GetAtt` to a template resource has no literal before deployment, and `resolve` (and a reference inside a
list returned by `resolve_all`) renders it according to the package being evaluated:

- **Handwritten built-in policies** receive the `{"__ref": target}` marker object, the same shape the `input` document
uses. It is never a bare string, so a format or enum check that guards with `is_string` skips the reference instead of
judging a logical ID as if it were the value, while a presence check still sees a value.
- **Custom rules** receive the target's logical ID as a plain string, the contract they were written against; a custom
rule may look the target up in `input.resources` or compare it with another logical ID. Rules that validate literal
content should exclude a logical ID with `not input.resources[value]`, or read the reference explicitly with
`follow_ref` or `authored_form`.

The rendering is selected per package around each `eval_rule` query, so the two kinds evaluate on one engine instance
without observing each other's rendering. Guard rules are not affected: they never evaluate through Rego.

### Resource Queries

| Builtin | Signature | Purpose |
Expand Down
Loading
Loading