use super::patterns::AMI_ID_RE;
use super::resources_extra::{scenario_has_effective_property, scenario_is_reachable};
use super::{EvalContext, NativeRuleRegistry};
use data_source::rule_data::{PathSegment, ResourcePropertyPath};
use diagnostics::Diagnostic;
use rules::Category;
use std::collections::HashSet;
use std::sync::{Arc, LazyLock};
use template_model::SemanticModel;
use template_model::coercion::{coerce_to_bool, coerce_to_integer, coerce_to_string};
use template_model::consts::{
EDGE_KIND_REF, FIELD_KIND, FIELD_OUTGOING_REFS, FIELD_PROPERTIES, FIELD_RESOURCES, FIELD_SOURCE_PATH, FIELD_TARGET,
KEY_DELETION_POLICY, KEY_PROPERTIES, KEY_UPDATE_REPLACE_POLICY, TRANSFORM_SERVERLESS,
effective_deployed_resource_type,
};
use template_model::iam_policy::{inline_identity_policy_document_paths, policy_has_allow_not_action_scenarios};
use template_model::resolver::ResolvedValue;
use validation_engine::make_resource_diagnostic;
static ACCT_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"arn:[^:]*:[^:]*:[^:]*:\d{12}:").expect("Invalid ACCT_RE pattern"));
pub fn register(reg: &mut NativeRuleRegistry) {
reg.add(Category::BestPractice, eval_best_practices);
reg.add(Category::BestPractice, eval_retention_period_rules);
reg.add(Category::BestPractice, eval_deprecated_resource_types);
reg.add(Category::Security, eval_sensitive_port_rules);
}
fn resolve_concrete(m: &SemanticModel, rid: &str, path: &str) -> Option<serde_json::Value> {
let rv = m.resolve_deep(rid, path).or_else(|| m.resolve(rid, path).cloned())?;
match rv {
ResolvedValue::Concrete { value: v } => Some(v.into_inner()),
_ => None,
}
}
fn policy_is_delete(policy: Option<&ResolvedValue>) -> bool {
matches!(policy, Some(ResolvedValue::Concrete { value: v }) if v.as_str() == Some("Delete"))
}
const RDS_INHERITED_OR_IGNORED_ENCRYPTION_FIELDS: [&str; 8] = [
"DBClusterIdentifier",
"DBSnapshotIdentifier",
"DBClusterSnapshotIdentifier",
"SourceDBInstanceIdentifier",
"SourceDbiResourceId",
"SourceDBInstanceAutomatedBackupsArn",
"SourceDBClusterIdentifier",
"DBSecurityGroups",
];
enum ScenarioProperty<'a> {
Absent,
Known(&'a serde_json::Value),
Unknown,
}
impl ScenarioProperty<'_> {
fn is_present(&self) -> bool {
!matches!(self, Self::Absent)
}
}
fn scenario_property<'a>(properties: &'a ResolvedValue, name: &str) -> ScenarioProperty<'a> {
match properties {
ResolvedValue::Concrete { value } => match value.0.get(name) {
None | Some(serde_json::Value::Null) => ScenarioProperty::Absent,
Some(value) => ScenarioProperty::Known(value),
},
ResolvedValue::Map { entries } => {
let Some(value) = entries.iter().find(|entry| entry.key == name).map(|entry| &entry.value) else {
return ScenarioProperty::Absent;
};
match value {
ResolvedValue::Concrete { value } if value.is_null() => ScenarioProperty::Absent,
ResolvedValue::Concrete { value } => ScenarioProperty::Known(&value.0),
_ => ScenarioProperty::Unknown,
}
}
_ => ScenarioProperty::Absent,
}
}
fn rds_encryption_is_inherited_or_ignored(properties: &ResolvedValue) -> bool {
RDS_INHERITED_OR_IGNORED_ENCRYPTION_FIELDS.iter().any(|field| {
let value = scenario_property(properties, field);
if *field == "DBSnapshotIdentifier" {
match value {
ScenarioProperty::Absent => false,
ScenarioProperty::Known(value) if value.as_str() == Some("") => false,
_ => true,
}
} else {
value.is_present()
}
})
}
fn rds_storage_encryption_scenario_warns(properties: &ResolvedValue) -> bool {
if rds_encryption_is_inherited_or_ignored(properties) {
return false;
}
let ScenarioProperty::Known(engine) = scenario_property(properties, "Engine") else {
return false;
};
let Some(engine) = engine.as_str() else {
return false;
};
let engine = engine.to_ascii_lowercase();
if engine.starts_with("aurora") {
return false;
}
match (engine.starts_with("custom-"), scenario_property(properties, "StorageEncrypted")) {
(true, ScenarioProperty::Known(value)) | (false, ScenarioProperty::Known(value)) => {
coerce_to_bool(value) == Some(false)
}
(false, ScenarioProperty::Absent) => true,
(true, ScenarioProperty::Absent) | (_, ScenarioProperty::Unknown) => false,
}
}
fn rds_storage_encryption_warns(model: &SemanticModel, resource_id: &str) -> bool {
model.resolve_properties_scenarios(resource_id).into_iter().any(|(properties, conditions)| {
let assumptions: Vec<(String, bool)> = conditions.into_iter().collect();
(assumptions.is_empty() || model.conditions.is_satisfiable(&assumptions))
&& rds_storage_encryption_scenario_warns(&properties)
})
}
fn eval_best_practices(ctx: &EvalContext) -> Vec<Diagnostic> {
let mut out = Vec::new();
let m = ctx.model;
let stateful_types = &ctx.cached_data.stateful_resource_types;
for (name, res) in &m.resources {
let effective_type = effective_deployed_resource_type(&res.resource_type);
if stateful_types.contains(effective_type) && effective_type != "AWS::S3::Bucket" {
let deletion_present = m.lifecycle_attribute_status(name, KEY_DELETION_POLICY).may_be_present;
let update_replace_present = m.lifecycle_attribute_status(name, KEY_UPDATE_REPLACE_POLICY).may_be_present;
if !deletion_present {
out.push(make_resource_diagnostic("I3011",
"'DeletionPolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)",
m,
name,
"",
None,
));
}
if !update_replace_present {
out.push(make_resource_diagnostic("I3011",
"'UpdateReplacePolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)",
m,
name,
"",
None,
));
}
}
}
for (name, res) in &m.resources {
let deletion_present = m.lifecycle_attribute_status(name, KEY_DELETION_POLICY).may_be_present;
let update_replace_present = m.lifecycle_attribute_status(name, KEY_UPDATE_REPLACE_POLICY).may_be_present;
if deletion_present && !update_replace_present && !policy_is_delete(res.deletion_policy.as_ref()) {
out.push(make_resource_diagnostic(
"W3011",
"Both 'UpdateReplacePolicy' and 'DeletionPolicy' are needed to protect resource from deletion",
m,
name,
"",
None,
));
}
if update_replace_present && !deletion_present && !policy_is_delete(res.update_replace_policy.as_ref()) {
out.push(make_resource_diagnostic(
"W3011",
"Both 'UpdateReplacePolicy' and 'DeletionPolicy' are needed to protect resource from deletion",
m,
name,
"",
None,
));
}
}
let policy_doc_types: &[(&str, &str)] = &[
("AWS::IAM::Policy", "Properties.PolicyDocument"),
("AWS::IAM::ManagedPolicy", "Properties.PolicyDocument"),
("AWS::SQS::QueuePolicy", "Properties.PolicyDocument"),
("AWS::SNS::TopicPolicy", "Properties.PolicyDocument"),
("AWS::S3::BucketPolicy", "Properties.PolicyDocument"),
];
for (rtype, doc_path) in policy_doc_types {
for name in m.resources_of_type(rtype) {
if policy_has_allow_not_action_scenarios(m, name, doc_path) {
out.push(make_resource_diagnostic("W2512",
"IAM policy uses NotAction which grants all actions except those listed - consider using Action instead",
m,
name,
"",
None,
));
}
}
}
for rtype in &["AWS::IAM::Role", "AWS::IAM::User", "AWS::IAM::Group"] {
for name in m.resources_of_type(rtype) {
for doc_path in inline_identity_policy_document_paths(m, name, "Properties.Policies") {
if policy_has_allow_not_action_scenarios(m, name, &doc_path) {
out.push(make_resource_diagnostic("W2512",
"IAM policy uses NotAction which grants all actions except those listed - consider using Action instead",
m,
name,
"",
None,
));
break;
}
}
}
}
for name in m.resources_of_type("AWS::SSO::PermissionSet") {
if policy_has_allow_not_action_scenarios(m, name, "Properties.InlinePolicy") {
out.push(make_resource_diagnostic("W2512",
"IAM policy uses NotAction which grants all actions except those listed - consider using Action instead",
m,
name,
"",
None,
));
}
}
for name in m.resources_of_type("AWS::EC2::Instance") {
let scenarios = m.resolve_scenarios_json(name, "Properties.ImageId");
let mut seen: HashSet<String> = HashSet::new();
for (val, _) in scenarios {
let Some(s) = val.as_str() else {
continue;
};
if !AMI_ID_RE.is_match(s) {
continue;
}
if !seen.insert(s.to_string()) {
continue;
}
out.push(make_resource_diagnostic(
"W9010",
"Hardcoded AMI ID - use a parameter or mapping for portability",
m,
name,
"Properties.ImageId",
None,
));
break;
}
}
for (name, res) in &m.resources {
for (key, val) in &res.properties {
if let ResolvedValue::Concrete { value: v } = val
&& let Some(s) = v.as_str()
&& ACCT_RE.is_match(s)
&& !m.is_from_intrinsic(name, &format!("Properties.{}", key))
{
out.push(make_resource_diagnostic(
"W9013",
"Hardcoded account ID in ARN - use AWS::AccountId pseudo-parameter",
m,
name,
"",
None,
));
break;
}
}
}
let has_serverless = m.transforms.iter().any(|t| t == TRANSFORM_SERVERLESS);
if !has_serverless {
for (name, res) in &m.resources {
for path in &res.diagnostics.hardcoded_partition_arns {
out.push(make_resource_diagnostic(
"I3042",
&format!(
"ARN in Resource {} contains hardcoded Partition in ARN or incorrectly placed Pseudo Parameters",
name
),
m,
name,
path,
None,
));
}
}
}
let password_props = &ctx.cached_data.rule_tables.password_property_names;
let mut ref_to_param_props: HashSet<(String, String)> = HashSet::new();
if let Some(resources) = ctx.input.get(FIELD_RESOURCES).and_then(|r| r.as_object()) {
for (rname, res) in resources {
if let Some(edges) = res.get(FIELD_OUTGOING_REFS).and_then(|r| r.as_array()) {
for edge in edges {
if edge.get(FIELD_KIND).and_then(|k| k.as_str()) != Some(EDGE_KIND_REF) {
continue;
}
let sp = edge.get(FIELD_SOURCE_PATH).and_then(|p| p.as_str()).unwrap_or("");
let target = edge.get(FIELD_TARGET).and_then(|t| t.as_str()).unwrap_or("");
if let Some(suffix) = sp.strip_prefix("Properties.") {
let leaf = suffix.rsplit('.').next().unwrap_or(suffix);
if password_props.iter().any(|p| p == leaf) && m.parameters.contains_key(target) {
ref_to_param_props.insert((rname.clone(), suffix.to_string()));
}
}
}
}
}
}
if let Some(resources) = ctx.input.get(FIELD_RESOURCES).and_then(|r| r.as_object()) {
for (rname, _res) in resources {
let props_json = ctx
.input
.get(FIELD_RESOURCES)
.and_then(|r| r.get(rname.as_str()))
.and_then(|r| r.get(FIELD_PROPERTIES));
let mut found_paths: Vec<String> = Vec::new();
if let Some(props) = props_json {
find_password_property_paths(props, password_props, String::new(), &mut found_paths);
}
for suffix in &found_paths {
let path = format!("Properties.{}", suffix);
let prop_leaf = suffix.rsplit('.').next().unwrap_or(suffix);
if let Some(res) = m.resources.get(rname.as_str()) {
let is_dynamic =
if !suffix.contains('.') { res.properties.get(prop_leaf) } else { m.resolve(rname, &path) };
if let Some(ResolvedValue::Dynamic { reason } | ResolvedValue::TypedDynamic { reason, .. }) =
is_dynamic
{
if reason.contains("{{resolve:")
&& !reason.contains("{{resolve:ssm-secure:")
&& !reason.contains("{{resolve:secretsmanager:")
{
out.push(make_resource_diagnostic(
"W2501",
&format!(
"Password should use a secure dynamic reference for Resources/{}/Properties/{}",
rname,
suffix.replace('.', "/")
),
m,
rname,
&path,
None,
));
}
continue;
}
}
if let Some(scenarios) = m.resolve_scenarios_json(rname, &path).first()
&& let serde_json::Value::String(s) = &scenarios.0
{
let is_secure = s.contains("{{resolve:ssm-secure:") || s.contains("{{resolve:secretsmanager:");
let is_any_dynamic_ref = s.contains("{{resolve:");
if is_secure {
continue;
}
if is_any_dynamic_ref {
out.push(make_resource_diagnostic(
"W2501",
&format!(
"Password should use a secure dynamic reference for Resources/{}/Properties/{}",
rname,
suffix.replace('.', "/")
),
m,
rname,
&path,
None,
));
continue;
}
if ref_to_param_props.contains(&(rname.clone(), suffix.to_string())) {
continue;
}
if !crate::functions::contains_unresolvable_content(&ResolvedValue::Concrete {
value: scenarios.0.clone().into(),
}) {
out.push(make_resource_diagnostic("W2501",
&format!("Property '{}' should not be a hardcoded string - use a parameter with NoEcho or a dynamic reference", prop_leaf),
m, rname, &path, None,
));
}
}
}
}
}
if let Some(resources) = ctx.input.get(FIELD_RESOURCES).and_then(|r| r.as_object()) {
for (_rname, res) in resources {
let Some(edges) = res.get(FIELD_OUTGOING_REFS).and_then(|r| r.as_array()) else {
continue;
};
for edge in edges {
if edge.get(FIELD_KIND).and_then(|k| k.as_str()) != Some(EDGE_KIND_REF) {
continue;
}
let sp = edge.get(FIELD_SOURCE_PATH).and_then(|p| p.as_str()).unwrap_or("");
let suffix = sp.strip_prefix("Properties.").unwrap_or("");
let leaf = suffix.rsplit('.').next().unwrap_or(suffix);
if !password_props.iter().any(|p| p == leaf) {
continue;
}
let Some(target) = edge.get(FIELD_TARGET).and_then(|t| t.as_str()) else {
continue;
};
if let Some(param) = m.parameters.get(target)
&& !param.no_echo
{
out.push(make_resource_diagnostic(
"W2501",
&format!("Parameter {} used as {}, therefore NoEcho should be True", target, leaf),
m,
"",
&format!("Parameters/{}", target),
None,
));
}
}
}
}
let secret_paths = &ctx.cached_data.rule_tables.secret_dynamic_reference_property_paths;
if let Some(resources) = ctx.input.get(FIELD_RESOURCES).and_then(|r| r.as_object()) {
for (rname, res) in resources {
let rtype = res.get(template_model::consts::FIELD_RESOURCE_TYPE).and_then(|t| t.as_str()).unwrap_or("");
let Some(edges) = res.get(FIELD_OUTGOING_REFS).and_then(|r| r.as_array()) else {
continue;
};
for edge in edges {
if edge.get(FIELD_KIND).and_then(|k| k.as_str()) != Some(EDGE_KIND_REF) {
continue;
}
let sp = edge.get(FIELD_SOURCE_PATH).and_then(|p| p.as_str()).unwrap_or("");
let Some(target) = edge.get(FIELD_TARGET).and_then(|t| t.as_str()) else {
continue;
};
if !m.parameters.contains_key(target) {
continue;
}
if !source_path_matches_secret_slot(rtype, sp, secret_paths) {
continue;
}
out.push(make_resource_diagnostic(
"W1011",
&format!(
"Use dynamic references (e.g., SSM SecureString) instead of parameter '{}' for secrets",
target
),
m,
rname,
sp,
None,
));
}
}
}
if let Some(sm) = ctx.cached_data.schema_metadata().get("schema_metadata") {
for (name, res) in &m.resources {
if res.resource_type.ends_with("::MODULE") {
continue;
}
let supports_tags = sm
.get(&res.resource_type)
.and_then(|entry| entry.get("properties"))
.and_then(|p| p.as_array())
.map(|arr| arr.iter().any(|v| v.as_str() == Some("Tags")))
.unwrap_or(false);
let tags_missing = m.resolve_properties_scenarios(name).iter().any(|(properties, conditions)| {
scenario_is_reachable(m, name, conditions) && !scenario_has_effective_property(properties, "Tags")
});
if supports_tags && tags_missing {
out.push(make_resource_diagnostic(
"I9040",
&format!(
"Resource '{}' of type '{}' supports Tags but none are configured",
name, res.resource_type
),
m,
name,
"Properties.Tags",
Some("Add Tags to improve resource organization and cost tracking"),
));
}
}
}
let snapstart_prefixes = &ctx.cached_data.rule_tables.snapstart_recommendation_runtime_prefixes;
let snapstart_excluded = &ctx.cached_data.rule_tables.snapstart_recommendation_excluded_runtimes;
let snapstart_supported_regions = &ctx.cached_data.rule_tables.snapstart_supported_regions;
let region_supported =
ctx.region.as_ref().map(|r| snapstart_supported_regions.iter().any(|sr| sr == r)).unwrap_or(true); if region_supported {
for name in m.resources_of_type("AWS::Lambda::Function") {
if let Some(serde_json::Value::String(rt)) = resolve_concrete(m, name, "Properties.Runtime")
&& snapstart_prefixes.iter().any(|prefix| rt.starts_with(prefix.as_str()))
&& !snapstart_excluded.iter().any(|excl| excl == &rt)
{
let has_snap = resolve_concrete(m, name, "Properties.SnapStart")
.and_then(|v| v.get("ApplyOn").and_then(|a| a.as_str()).map(|s| s.to_string()))
.unwrap_or_default();
if has_snap != "PublishedVersions" {
let mut diag = make_resource_diagnostic(
"I2530",
&format!("Runtime '{}' should consider using SnapStart for improved performance", rt),
m,
name,
"Properties.SnapStart.ApplyOn",
Some("Add SnapStart with ApplyOn set to 'PublishedVersions'"),
);
diag.documentation_url = Some("https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html".into());
out.push(diag);
}
}
}
}
for name in m.resources_of_type("AWS::RDS::DBInstance") {
if rds_storage_encryption_warns(m, name) {
out.push(make_resource_diagnostic(
"W9008",
"RDS instance should have StorageEncrypted set to true",
m,
name,
"Properties.StorageEncrypted",
Some("Set StorageEncrypted to true"),
));
}
}
for name in m.resources_of_type("AWS::RDS::DBInstance") {
if resolve_concrete(m, name, "Properties.PubliclyAccessible").as_ref().and_then(coerce_to_bool) == Some(true) {
out.push(make_resource_diagnostic(
"W9011",
"RDS instance has PubliclyAccessible set to true - consider restricting access",
m,
name,
"Properties.PubliclyAccessible",
Some("Set PubliclyAccessible to false"),
));
}
}
out
}
fn source_path_matches_secret_slot(
resource_type: &str,
source_path: &str,
secret_paths: &[ResourcePropertyPath],
) -> bool {
secret_paths.iter().filter(|p| p.resource_type == resource_type).any(|p| {
let path_segs: Vec<&str> = source_path.split('.').collect();
if path_segs.len() != p.segments.len() {
return false;
}
p.segments.iter().zip(&path_segs).all(|(seg, part)| match seg {
PathSegment::Wildcard => true,
PathSegment::Literal(expected) => expected == *part,
})
})
}
fn eval_retention_period_rules(ctx: &EvalContext) -> Vec<Diagnostic> {
let mut out = Vec::new();
let m = ctx.model;
for (resource_type, required_props) in &ctx.cached_data.retention_period_requirements {
for resource_name in m.resources_of_type(resource_type) {
if resource_type == "AWS::RDS::DBInstance" && !rds_dbinstance_needs_retention(ctx, resource_name) {
continue;
}
let first_missing = required_props.iter().find(|prop| {
let key_present = m
.resources
.get(resource_name.as_str())
.map(|r| r.properties.contains_key(prop.as_str()))
.unwrap_or(false);
let path = format!("Properties.{}", prop);
let scenarios = m.resolve_scenarios_json(resource_name.as_str(), &path);
!key_present || scenarios.is_empty() || scenarios.iter().any(|(val, _)| val.is_null())
});
if let Some(prop) = first_missing {
out.push(make_resource_diagnostic("I3013",
&format!("'{}' is a required property (The default retention period will delete the data after a pre-defined time. Set an explicit values to avoid data loss on resource)", prop),
m,
resource_name,
"Properties",
None,
));
}
}
}
out
}
fn rds_dbinstance_needs_retention(ctx: &EvalContext, name: &str) -> bool {
let Some(props) =
ctx.input.get(FIELD_RESOURCES).and_then(|r| r.get(name)).and_then(|res| res.get(FIELD_PROPERTIES))
else {
return false;
};
let Some(engine) = props.get("Engine").and_then(|e| e.as_str()) else {
return false;
};
!engine.starts_with("aurora") && props.get("SourceDBInstanceIdentifier").is_none()
}
fn eval_deprecated_resource_types(ctx: &EvalContext) -> Vec<Diagnostic> {
let mut out = Vec::new();
for (name, res) in &ctx.model.resources {
if ctx.cached_data.deprecated_resource_types.contains(&res.resource_type) {
out.push(make_resource_diagnostic(
"W9009",
&format!("Resource type '{}' is deprecated - consider using a newer alternative", res.resource_type),
ctx.model,
name,
"",
Some(&format!("Replace {} with a supported alternative", res.resource_type)),
));
}
}
out
}
fn eval_sensitive_port_rules(ctx: &EvalContext) -> Vec<Diagnostic> {
let mut out = Vec::new();
let ports = &ctx.cached_data.sensitive_ports;
let m = ctx.model;
for name in m.resources_of_type("AWS::EC2::SecurityGroup") {
let ingress_path = "Properties.SecurityGroupIngress";
let Some(len) = resolve_array_len(m, name, ingress_path) else {
continue;
};
for idx in 0..len {
check_sg_rule(&mut out, m, name, &format!("{}.{}", ingress_path, idx), ports);
}
}
for name in m.resources_of_type("AWS::EC2::SecurityGroupIngress") {
check_sg_rule(&mut out, m, name, KEY_PROPERTIES, ports);
}
out
}
fn resolve_array_len(m: &Arc<SemanticModel>, name: &str, path: &str) -> Option<usize> {
let rv = m.resolve_deep(name, path).or_else(|| m.resolve(name, path).cloned())?;
match rv {
ResolvedValue::Concrete { value: v } => v.as_array().map(|a| a.len()),
_ => None,
}
}
fn resolve_str(m: &Arc<SemanticModel>, name: &str, path: &str) -> Option<String> {
coerce_to_string(&resolve_concrete(m, name, path)?)
}
fn resolve_i64(m: &Arc<SemanticModel>, name: &str, path: &str) -> Option<i64> {
coerce_to_integer(&resolve_concrete(m, name, path)?)
}
fn check_sg_rule(out: &mut Vec<Diagnostic>, m: &Arc<SemanticModel>, name: &str, rule_path: &str, ports: &[u16]) {
let cidr4 = resolve_str(m, name, &format!("{}.CidrIp", rule_path)).unwrap_or_default();
let cidr6 = resolve_str(m, name, &format!("{}.CidrIpv6", rule_path)).unwrap_or_default();
if cidr4 != "0.0.0.0/0" && cidr6 != "::/0" {
return;
}
let open_cidr = if cidr4 == "0.0.0.0/0" { "0.0.0.0/0" } else { "::/0" };
let diag_path = if rule_path.starts_with("Properties.SecurityGroupIngress") {
"Properties.SecurityGroupIngress"
} else {
KEY_PROPERTIES
};
let proto = resolve_str(m, name, &format!("{}.IpProtocol", rule_path)).unwrap_or_default();
if proto == "-1" {
for port in ports {
out.push(make_resource_diagnostic(
"W2508",
&format!("Security group allows all traffic from {} - sensitive port {} is exposed", open_cidr, port),
m,
name,
diag_path,
Some("Restrict the CIDR range or limit the protocol"),
));
}
return;
}
let Some(from) = resolve_i64(m, name, &format!("{}.FromPort", rule_path)) else {
return;
};
let Some(to) = resolve_i64(m, name, &format!("{}.ToPort", rule_path)) else {
return;
};
for port in ports {
if (*port as i64) >= from && (*port as i64) <= to {
out.push(make_resource_diagnostic(
"W2508",
&format!(
"Security group allows {} access to sensitive port {} (range {}-{})",
open_cidr, port, from, to
),
m,
name,
diag_path,
Some("Restrict the CIDR range to specific IP addresses"),
));
}
}
}
fn find_password_property_paths(
value: &serde_json::Value,
password_names: &[String],
prefix: String,
out: &mut Vec<String>,
) {
match value {
serde_json::Value::Object(map) => {
for (key, child) in map {
let child_path = if prefix.is_empty() { key.clone() } else { format!("{}.{}", prefix, key) };
if password_names.iter().any(|p| p == key) {
out.push(child_path.clone());
}
find_password_property_paths(child, password_names, child_path, out);
}
}
serde_json::Value::Array(arr) => {
for (idx, item) in arr.iter().enumerate() {
let item_path = if prefix.is_empty() { idx.to_string() } else { format!("{}.{}", prefix, idx) };
find_password_property_paths(item, password_names, item_path, out);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_password_paths_flat_property() {
let props = serde_json::json!({
"MasterUserPassword": "secret",
"Engine": "mysql"
});
let names = vec!["MasterUserPassword".to_string(), "Password".to_string()];
let mut paths = Vec::new();
find_password_property_paths(&props, &names, String::new(), &mut paths);
assert_eq!(paths, vec!["MasterUserPassword"]);
}
#[test]
fn find_password_paths_nested_in_object() {
let props = serde_json::json!({
"Config": {
"AccountPassword": "hardcoded"
}
});
let names = vec!["AccountPassword".to_string()];
let mut paths = Vec::new();
find_password_property_paths(&props, &names, String::new(), &mut paths);
assert_eq!(paths, vec!["Config.AccountPassword"]);
}
#[test]
fn find_password_paths_nested_in_array() {
let props = serde_json::json!({
"Items": [
{"AccountPassword": "pw1"},
{"Other": "val"}
]
});
let names = vec!["AccountPassword".to_string()];
let mut paths = Vec::new();
find_password_property_paths(&props, &names, String::new(), &mut paths);
assert_eq!(paths, vec!["Items.0.AccountPassword"]);
}
#[test]
fn find_password_paths_deeply_nested() {
let props = serde_json::json!({
"Level1": {
"Level2": {
"Level3": {
"Password": "deep"
}
}
}
});
let names = vec!["Password".to_string()];
let mut paths = Vec::new();
find_password_property_paths(&props, &names, String::new(), &mut paths);
assert_eq!(paths, vec!["Level1.Level2.Level3.Password"]);
}
#[test]
fn find_password_paths_no_match() {
let props = serde_json::json!({
"Engine": "mysql",
"InstanceType": "db.m5.large"
});
let names = vec!["Password".to_string(), "MasterUserPassword".to_string()];
let mut paths = Vec::new();
find_password_property_paths(&props, &names, String::new(), &mut paths);
assert!(paths.is_empty());
}
}