use crate::consts::{
FN_IF, FN_SUB, INTRINSIC_FN_PATH_SEGMENTS, KEY_TYPE, SECTION_OUTPUTS, SECTION_PARAMETERS, SECTION_RESOURCES,
SSM_SECURE_ALLOWED_PROPERTY_PATHS,
};
use crate::defect::ParseDefect;
use crate::ir::{Arena, Node, NodeRef};
use std::collections::HashMap;
const RULE_DYNAMIC_REFERENCE: &str = "E1050";
const RULE_SSM_SECURE_LOCATION: &str = "E1027";
const RULE_SECRETSMANAGER_LOCATION: &str = "E1051";
const RULE_SSM_LOCATION: &str = "E1052";
const RULE_DYNAMIC_REFERENCE_SPACES: &str = "W1053";
const ALLOWED_SERVICES: &[&str] = &["ssm", "ssm-secure", "secretsmanager"];
pub fn validate_dynamic_references(arena: &Arena, resources: NodeRef) -> Vec<ParseDefect> {
let resource_types = collect_resource_types(arena, resources);
let mut out = Vec::new();
for idx in 0..arena.len() {
let node_ref = idx as NodeRef;
let spanned = arena.get(node_ref);
let Node::String(s) = &spanned.node else {
continue;
};
if !string_is_function_argument(&spanned.path)
&& let Some(message) = dynamic_reference_error(s)
{
out.push(crate::make_parse_defect_at(RULE_DYNAMIC_REFERENCE, message, spanned.span, &spanned.path));
}
if let Some((rule, message)) = dynamic_reference_location_error(s, &spanned.path, &resource_types) {
out.push(crate::make_parse_defect_at(rule, message, spanned.span, &spanned.path));
}
if let Some(message) = dynamic_reference_spaces_warning(s) {
out.push(crate::make_parse_defect_at(RULE_DYNAMIC_REFERENCE_SPACES, message, spanned.span, &spanned.path));
}
}
out
}
fn dynamic_reference_spaces_warning(s: &str) -> Option<String> {
let mut search_from = 0;
while let Some(open_rel) = s[search_from..].find("{{") {
let after_open = &s[search_from + open_rel + 2..];
let trimmed = after_open.trim_start();
if trimmed.len() != after_open.len()
&& let Some(after_resolve) = trimmed.strip_prefix("resolve")
{
let after_colon = after_resolve.trim_start().strip_prefix(':').map(str::trim_start);
if let Some(rest) = after_colon
&& ALLOWED_SERVICES
.iter()
.any(|svc| rest.strip_prefix(svc).map(|tail| tail.trim_start().starts_with(':')).unwrap_or(false))
{
return Some(format!(
"'{}' has spaces and will not be resolved as a dynamic reference. Remove spaces from '{{{{resolve:...}}}}'",
s
));
}
}
search_from += open_rel + 2;
}
None
}
fn collect_resource_types(arena: &Arena, resources: NodeRef) -> HashMap<String, String> {
let mut types = HashMap::new();
if resources == crate::ir::NULL_REF {
return types;
}
if let Some(entries) = arena.as_map(resources) {
for (logical_id, resource_ref) in entries {
if let Some(type_ref) = arena.map_get(*resource_ref, KEY_TYPE)
&& let Some(type_name) = arena.as_str(type_ref)
{
types.insert(logical_id.clone(), type_name.to_string());
}
}
}
types
}
fn string_is_function_argument(path: &str) -> bool {
path.split('/').any(|segment| segment != FN_IF && INTRINSIC_FN_PATH_SEGMENTS.contains(&segment))
}
fn dynamic_reference_location_error(
s: &str,
path: &str,
resource_types: &HashMap<String, String>,
) -> Option<(&'static str, String)> {
if !s.contains("{{resolve:") {
return None;
}
let location = classify_location(path, resource_types)?;
if path
.split('/')
.any(|segment| segment != FN_IF && segment != FN_SUB && INTRINSIC_FN_PATH_SEGMENTS.contains(&segment))
{
return None;
}
if contains_service_ref(s, "ssm-secure") && !location_allows_ssm_secure(&location) {
return Some((
RULE_SSM_SECURE_LOCATION,
format!("Dynamic reference '{}' to SSM secure strings can only be used in resource properties", s),
));
}
if contains_service_ref(s, "secretsmanager") && !location_allows_secretsmanager(&location) {
return Some((
RULE_SECRETSMANAGER_LOCATION,
format!("Dynamic reference '{}' to secrets manager can only be used in resource properties", s),
));
}
if contains_service_ref(s, "ssm") && !location_allows_ssm(&location) {
return Some((RULE_SSM_LOCATION, format!("Dynamic reference '{}' to SSM parameters is not allowed here", s)));
}
None
}
enum DynRefLocation {
ParameterDefaultOrAllowed,
ResourceProperty { normalized: String },
ResourceMetadata,
OutputValue,
OutputExport,
}
fn classify_location(path: &str, resource_types: &HashMap<String, String>) -> Option<DynRefLocation> {
let segments: Vec<&str> = path.split('/').collect();
match (segments.first().copied(), segments.get(2).copied()) {
(Some(s), Some("Default" | "AllowedValues")) if s == SECTION_PARAMETERS => {
Some(DynRefLocation::ParameterDefaultOrAllowed)
}
(Some(s), Some("Properties")) if s == SECTION_RESOURCES => {
let resource_type = resource_types.get(segments[1]).map(String::as_str).unwrap_or("");
let mut normalized = format!("{}/{}/Properties", SECTION_RESOURCES, resource_type);
let mut previous_was_function = false;
for segment in &segments[3..] {
if INTRINSIC_FN_PATH_SEGMENTS.contains(segment) {
previous_was_function = true;
continue;
}
if segment.parse::<usize>().is_ok() {
if !previous_was_function {
normalized.push_str("/*");
}
continue;
}
previous_was_function = false;
normalized.push('/');
normalized.push_str(segment);
}
Some(DynRefLocation::ResourceProperty { normalized })
}
(Some(s), Some("Metadata")) if s == SECTION_RESOURCES => Some(DynRefLocation::ResourceMetadata),
(Some(s), Some("Value")) if s == SECTION_OUTPUTS => Some(DynRefLocation::OutputValue),
(Some(s), Some("Export")) if s == SECTION_OUTPUTS => Some(DynRefLocation::OutputExport),
_ => None,
}
}
fn contains_service_ref(s: &str, service: &str) -> bool {
s.contains(&format!("{{{{resolve:{}:", service))
}
fn location_allows_ssm_secure(location: &DynRefLocation) -> bool {
match location {
DynRefLocation::ResourceProperty { normalized } => {
SSM_SECURE_ALLOWED_PROPERTY_PATHS.contains(&normalized.as_str())
}
_ => false,
}
}
fn location_allows_secretsmanager(location: &DynRefLocation) -> bool {
matches!(location, DynRefLocation::ResourceProperty { .. } | DynRefLocation::ParameterDefaultOrAllowed)
}
fn location_allows_ssm(location: &DynRefLocation) -> bool {
matches!(
location,
DynRefLocation::ParameterDefaultOrAllowed
| DynRefLocation::ResourceProperty { .. }
| DynRefLocation::ResourceMetadata
| DynRefLocation::OutputValue
)
}
fn dynamic_reference_error(s: &str) -> Option<String> {
let payload = extract_last_resolve_payload(s)?;
let parts: Vec<&str> = payload.split(':').collect();
if parts.len() < 3 {
return Some(format!("Dynamic reference '{{{{{}}}}}' must have at least 3 parts", payload));
}
if parts[0] != "resolve" {
return Some(format!("'{}' is not 'resolve' in dynamic reference '{{{{{}}}}}'", parts[0], payload));
}
if !ALLOWED_SERVICES.contains(&parts[1]) {
return Some(format!("'{}' is not one of {:?}", parts[1], ALLOWED_SERVICES));
}
match parts[1] {
"ssm" | "ssm-secure" => ssm_error(&parts, payload),
_ if parts.get(2) == Some(&"arn") => secretsmanager_arn_error(&parts, payload),
_ => secretsmanager_error(&parts, payload),
}
}
fn ssm_error(parts: &[&str], payload: &str) -> Option<String> {
if parts.len() > 4 {
return Some(format!("Dynamic reference '{{{{{}}}}}' has too many parts for '{}'", payload, parts[1]));
}
let name = parts.get(2).copied().unwrap_or("");
if !name.chars().any(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-' | '/')) {
return Some(format!("'{}' does not contain a valid SSM parameter name character", name));
}
if let Some(version) = parts.get(3)
&& (version.is_empty() || !version.chars().all(|c| c.is_ascii_digit()))
{
return Some(format!("'{}' does not match '\\d+'", version));
}
None
}
fn secretsmanager_error(parts: &[&str], payload: &str) -> Option<String> {
if parts.len() > 7 {
return Some(format!("Dynamic reference '{{{{{}}}}}' has too many parts for 'secretsmanager'", payload));
}
if let Some(secret_string) = parts.get(3)
&& !matches!(*secret_string, "SecretString" | "")
{
return Some(format!("'{}' is not one of ['SecretString', '']", secret_string));
}
None
}
fn secretsmanager_arn_error(parts: &[&str], payload: &str) -> Option<String> {
if parts.len() < 9 {
return Some(format!("Dynamic reference ARN '{{{{{}}}}}' must have at least 9 parts", payload));
}
if parts.len() > 13 {
return Some(format!("Dynamic reference ARN '{{{{{}}}}}' has too many parts", payload));
}
if parts[7] != "secret" {
return Some(format!("'{}' was expected to be 'secret'", parts[7]));
}
if let Some(secret_string) = parts.get(9)
&& !matches!(*secret_string, "SecretString" | "")
{
return Some(format!("'{}' is not one of ['SecretString', '']", secret_string));
}
None
}
fn extract_last_resolve_payload(s: &str) -> Option<&str> {
let open = s.rfind("{{")?;
let after_open = &s[open + 2..];
let close_rel = after_open.rfind("}}")?;
let inner = &after_open[..close_rel];
if inner.starts_with("resolve:") { Some(inner) } else { None }
}
#[cfg(test)]
mod tests {
use super::*;
fn err(s: &str) -> Option<String> {
dynamic_reference_error(s)
}
#[test]
fn valid_ssm_forms_are_accepted() {
assert!(err("{{resolve:ssm:/my/param}}").is_none());
assert!(err("{{resolve:ssm:/my/param:1}}").is_none());
assert!(err("{{resolve:ssm-secure:/my/param}}").is_none());
assert!(err("{{resolve:ssm:my param with spaces}}").is_none());
}
#[test]
fn ssm_non_numeric_version_is_rejected() {
assert!(err("{{resolve:ssm:/my/param:notanum}}").is_some());
}
#[test]
fn unknown_service_is_rejected() {
assert!(err("{{resolve:not-a-service:foo}}").is_some());
}
#[test]
fn secretsmanager_bare_forms_are_accepted() {
assert!(err("{{resolve:secretsmanager:my-secret}}").is_none());
assert!(err("{{resolve:secretsmanager:my-secret:SecretString}}").is_none());
assert!(err("{{resolve:secretsmanager:my-secret:SecretString:key:AWSCURRENT:versionid}}").is_none());
assert!(err("{{resolve:secretsmanager:}}").is_none());
}
#[test]
fn secretsmanager_too_many_parts_is_rejected() {
assert!(err("{{resolve:secretsmanager:a:SecretString:k:s:i:extra}}").is_some());
}
#[test]
fn secretsmanager_arn_requires_secret_segment() {
assert!(err("{{resolve:secretsmanager:arn:aws:secretsmanager:us-east-1:123456789012:secret:name}}").is_none());
assert!(err("{{resolve:secretsmanager:arn:aws:s3:us-east-1:123456789012:notsecret:name}}").is_some());
}
#[test]
fn only_the_last_reference_is_validated() {
assert!(err("{{resolve:ssm:p:notanum}}-{{resolve:ssm:goodparam:1}}").is_none());
assert!(err("{{resolve:ssm:goodparam:1}}-{{resolve:ssm:p:notanum}}").is_some());
}
#[test]
fn function_argument_paths_are_skipped() {
assert!(string_is_function_argument("Resources/R/Properties/X/Fn::Sub"));
assert!(string_is_function_argument("Resources/R/Properties/X/Fn::Join/1/0"));
assert!(!string_is_function_argument("Resources/R/Properties/X"));
assert!(!string_is_function_argument("Resources/R/Properties/X/Fn::If/1"));
}
#[test]
fn plain_string_without_reference_is_ignored() {
assert!(err("just a normal string").is_none());
assert!(err("").is_none());
}
#[test]
fn spaced_dynamic_reference_is_warned() {
assert!(dynamic_reference_spaces_warning("{{ resolve:ssm:/my/param }}").is_some());
assert!(dynamic_reference_spaces_warning("{{ resolve : secretsmanager : secret}}").is_some());
assert!(dynamic_reference_spaces_warning("prefix {{ resolve:ssm-secure:/p}} suffix").is_some());
assert!(dynamic_reference_spaces_warning("{{resolve:ssm:/my/param}}").is_none());
assert!(dynamic_reference_spaces_warning("{{ mustache template }}").is_none());
assert!(dynamic_reference_spaces_warning("{{ resolve:unknown:thing}}").is_none());
}
#[test]
fn spaced_dynamic_reference_fires_w1053_from_model() {
let ids = model_rule_ids(
"Resources:\n B:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: \"{{ resolve:ssm:/p }}\"\n",
);
assert!(ids.contains(&"W1053".to_string()), "spaced reference must warn: {:?}", ids);
}
fn model_rule_ids(yaml: &str) -> Vec<String> {
let model = crate::SemanticModel::from_bytes(yaml.as_bytes()).expect("model builds");
let mut ids: Vec<String> = model.diagnostics.iter().map(|d| d.rule_id.clone()).collect();
ids.sort();
ids.dedup();
ids
}
#[test]
fn validate_over_model_reports_e1050() {
let ids = model_rule_ids(
"Resources:\n R:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: \"{{resolve:not-a-service:foo}}\"\n",
);
assert!(ids.contains(&"E1050".to_string()));
}
#[test]
fn fn_named_map_key_is_not_a_function_wrapper() {
let ids = model_rule_ids(
"Resources:\n L:\n Type: AWS::S3::Bucket\n Metadata:\n Vars:\n \"Fn::Custom\": \"{{resolve:ssm:/p:notanum}}\"\n",
);
assert!(ids.contains(&"E1050".to_string()), "Fn::-named data key must not suppress the check: {:?}", ids);
}
#[test]
fn ssm_secure_location_rule_matches_reference_allowlist() {
let ids = model_rule_ids(
"Resources:\n B:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: \"{{resolve:ssm-secure:/sec}}\"\n",
);
assert!(ids.contains(&"E1027".to_string()), "ssm-secure in a non-allowlisted property: {:?}", ids);
let ids = model_rule_ids(
"Resources:\n D:\n Type: AWS::RDS::DBInstance\n Properties:\n MasterUserPassword: \"{{resolve:ssm-secure:/sec}}\"\n",
);
assert!(!ids.contains(&"E1027".to_string()), "allowlisted property must not fire: {:?}", ids);
let ids = model_rule_ids(
"Parameters:\n P:\n Type: String\n Default: \"{{resolve:ssm-secure:/sec}}\"\nResources:\n B:\n Type: AWS::S3::Bucket\n",
);
assert!(ids.contains(&"E1027".to_string()), "ssm-secure in parameter default: {:?}", ids);
}
#[test]
fn secretsmanager_location_rule_allows_properties_and_defaults() {
let ids = model_rule_ids(
"Resources:\n B:\n Type: AWS::S3::Bucket\n Properties:\n BucketName: \"{{resolve:secretsmanager:s}}\"\n",
);
assert!(!ids.contains(&"E1051".to_string()), "secretsmanager in a property is allowed: {:?}", ids);
let ids = model_rule_ids(
"Parameters:\n Q:\n Type: String\n Default: \"{{resolve:secretsmanager:s}}\"\nResources:\n B:\n Type: AWS::S3::Bucket\n",
);
assert!(!ids.contains(&"E1051".to_string()), "secretsmanager in a parameter default is allowed: {:?}", ids);
let ids = model_rule_ids(
"Resources:\n B:\n Type: AWS::S3::Bucket\nOutputs:\n O:\n Value: \"{{resolve:secretsmanager:s}}\"\n",
);
assert!(ids.contains(&"E1051".to_string()), "secretsmanager in an output value must fire: {:?}", ids);
}
#[test]
fn ssm_location_rule_allows_output_values_and_defaults() {
let ids = model_rule_ids(
"Resources:\n B:\n Type: AWS::S3::Bucket\nOutputs:\n O:\n Value: \"{{resolve:ssm:/p}}\"\n",
);
assert!(!ids.contains(&"E1052".to_string()), "ssm in an output value is allowed: {:?}", ids);
let ids = model_rule_ids(
"Parameters:\n P:\n Type: String\nConditions:\n C: !Equals [\"{{resolve:ssm:/p}}\", x]\nResources:\n B:\n Type: AWS::S3::Bucket\n Condition: C\n",
);
assert!(!ids.contains(&"E1052".to_string()), "Conditions are not walked: {:?}", ids);
let ids = model_rule_ids(
"Resources:\n B:\n Type: AWS::S3::Bucket\nOutputs:\n O:\n Value: x\n Export:\n Name: \"{{resolve:ssm:/p}}\"\n",
);
assert!(ids.contains(&"E1052".to_string()), "ssm in an export name must fire: {:?}", ids);
}
}