diff --git a/scripts/audit_rule_categorization.py b/scripts/audit_rule_categorization.py index 00df247..73b7c19 100755 --- a/scripts/audit_rule_categorization.py +++ b/scripts/audit_rule_categorization.py @@ -45,7 +45,7 @@ ALLOWED_CATS = { "Fatal": {"Structure", "Schema", "Intrinsic", - "Parameter", "Reference", "Parse"}, + "Parameter", "Reference"}, "Warn": {"Security", "Deprecation", "BestPractice"}, "Info": {"BestPractice", "Deprecation", "Structure", "Intrinsic", "Security"}, @@ -86,6 +86,61 @@ r'shortdesc\s*=\s*\(?\s*((?:"[^"]*"\s*)+)\)?', re.DOTALL ) +# Rules whose IDs collide with cfn-lint by ID number but are INTENTIONALLY +# classified as structural (Schema or Engine origin) in our registry rather +# than CfnLint. The project policy is: +# +# "If an intrinsic or condition is structurally invalid — wrong arguments, +# wrong shape, undefined reference, etc. — and it is guaranteed to cause +# a CloudFormation deployment failure, it is a STRUCTURAL rule, not a +# cfn-lint-sourced rule, even when cfn-lint happens to share the ID." +# +# These rules keep cfn-lint's ID for cross-tool comparison (a user grepping +# for E8003 finds the same concern in both tools) but the origin reflects +# that the failure mode is a deployment-blocker, not a semantic warning. +# The audit short-circuits the "ID match → CfnLint origin" rule for these. +# +# The dict value is the human-readable rationale shown in the report — it +# also serves as the single source of truth so the report and the +# classification logic cannot drift. +STRUCTURAL_OVERRIDES = { + # Boolean-condition shape rules — wrong arity rejects the template. + "E8001": "Conditions section structure must be valid", + "E8002": "Reference to undefined Condition rejects template at deploy", + "E8003": "Fn::Equals must take exactly 2 operands", + "E8004": "Fn::And must take 2-10 operands", + "E8005": "Fn::Not must take exactly 1 operand", + "E8006": "Fn::Or must take 2-10 operands", + "E8007": "Condition function value shape required by CFN", + # Intrinsic-shape and existence rules — invalid input crashes the deploy. + "E1011": "Fn::FindInMap operand types — wrong type (e.g. list as map key) is rejected at deploy", + "E1017": "Fn::Select requires exactly two operands and a list source", + "E1018": "Fn::Split source must be a string-producing intrinsic", + "E1019": "Fn::Sub variable map values must be string-producing", + "E1021": "Fn::Base64 argument must be a string-producing intrinsic", + "E1022": "Fn::Join requires a string delimiter and a list of string-producing operands", + "E1024": "Fn::Cidr requires a CIDR-format ipBlock and integer count/cidrBits", + "E1028": "ID collision only — cfn-lint E1028 checks Fn::If operand shape; ours checks condition reference resolution", + "E1029": "Substitution variable requires Fn::Sub — cfn-lint states the deployment fails", + "E1030": "Fn::Length: transform-missing AND parameter shape (matches cfn-lint E1030)", + "E1031": "Fn::ToJsonString: transform-missing AND parameter shape (matches cfn-lint E1031)", + "E1032": "Fn::ForEach requires AWS::LanguageExtensions transform (matches cfn-lint E1032 transform_pre expansion)", + "E1033": "Fn::GetStackOutput parameter shape AND AWS::LanguageExtensions transform requirement", + "E1050": "Dynamic reference structure must match per-flavor format", + # E1101 number collides with cfn-lint's generic JSON-schema meta-rule; + # ours implements a structural intrinsic-nesting check that is unrelated. + "E1101": "ID collision only — different rule from cfn-lint E1101", +} + +# Subset of STRUCTURAL_OVERRIDES whose ID collides with cfn-lint but whose +# cfn-lint counterpart never emits user-visible findings. Our rule has no +# matching cfn-lint counterpart for cross-tool comparison, so it must be +# treated as engine-extra even though the ID exists in cfn-lint's source. +STRUCTURAL_OVERRIDES_PURE_COLLISION = frozenset({ + "E1101", # cfn-lint E1101 is an internal meta-rule, never directly emitted + "E1028", # cfn-lint E1028 checks Fn::If operand shape; ours checks condition existence +}) + RuleOrigins = namedtuple("RuleOrigins", [ "registry", # list of (id, severity, category, reg_origin, description) "cfnlint_ids", # {id: (shortdesc, filename)} from cfn-lint source @@ -162,7 +217,20 @@ def compute_rule_origins(cfnlint_root: Path) -> RuleOrigins: prefix = rid[0] num = rid[1:] - if rid in cfnlint_ids: + if rid in STRUCTURAL_OVERRIDES: + # Project policy: a structurally invalid intrinsic or condition + # that guarantees a deployment failure is classified as Schema + # (or Engine, when our implementation diverges from cfn-lint's + # rule semantics entirely). The ID collision with cfn-lint is + # preserved for cross-tool searchability only. + if reg_origin in ("Schema", "Engine"): + true_origins[rid] = reg_origin + else: + true_origins[rid] = "Schema" + origin_issues.append((rid, reg_origin, "Schema", + "structural-failure rule (deployment will fail) must use Schema or Engine origin per project policy")) + + elif rid in cfnlint_ids: # Exact ID match → this rule comes from cfn-lint true_origins[rid] = "CfnLint" if reg_origin != "CfnLint": @@ -206,6 +274,34 @@ def compute_rule_origins(cfnlint_root: Path) -> RuleOrigins: # rule_aliases set is computed below. cfnlint_to_engine["E0001"] = "E0001" + # Rename mappings: cfn-lint uses these IDs for specific checks; our engine + # emits under a different ID because the ID was either collision-prone or + # used generically for multiple checks. + cfnlint_to_engine["E1010"] = "E9004" # cfn-lint E1010 GetAtt; engine split E9004+E9003 + cfnlint_to_engine["E3690"] = "E9006" # cfn-lint E3690 DB Cluster; engine generic extension-enum + + # cfn-lint rules whose checks overlap with our Fatal rules under different + # IDs. The numeric suffixes differ so the auto-mapping can't detect them. + # Each mapping was verified by running cfn-validate on the affected templates + # and confirming our Fatal rule fires on the same resource for the same issue. + # Cases where cfn-lint checks MORE than our Fatal rule (e.g. non-string type + # validation) remain as legitimate FNs — the alias only matches what we emit. + _cross_id_mappings = { + "E3035": "F3016", # DeletionPolicy enum values → our schema DeletionPolicy check + "E3036": "F0018", # UpdateReplacePolicy enum values → our schema UpdateReplacePolicy check + "E3015": "E8002", # Resource condition must exist → our condition-ref check + "E1019": "F1018", # Sub variable resolution → our Sub variable check + "E1028": "F0013", # Fn::If structure (count) → our Fn::If element count check + "E3006": "E9001", # Resource type must exist → our E9001 unknown resource type check + "E1022": "F1020", # GetAtt resource must exist (incl. GetAtt embedded in Fn::Join) → our Ref/GetAtt target check + } + for e_id, f_id in _cross_id_mappings.items(): + cfnlint_to_engine[e_id] = f_id + + # Freeze the reverse mapping AFTER all cfnlint_to_engine mutations are applied. + # engine_to_cfnlint must reflect every mapping the comparison layer will use, + # otherwise engine_extra membership drifts (e.g. cross-id Fatals incorrectly + # treated as engine-only when they actually pair with a cfn-lint rule). engine_to_cfnlint = {v: k for k, v in cfnlint_to_engine.items()} # ── Engine-extra set ───────────────────────────────────────────────── @@ -219,8 +315,24 @@ def compute_rule_origins(cfnlint_root: Path) -> RuleOrigins: # SAT solving, format checks on resolved values) and produces findings # cfn-lint misses. These rules participate in parity matching for # findings cfn-lint DOES emit, but unmatched extras are not false positives. + # + # STRUCTURAL_OVERRIDES rules whose ID is also defined in cfn-lint are NOT + # engine-extra — cfn-lint emits findings under the same ID for the same + # concern, so the comparison still pairs them. The override changes the + # *origin* tag (to reflect deployment-blocker semantics) but not the + # cross-tool finding compatibility. STRUCTURAL_OVERRIDES_PURE_COLLISION + # holds the exception: rules where the cfn-lint counterpart is internal + # only and never emits user-visible findings, so our rule is genuinely + # engine-extra despite the shared ID. engine_extra = set() for rid, true_o in true_origins.items(): + cfnlint_emits_same_id = ( + rid in cfnlint_ids + and rid in STRUCTURAL_OVERRIDES + and rid not in STRUCTURAL_OVERRIDES_PURE_COLLISION + ) + if cfnlint_emits_same_id: + continue if true_o in ("Engine", "Engine(collision)"): engine_extra.add(rid) elif true_o == "Schema" and rid not in engine_to_cfnlint: @@ -233,8 +345,10 @@ def compute_rule_origins(cfnlint_root: Path) -> RuleOrigins: # These are identified by category of deeper analysis our engine performs: # # Condition analysis — our SAT solver finds unreachable Fn::If branches - # and conditional resource references that cfn-lint doesn't analyze: - ENGINE_STRICTER_CONDITION = {"W1028", "W8001"} + # that cfn-lint doesn't analyze. (W8001 was removed: its transitive-usage + # logic was aligned with cfn-lint's conditions/Used.py, so it is no longer + # stricter than cfn-lint.) + ENGINE_STRICTER_CONDITION = {"W1028"} # # Cross-resource / resolved-value checks — our engine resolves Ref/GetAtt # chains, parameter defaults, and Fn::Sub to concrete values, then runs @@ -269,7 +383,6 @@ def compute_rule_origins(cfnlint_root: Path) -> RuleOrigins: ENGINE_STRICTER_STRUCTURAL = { "W1001", # Conditional reference (engine fires more broadly than cfn-lint) "W2001", # Unused parameter (engine tracks Fn::Sub variable usage) - "W9002", # Hardcoded ARN (resolved through Fn::Sub) "W3005", # Obsolete DependsOn (engine resolves implicit deps) "W3011", # Both UpdateReplacePolicy and DeletionPolicy needed "W7001", # Unused mapping (engine tracks FindInMap usage) @@ -281,10 +394,25 @@ def compute_rule_origins(cfnlint_root: Path) -> RuleOrigins: # processes differently: ENGINE_STRICTER_SCHEMA = { "F0001", # Resources section structural check (empty/missing Resources) + "F0013", # Fn::If element count — engine catches arity violations cfn-lint reports under different paths "F3003", # Required property missing (cfn-lint may use specific conditional rules, e.g. E3639/E3676) "F3012", # Type mismatch (post-SAM-transform property-level) "F3034", # Numeric range (cross-resource: FIFO queue → EventSourceMapping) "F6101", # Output value type (GetAtt return type in outputs) + # Per-function operand-shape rules in template-model/src/intrinsic_arg_shapes.rs + # enforce CloudFormation's compiled per-function schemas. cfn-lint emits + # the same IDs but on a narrower set of triggers; our broader operand + # validation produces additional findings on the same bad templates. + "E1011", # Fn::FindInMap operand types (extra triggers via intrinsic-shape pass) + "E1016", # Fn::ImportValue argument shape (engine catches non-string and nested-ImportValue) + "E1017", # Fn::Select arity / list-source / index type (broader than cfn-lint's variant) + "E1018", # Fn::Split source must be a string-producing intrinsic + "E1019", # Fn::Sub variable map shape + "E1021", # Fn::Base64 argument must be string-producing + "E1022", # Fn::Join delimiter and list-element shapes + "E1024", # Fn::Cidr scalar operand types + "E1032", # Fn::ForEach detection at section level (Resources/Outputs) + "E8003", # Fn::Equals operand-type checks (null/Ref folded into E8003) } engine_extra |= ENGINE_STRICTER_CONDITION @@ -323,35 +451,24 @@ def compute_rule_origins(cfnlint_root: Path) -> RuleOrigins: if "F1010" in rule_aliases: rule_aliases["F1010"].update({"E9004", "E9003"}) - # Rename aliases: cfn-lint uses these IDs for specific checks; our engine - # emits under a different ID because the ID was either collision-prone or - # used generically for multiple checks. - cfnlint_to_engine["E1010"] = "E9004" # cfn-lint E1010 GetAtt; engine split E9004+E9003 - cfnlint_to_engine["E3690"] = "E9006" # cfn-lint E3690 DB Cluster; engine generic extension-enum - # Add reverse-lookup alias keyed by engine ID so compare_cfnlint matches - # both directions via _alias_keys: + # Rename aliases: the corresponding cfnlint_to_engine mutations were + # applied earlier so engine_to_cfnlint reflects them. Here we add the + # reverse-lookup aliases (engine ID → cfn-lint ID) that compare_cfnlint + # uses via _alias_keys. rule_aliases.setdefault("E9006", set()).add("E3690") rule_aliases.setdefault("E9003", set()).add("E1015") - # cfn-lint rules whose checks overlap with our Fatal rules under different - # IDs. The numeric suffixes differ so the auto-mapping can't detect them. - # Each mapping was verified by running cfn-validate on the affected templates - # and confirming our Fatal rule fires on the same resource for the same issue. - # Cases where cfn-lint checks MORE than our Fatal rule (e.g. non-string type - # validation) remain as legitimate FNs — the alias only matches what we emit. - _cross_id_mappings = { - "E3035": "F3016", # DeletionPolicy enum values → our schema DeletionPolicy check - "E3036": "F0018", # UpdateReplacePolicy enum values → our schema UpdateReplacePolicy check - "E3015": "F8002", # Resource condition must exist → our condition-ref check - "E1019": "F1018", # Sub variable resolution → our Sub variable check - "E8003": "F0014", # Fn::Equals structure (count) → our Fn::Equals element count check - "E8004": "F0014", # Fn::And structure (count) → our Fn::And element count check - "E1028": "F0013", # Fn::If structure (count) → our Fn::If element count check - "E3006": "E9001", # Resource type must exist → our E9001 unknown resource type check - "E1022": "F1020", # GetAtt resource must exist (incl. GetAtt embedded in Fn::Join) → our Ref/GetAtt target check + # Add rule_aliases entries for every cross-id mapping that was applied + # earlier (these were already mutated into cfnlint_to_engine before the + # freeze; the auto-loop above already populated rule_aliases[f_id] from + # cfnlint_to_engine.items(), but compare_cfnlint also looks up the + # cfn-lint ID directly so we explicitly seed rule_aliases[e_id] here). + _cross_id_mapping_for_aliases = { + "E3035": "F3016", "E3036": "F0018", "E3015": "E8002", + "E1019": "F1018", "E1028": "F0013", "E3006": "E9001", + "E1022": "F1020", } - for e_id, f_id in _cross_id_mappings.items(): - cfnlint_to_engine[e_id] = f_id + for e_id, f_id in _cross_id_mapping_for_aliases.items(): rule_aliases.setdefault(f_id, {f_id}).add(e_id) # E3001 sub-checks: cfn-lint groups multiple checks under E3001 that our @@ -359,6 +476,29 @@ def compute_rule_origins(cfnlint_root: Path) -> RuleOrigins: # cfn-lint ID (E3001) so _alias_keys can find engine IDs when matching. rule_aliases.setdefault("E3001", {"E3001"}).update({"F0006", "E5001", "F6004"}) + # Condition boolean rules: cfn-lint reports an undefined Condition reference + # nested inside Fn::And/Or/Not under the enclosing rule's ID (E8004/E8005/ + # E8006, "'X' is not one of [...]"), whereas our condition_shape check + # pinpoints it as E8007 ("Condition 'X' is not defined"). Same finding, more + # precise ID — so a cfn-lint E8004/E8005/E8006 can match our E8007. + for enclosing in ("E8004", "E8005", "E8006"): + rule_aliases.setdefault(enclosing, {enclosing}).add("E8007") + + # E1017 (Select) covers all sub-cases: arity, source-type, index-type, + # and negative index. The parser/cel-engine emit them all under E1017 + # directly so no aliasing is needed. + + # cfn-lint E1011 (FindInMap) operand-intrinsic checks surface in our engine + # as the intrinsic-nesting rule E1101. + rule_aliases.setdefault("E1011", {"E1011"}).add("E1101") + + # cfn-lint splits malformed `Fn::Equals` operands into per-case rule IDs: + # `E1001` when the value is null or not an array, `E1020` when an operand + # is a Ref to a non-string. Our engine reports all malformed-Equals cases + # uniformly under `E8003` (the structural shape ID). Alias the cfn-lint + # sub-cases to E8003 so cross-tool comparison pairs them correctly. + rule_aliases.setdefault("E1020", {"E1020"}).add("E8003") + # E1001 sub-checks: cfn-lint's E1001 (BaseJsonSchema) is a parent rule that # validates the template against a JSON schema. It delegates to child rules # but also reports directly for top-level structural issues. Our engine emits @@ -366,11 +506,12 @@ def compute_rule_origins(cfnlint_root: Path) -> RuleOrigins: # - missing Resources (cfn-lint path []) -> F0001 # - bad AWSTemplateFormatVersion (cfn-lint path ['AWSTemplateFormatVersion']) -> F0002 # - invalid top-level section -> F0005 + # - null/non-array Fn::Equals value -> E8003 (structural shape) # F0002 carries the 'AWSTemplateFormatVersion' property path so it matches the # format-version E1001 by exact path, leaving F0001 (empty Resources, which # cfn-lint does not flag) correctly classified as engine-extra rather than # greedily consuming the format-version E1001. - rule_aliases.setdefault("E1001", {"E1001"}).update({"F0001", "F0002", "F0005"}) + rule_aliases.setdefault("E1001", {"E1001"}).update({"F0001", "F0002", "F0005", "E8003"}) # Message-based engine-extra predicate: diagnostics that are engine-extra # based on message content, not just rule ID. These cover cases where the @@ -522,7 +663,33 @@ def _check_dual_use(registry_map, cel_emissions): Only checks CEL (Rust) because messages are human-readable strings. Rego messages are often property paths, not suitable for similarity. + + Rules in LEGITIMATE_COMPOUND_RULES are known to cover multiple related + sub-checks under one rule ID — the registry description is the umbrella, + and each emission has its own specific message. Inspection has confirmed + that splitting these into separate rule IDs would not improve clarity. """ + # Compound rules whose registry description is intentionally an umbrella + # that covers several sub-checks. Each entry has been individually + # inspected; the cluster differences are sub-cases of one logical concern, + # not unrelated rules sharing an ID. + LEGITIMATE_COMPOUND_RULES = { + "E1005", # Transform configuration: shape + Name property + "E1150", # Security group format: parser check + schema-extension check + "E3005", # DependsOn: target-not-exists + condition-false + "E3013", # CloudFront Aliases: wildcard position + domain validity + "E3023", # Route53 RecordSet: per record-type validators (A, CNAME, TXT, CAA) + "E3027", # ScheduleExpression: rate() and cron() formats + "E3029", # Route53 alias: TTL incompat + record-type compat + "E3701", # CodePipeline artifacts: input ref + output uniqueness + "F0050", # Mapping: 3-level shape + size limit + "F1020", # Ref/GetAtt target: Ref-target + GetAtt-target + "F2015", # Parameter Default: pattern + length + value range + "W1020", # Sub simplification: no-vars + single-var + "W1030", # Resolved Ref values: per-target-type format checks + "W2501", # Password properties: dynamic-ref + hardcoded + NoEcho + } + by_rule = defaultdict(list) for rid, msg, path, line in cel_emissions: if msg: @@ -532,6 +699,8 @@ def _check_dual_use(registry_map, cel_emissions): for rid, entries in sorted(by_rule.items()): if len(entries) < 2: continue + if rid in LEGITIMATE_COMPOUND_RULES: + continue clusters = [] for msg, path, line in entries: tokens = tokenize(msg) @@ -596,6 +765,20 @@ def build_report(origins: RuleOrigins) -> str: w("True origin is computed by checking cfn-lint source, not the registry's") w("`origin:` field. Mismatches indicate the registry needs updating.") w("") + w("**Project policy — structural-failure rules are not cfn-lint-sourced.**") + w("A rule that detects structurally invalid input — wrong intrinsic arity,") + w("wrong shape, undefined reference — is classified as `Schema` (or") + w("`Engine`) because the failure mode is a guaranteed deployment block,") + w("not a semantic warning. The cfn-lint ID is preserved for cross-tool") + w("searchability. The rules below are intentionally classified this way:") + w("") + w("| ID | Description | Why structural |") + w("|----|-------------|---------------|") + registry_descriptions = {r[0]: r[4] for r in our} + for rid, reason in sorted(STRUCTURAL_OVERRIDES.items()): + desc = registry_descriptions.get(rid, "(unregistered)") + w(f"| `{rid}` | {desc} | {reason} |") + w("") if origins.origin_issues: w(f"**{len(origins.origin_issues)} issue(s) found.**") w("") @@ -722,7 +905,7 @@ def build_report(origins: RuleOrigins) -> str: r = reg_map.get(rid) if not r: continue - reason = "condition analysis" if rid in {"W1028", "W8001"} \ + reason = "condition analysis" if rid in {"W1028"} \ else "resolved-value check" if rid in {"E1040","E1150","E1151","E1152","E1154","W1030","W3010","E2533"} \ else "best-practice on conditional resources" if rid[0] == "I" \ else "deeper dependency/usage analysis" if rid in {"W2001","W9002","W3005","W3011","W7001"} \ @@ -751,18 +934,13 @@ def build_report(origins: RuleOrigins) -> str: # flags, producing an equivalent diagnostic under a different ID. "E1001": ("F0002/F0005", "Base template JSON schema (top-level structure)"), "E1003": ("F0011", "description max length 1024"), - "E1011": ("F1012/F1101", "FindInMap structural validation (template-model parser)"), - "E1017": ("F1050/F1101", "Select structural validation (template-model parser)"), - "E1019": ("F1018", "Sub variable resolution"), - "E1021": ("F1101", "Base64 structural validation (template-model parser)"), - "E1022": ("F1101", "Join structural validation (template-model parser)"), "E1028": ("F0013", "Fn::If must have 3 elements"), "E1700": ("F8600", "Rules section config"), "E1701": ("F8603", "Rule Assertions required"), "E1702": ("F8606", "Rule RuleCondition validation"), "E2010": ("F0003", "Parameter limit 200"), "E3006": ("E9001", "Resource type must be recognized"), - "E3015": ("F8002", "Condition reference on resource"), + "E3015": ("E8002", "Condition reference on resource"), # E3008: prefixItems array validation — handled by schema-validator # through compiled JSON Schema (prefixItems is a standard JSON Schema keyword). "E3008": ("schema-patch", "Array prefixItems validation (compiled schema)"), @@ -776,29 +954,28 @@ def build_report(origins: RuleOrigins) -> str: "E6010": ("F0004", "Output limit 200"), "E6102": ("F6005/F6101", "Output Export validation"), "E7010": ("F0008", "Mappings limit 200"), - "E8004": ("F0014", "Fn::And structure"), - "E8005": ("F0014", "Fn::Not structure"), - "E8006": ("F0014", "Fn::Or structure"), - "E8007": ("F8002", "Condition reference validation"), + "E8007": ("E8002", "Condition reference validation"), # Info approaching-limits rules — covered by I-prefix equivalents: "I1002": ("I2010/I6010", "approaching template size (via parameter/output limit warns)"), "I3010": ("I2010", "resource limit approach"), # Intrinsic resolved-value rules — our engine does resolution during # SemanticModel build; resolved-value errors surface via schema rules: - "W1019": ("F1018/F1029", "Fn::Sub parameter usage"), + "W1019": ("F1018/W1056", "Fn::Sub parameter usage"), "W1031": ("F3012+W9003", "Fn::Sub resolved values (via resolver)"), "W1032": ("F3012+W9003", "Fn::Join resolved values"), "W1033": ("F3012+W9003", "Fn::Split resolved values"), + "W1034": ("F3012+W9003", "Fn::FindInMap resolved values"), "W1035": ("F3012+W9003", "Fn::Select resolved values"), + "W1036": ("F3012+W9003", "Fn::GetAZs resolved values"), "W1040": ("F3012+W9003", "Fn::ToJsonString resolved values"), "W2030": ("F2015", "Parameter Default enum check"), "W2031": ("F3031", "Parameter AllowedPattern check"), "W3034": ("E3034/F3034", "Parameter value numeric range"), "W6001": ("out-of-scope", "Output ImportValue usage (cfn-lint checks cross-stack references)"), - # Intrinsic function structural validation — template-model validates - # these during parsing and emits F1101 (structural error) or W1102 - # (type error) instead of the cfn-lint rule IDs: - "E1024": ("F1101/W1102", "Cidr validation (template-model parser)"), + # E1018, E1019, E1021, E1022, E1024 are now implemented natively in + # template-model/src/intrinsic_arg_shapes.rs. The narrow engine-only + # subsets (E1058 Fn::Split delimiter dynref, E1059 Fn::Base64 nested + # allowlist) live alongside. # W1051: Secrets Manager cross-account ARN detection requires runtime # context (account ID) that is not available during template validation. # cfn-lint checks for non-ARN secret references but this engine validates @@ -813,7 +990,6 @@ def build_report(origins: RuleOrigins) -> str: "E1160": ("schema-format", "Lambda function ARN format (schema format field)"), "E1161": ("schema-format", "S3 bucket name format (schema format field)"), "E1162": ("schema-format", "KMS key ID format (schema format field)"), - "E1163": ("schema-format", "Lambda function name format (schema format field)"), "E1164": ("schema-format", "KMS alias name format (schema format field)"), # Covered via schema-validator extensions (extensions.json if/then patches): "E3046": ("schema-ext", "ECS awslogs config — via extensions"), @@ -870,18 +1046,33 @@ def build_report(origins: RuleOrigins) -> str: "E3043": ("out-of-scope", "Nested stack parameters (runtime-only)"), "W4001": ("out-of-scope", "Metadata Interface parameters"), "W4005": ("out-of-scope", "cfn-lint metadata config"), - "W1100": ("out-of-scope", "YAML merge directives"), } missing = [] covered = [] stale_coverage = [] + stale_coverage_keys = [] rule_id_pattern = re.compile(r'^[FEWID]\d{4}$') + # "out-of-scope" entries describe things the engine intentionally never + # implements (CLI config, metadata directives, YAML merge). cfn-lint may + # have removed or never had a matching ID — exclude from the cfn-lint + # presence check so deliberately-noop coverage isn't flagged stale. + out_of_scope_keys = { + cid for cid, (mech, _note) in LOGICAL_COVERAGE.items() + if mech == "out-of-scope" + } for cid, (our_mechanism, note) in LOGICAL_COVERAGE.items(): for part in re.split(r'[/+]', our_mechanism): part = part.strip() if rule_id_pattern.match(part) and part not in our_ids: stale_coverage.append((cid, part, our_mechanism, note)) + # Key-side staleness: a LOGICAL_COVERAGE entry whose cfn-lint key + # does not exist in cfn-lint anymore (rule deleted upstream) is a + # bookkeeping error — there is nothing left to cover. + if (rule_id_pattern.match(cid) + and cid not in cfnlint + and cid not in out_of_scope_keys): + stale_coverage_keys.append((cid, our_mechanism, note)) for cid in sorted(cfnlint): if cid in our_ids: @@ -906,6 +1097,19 @@ def build_report(origins: RuleOrigins) -> str: w(f"| `{cid}` | `{missing_id}` | `{mechanism}` | {note} |") w("") + if stale_coverage_keys: + w(f"### Stale LOGICAL_COVERAGE keys ({len(stale_coverage_keys)})") + w("") + w("These cfn-lint IDs no longer exist in cfn-lint — the coverage entry") + w("describes a rule that has been removed or renamed upstream and should") + w("be deleted.") + w("") + w("| cfn-lint ID | Mechanism | Note |") + w("|-------------|-----------|------|") + for cid, mechanism, note in sorted(stale_coverage_keys): + w(f"| `{cid}` | `{mechanism}` | {note} |") + w("") + w(f"### Not implemented ({len(missing)})") w("") if missing: diff --git a/src/cel-engine/src/rules/conditions.rs b/src/cel-engine/src/rules/conditions.rs index 0af0884..678defe 100644 --- a/src/cel-engine/src/rules/conditions.rs +++ b/src/cel-engine/src/rules/conditions.rs @@ -153,9 +153,10 @@ fn find_unreachable_branches( let mut true_assumptions = assumptions.to_vec(); true_assumptions.push((cond.clone(), true)); if !model.conditions.is_satisfiable(&true_assumptions) { + let explanation = build_unreachable_explanation(cond, true, assumptions); out.push(make_resource_diagnostic( "W1028", - &format!("['Fn::If', 1] is not reachable. When setting condition '{}' to True", cond), + &format!("['Fn::If', 1] is not reachable. {}", explanation), model, resource_id, &format!("{}.Fn::If.1", path), @@ -220,15 +221,10 @@ fn build_unreachable_explanation(condition: &str, target_value: bool, assumption let existing: Vec = assumptions .iter() .filter(|(name, _)| name != condition) - .map(|(name, val)| format!("condition '{}' is {}", name, if *val { "True" } else { "False" })) + .map(|(name, val)| format!("'{}' is {}", name, if *val { "True" } else { "False" })) .collect(); if existing.is_empty() { - format!( - "When setting condition '{}' to {} from current status {}", - condition, - setting, - if target_value { "False" } else { "True" } - ) + format!("When setting condition '{}' to {}", condition, setting) } else { format!( "When setting condition '{}' to {}. Where existing status for {}", @@ -246,26 +242,33 @@ mod tests { #[test] fn explanation_no_existing_assumptions() { let result = build_unreachable_explanation("IsProduction", false, &[]); - assert!(result.contains("IsProduction")); - assert!(result.contains("False")); + assert_eq!(result, "When setting condition 'IsProduction' to False"); } #[test] fn explanation_with_existing_assumptions() { let assumptions = vec![("IsProduction".to_string(), true)]; let result = build_unreachable_explanation("IsStaging", false, &assumptions); - assert!(result.contains("IsStaging")); - assert!(result.contains("False")); - assert!(result.contains("IsProduction")); - assert!(result.contains("True")); + assert_eq!( + result, + "When setting condition 'IsStaging' to False. Where existing status for 'IsProduction' is True" + ); } #[test] fn explanation_filters_self_from_existing() { let assumptions = vec![("SameCond".to_string(), true)]; let result = build_unreachable_explanation("SameCond", false, &assumptions); - // Should not mention SameCond in the "existing status" part - assert!(result.contains("SameCond")); - assert!(result.contains("False")); + assert_eq!(result, "When setting condition 'SameCond' to False"); + } + + #[test] + fn explanation_multiple_existing_assumptions() { + let assumptions = vec![("CondA".to_string(), true), ("CondB".to_string(), false)]; + let result = build_unreachable_explanation("CondC", true, &assumptions); + assert_eq!( + result, + "When setting condition 'CondC' to True. Where existing status for 'CondA' is True and 'CondB' is False" + ); } } diff --git a/src/cel-engine/src/rules/intrinsics.rs b/src/cel-engine/src/rules/intrinsics.rs index 1f0e15f..04ee148 100644 --- a/src/cel-engine/src/rules/intrinsics.rs +++ b/src/cel-engine/src/rules/intrinsics.rs @@ -2,15 +2,17 @@ use super::{EvalContext, NativeRuleRegistry}; use diagnostics::Diagnostic; use std::collections::HashSet; use std::sync::{Arc, LazyLock}; +use template_model::aws_regions::availability_zone_suffixes; use template_model::consts::{ + DYNAMIC_REFERENCE_REASON_PREFIX, DYNREF_FLAVOR_SECRETSMANAGER, DYNREF_FLAVOR_SSM, DYNREF_FLAVOR_SSM_SECURE, EDGE_KIND_GET_ATT, EDGE_KIND_REF, EDGE_KIND_SUB, FIELD_ATTR, FIELD_CONDITIONS, FIELD_DEPENDS_ON, FIELD_KIND, FIELD_OUTGOING_REFS, FIELD_OUTPUTS, FIELD_PARAMETERS, FIELD_PROPERTIES, FIELD_RESOURCE_TYPE, FIELD_RESOURCES, FIELD_SOURCE_PATH, FIELD_TARGET, FN_FOR_EACH, FN_GET_AZS, FN_IMPORT_VALUE, FN_LENGTH, FN_TO_JSON_STRING, KEY_DEFAULT, KEY_DEPENDS_ON, KEY_PROPERTIES, OUTPUT_PSEUDO_RESOURCE_PREFIX, PSEUDO_STACK_NAME, SECTION_CONDITIONS, SECTION_OUTPUTS, TRANSFORM_LANGUAGE_EXTENSIONS, }; -use template_model::resolver::RefKind; -use template_model::resolver::ResolvedValue; +use template_model::model::ResolvedResource; +use template_model::resolver::{RefKind, ResolvedValue}; use template_model::{PSEUDO_PARAMETERS, SemanticModel}; use validation_engine::make_resource_diagnostic; @@ -19,6 +21,8 @@ pub fn register(reg: &mut NativeRuleRegistry) { reg.add(rules::Category::Intrinsic, eval_format_validation); reg.add(rules::Category::Intrinsic, eval_intrinsic_params); reg.add(rules::Category::Intrinsic, eval_dynamic_references); + reg.add(rules::Category::Intrinsic, eval_intrinsic_misc); + reg.add(rules::Category::BestPractice, eval_raw_pseudo_params); } fn eval_intrinsics(ctx: &EvalContext) -> Vec { @@ -222,7 +226,7 @@ fn eval_intrinsics(ctx: &EvalContext) -> Vec { && !cond_keys.contains(cname) { out.push(make_resource_diagnostic( - "F1060", + "E1028", &format!("Fn::If condition '{}' does not exist in Conditions section", cname), m, name, @@ -476,45 +480,13 @@ fn eval_format_validation(ctx: &EvalContext) -> Vec { out } -static VALID_REGIONS: LazyLock> = LazyLock::new(|| { - [ - "af-south-1", - "ap-east-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-northeast-3", - "ap-south-1", - "ap-south-2", - "ap-southeast-1", - "ap-southeast-2", - "ap-southeast-3", - "ap-southeast-4", - "ca-central-1", - "ca-west-1", - "eu-central-1", - "eu-central-2", - "eu-north-1", - "eu-south-1", - "eu-south-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "il-central-1", - "me-central-1", - "me-south-1", - "sa-east-1", - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "us-gov-east-1", - "us-gov-west-1", - "cn-north-1", - "cn-northwest-1", - ] - .into_iter() - .collect() -}); +// Region-valid lookup. Sources the canonical GA region list directly from +// `template_model::aws_regions` so the validator and the resolver share one +// truth — adding a region in `aws_regions.rs` automatically extends the +// validator's accepted set, eliminating the historical drift between the two. +fn is_known_region(region: &str) -> bool { + availability_zone_suffixes(region).is_some() +} fn has_language_extensions(model: &SemanticModel) -> bool { model.transforms.iter().any(|t| t == TRANSFORM_LANGUAGE_EXTENSIONS) @@ -578,6 +550,34 @@ fn eval_intrinsic_params(ctx: &EvalContext) -> Vec { for (name, res) in resources { check_language_extension_intrinsics(&mut out, m, name, res); } + // Section-level Fn::ForEach:: keys (in Resources or + // Outputs) are processed by CloudFormation only when the + // AWS::LanguageExtensions transform is present. Without it the deploy + // fails because CloudFormation does not recognise the macro form. + for name in m.resources.keys() { + if name.starts_with("Fn::ForEach::") { + out.push(make_resource_diagnostic( + "E1032", + "Fn::ForEach requires the AWS::LanguageExtensions transform", + m, + name, + "", + None, + )); + } + } + for out_name in m.outputs.keys() { + if out_name.starts_with("Fn::ForEach::") { + out.push(make_resource_diagnostic( + "E1032", + "Fn::ForEach requires the AWS::LanguageExtensions transform", + m, + out_name, + "", + None, + )); + } + } } out @@ -614,7 +614,7 @@ fn scan_value_for_intrinsics( if let Some(param) = obj.get(FN_GET_AZS) && let Some(s) = param.as_str() && !s.is_empty() - && !VALID_REGIONS.contains(s) + && !is_known_region(s) { out.push(make_resource_diagnostic( "E1015", @@ -931,3 +931,285 @@ fn scan_for_sm_cross_account( _ => {} } } + +static SSM_FORMAT_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"^\{\{resolve:ssm(?:-secure)?:[a-zA-Z0-9_.\-/]+(?::\d+)?\}\}$").expect("Invalid SSM_FORMAT_RE") +}); + +static SM_FORMAT_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r"^\{\{resolve:secretsmanager:(?:arn:[^:]+:[^:]*:[^:]*:[^:]*:secret:[^:]+|[^:}]+)(?::(?:SecretString|)(?::[^:}]*(?::[^:}]*)?)?)?\}\}$", + ) + .expect("Invalid SM_FORMAT_RE") +}); + +static DYNREF_EXTRACT_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"\{\{resolve:(ssm-secure|ssm|secretsmanager):[^}]*\}\}").expect("Invalid DYNREF_EXTRACT_RE") +}); + +const RULE_SPLIT_DYNAMIC_REF: &str = "E1058"; +const RULE_SUB_UNUSED_KEY: &str = "W1019"; +const RULE_BASE64_NESTED: &str = "E1059"; +const RULE_DYNREF_FORMAT: &str = "E1050"; +const RULE_SECRETSMANAGER_AT_ARN: &str = "W1051"; +const RULE_RAW_PSEUDO_PARAM: &str = "W1054"; + +fn eval_intrinsic_misc(ctx: &EvalContext) -> Vec { + let mut out = Vec::new(); + let m = ctx.model; + + for (name, res) in &m.resources { + for path in &res.diagnostics.split_dynamic_ref_delimiters { + out.push(make_resource_diagnostic( + RULE_SPLIT_DYNAMIC_REF, + "Fn::Split delimiter must not be a dynamic reference", + m, + name, + path, + None, + )); + } + + for entry in &res.diagnostics.unused_sub_keys { + out.push(make_resource_diagnostic( + RULE_SUB_UNUSED_KEY, + &format!( + "Parameter '{}' in Fn::Sub variable map is not referenced in the template string", + entry.value + ), + m, + name, + &entry.path, + None, + )); + } + + for entry in &res.diagnostics.base64_disallowed_functions { + out.push(make_resource_diagnostic( + RULE_BASE64_NESTED, + &format!("Fn::Base64 does not support nested function '{}'", entry.value), + m, + name, + &entry.path, + None, + )); + } + + scan_properties_for_dynref_format(&mut out, m, name, res); + scan_properties_for_sm_at_arn(&mut out, m, name, res, &ctx.cached_data.secretsmanager_arn_fields); + } + + out +} + +fn scan_properties_for_dynref_format( + out: &mut Vec, + m: &Arc, + resource_id: &str, + res: &ResolvedResource, +) { + for (prop, val) in &res.properties { + let path = format!("Properties.{}", prop); + scan_resolved_for_dynref_format(out, m, resource_id, val, &path); + } +} + +fn scan_resolved_for_dynref_format( + out: &mut Vec, + m: &Arc, + resource_id: &str, + val: &ResolvedValue, + path: &str, +) { + match val { + ResolvedValue::Concrete { value: v } => { + scan_value_for_dynref_format(out, m, resource_id, &v.0, path); + } + ResolvedValue::Dynamic { reason } => { + if let Some(ref_str) = reason.strip_prefix(DYNAMIC_REFERENCE_REASON_PREFIX) { + check_dynref_format_string(out, m, resource_id, ref_str, path); + } + } + ResolvedValue::List { items } => { + for (i, item) in items.iter().enumerate() { + let child_path = format!("{}.{}", path, i); + scan_resolved_for_dynref_format(out, m, resource_id, item, &child_path); + } + } + ResolvedValue::Map { entries } => { + for entry in entries { + let child_path = format!("{}.{}", path, entry.key); + scan_resolved_for_dynref_format(out, m, resource_id, &entry.value, &child_path); + } + } + ResolvedValue::Conditional { if_true, if_false, .. } => { + scan_resolved_for_dynref_format(out, m, resource_id, if_true, path); + scan_resolved_for_dynref_format(out, m, resource_id, if_false, path); + } + ResolvedValue::Enum { variants } => { + for v in variants { + scan_resolved_for_dynref_format(out, m, resource_id, v, path); + } + } + _ => {} + } +} + +fn check_dynref_format_string( + out: &mut Vec, + m: &Arc, + resource_id: &str, + s: &str, + path: &str, +) { + for cap in DYNREF_EXTRACT_RE.captures_iter(s) { + let Some(full_match) = cap.get(0).map(|m| m.as_str()) else { + continue; + }; + let ref_type = &cap[1]; + let valid = match ref_type { + DYNREF_FLAVOR_SSM | DYNREF_FLAVOR_SSM_SECURE => SSM_FORMAT_RE.is_match(full_match), + DYNREF_FLAVOR_SECRETSMANAGER => SM_FORMAT_RE.is_match(full_match), + _ => true, + }; + if !valid { + out.push(make_resource_diagnostic( + RULE_DYNREF_FORMAT, + &format!( + "Dynamic reference '{}' does not match the required format for '{}'", + full_match, ref_type + ), + m, + resource_id, + path, + Some("Check the dynamic reference syntax"), + )); + } + } +} + +fn scan_value_for_dynref_format( + out: &mut Vec, + m: &Arc, + resource_id: &str, + val: &serde_json::Value, + path: &str, +) { + match val { + serde_json::Value::String(s) => { + check_dynref_format_string(out, m, resource_id, s, path); + } + serde_json::Value::Array(arr) => { + for (i, item) in arr.iter().enumerate() { + let child_path = format!("{}.{}", path, i); + scan_value_for_dynref_format(out, m, resource_id, item, &child_path); + } + } + serde_json::Value::Object(obj) => { + for (key, v) in obj { + let child_path = format!("{}.{}", path, key); + scan_value_for_dynref_format(out, m, resource_id, v, &child_path); + } + } + _ => {} + } +} + +fn scan_properties_for_sm_at_arn( + out: &mut Vec, + m: &Arc, + resource_id: &str, + res: &ResolvedResource, + arn_fields: &HashSet, +) { + for (prop, val) in &res.properties { + let path = format!("Properties.{}", prop); + scan_resolved_for_sm_at_arn(out, m, resource_id, prop, val, &path, arn_fields); + } +} + +fn scan_resolved_for_sm_at_arn( + out: &mut Vec, + m: &Arc, + resource_id: &str, + field_name: &str, + val: &ResolvedValue, + path: &str, + arn_fields: &HashSet, +) { + if arn_fields.contains(field_name) { + let has_sm_ref = match val { + ResolvedValue::Concrete { value: v } => v.as_str().is_some_and(|s| s.contains("{{resolve:secretsmanager:")), + ResolvedValue::Dynamic { reason } => reason.contains("{{resolve:secretsmanager:"), + _ => false, + }; + if has_sm_ref { + out.push(make_resource_diagnostic( + RULE_SECRETSMANAGER_AT_ARN, + &format!( + "Dynamic reference to Secrets Manager resolves to the secret value, not the ARN — field '{}' expects an ARN", + field_name + ), + m, + resource_id, + path, + Some("Use the secret ARN directly instead of a dynamic reference"), + )); + return; + } + } + + match val { + ResolvedValue::Map { entries } => { + for entry in entries { + let child_path = format!("{}.{}", path, entry.key); + scan_resolved_for_sm_at_arn(out, m, resource_id, &entry.key, &entry.value, &child_path, arn_fields); + } + } + ResolvedValue::List { items } => { + for (i, item) in items.iter().enumerate() { + let child_path = format!("{}.{}", path, i); + scan_resolved_for_sm_at_arn(out, m, resource_id, field_name, item, &child_path, arn_fields); + } + } + ResolvedValue::Conditional { if_true, if_false, .. } => { + scan_resolved_for_sm_at_arn(out, m, resource_id, field_name, if_true, path, arn_fields); + scan_resolved_for_sm_at_arn(out, m, resource_id, field_name, if_false, path, arn_fields); + } + _ => {} + } +} + +fn eval_raw_pseudo_params(ctx: &EvalContext) -> Vec { + let mut out = Vec::new(); + let m = ctx.model; + + for (name, res) in &m.resources { + for (prop, val) in &res.properties { + if let ResolvedValue::Concrete { value: v } = val + && let Some(s) = v.as_str() + && !s.contains("{{resolve:") + { + for pseudo in PSEUDO_PARAMETERS { + if s == *pseudo { + let path = format!("Properties.{}", prop); + out.push(make_resource_diagnostic( + RULE_RAW_PSEUDO_PARAM, + &format!( + "String value '{}' is the pseudo-parameter '{}' used as a literal — use Ref to resolve it instead", + s, pseudo + ), + m, + name, + &path, + Some(&format!("Use {{Ref: {}}} instead of the literal string", pseudo)), + )); + break; + } + } + } + } + } + + out +} diff --git a/src/cel-engine/src/rules/mod.rs b/src/cel-engine/src/rules/mod.rs index bc1f1e6..d83cf54 100644 --- a/src/cel-engine/src/rules/mod.rs +++ b/src/cel-engine/src/rules/mod.rs @@ -1,7 +1,7 @@ use data_source::embedded; use data_source::types::{ ArtifactCountEntry, CodepipelineArtifactCounts, DeprecatedResourceTypes, GetattData, KnownResourceTypes, - PrimaryIdentifiers, RetentionPeriodRequirements, SensitivePorts, StatefulResourceTypes, + PrimaryIdentifiers, RetentionPeriodRequirements, SecretsManagerArnFields, SensitivePorts, StatefulResourceTypes, }; use diagnostics::Diagnostic; use std::collections::{HashMap, HashSet}; @@ -36,6 +36,10 @@ pub struct CachedData { pub deprecated_resource_types: HashSet, /// Ports that should not be open to 0.0.0.0/0 pub sensitive_ports: Vec, + /// Property names whose deploy-time value must be a Secrets Manager + /// secret ARN. A Secrets Manager dynamic reference at one of these fields + /// is wrong because the substring expands to the secret value, not the ARN. + pub secretsmanager_arn_fields: HashSet, } /// Enum data files and their embedded byte constants. @@ -128,6 +132,11 @@ impl CachedData { .map_err(|e| anyhow::anyhow!("Failed to parse embedded sensitive_ports data: {}", e))?; let sensitive_ports = sensitive_data.sensitive_ports; + let sm_arn_data: SecretsManagerArnFields = + serde_json::from_slice(&embedded::SECRETSMANAGER_ARN_FIELDS_BYTES) + .map_err(|e| anyhow::anyhow!("Failed to parse embedded secretsmanager_arn_fields data: {}", e))?; + let secretsmanager_arn_fields: HashSet = sm_arn_data.secretsmanager_arn_fields.into_iter().collect(); + let mut enum_data = HashMap::new(); for (name, bytes) in ENUM_DATA.iter() { let v: serde_json::Value = serde_json::from_slice(bytes) @@ -155,6 +164,7 @@ impl CachedData { codepipeline_artifact_counts, deprecated_resource_types, sensitive_ports, + secretsmanager_arn_fields, }) } diff --git a/src/cel-engine/src/rules/resources_extra.rs b/src/cel-engine/src/rules/resources_extra.rs index 928d834..5ebda56 100644 --- a/src/cel-engine/src/rules/resources_extra.rs +++ b/src/cel-engine/src/rules/resources_extra.rs @@ -1358,7 +1358,7 @@ pub fn eval_extra_resources(ctx: &EvalContext) -> Vec { && idx < 0 { out.push(make_resource_diagnostic( - "F1050", + "E1017", "Fn::Select index must be a non-negative integer", m, name, @@ -2075,7 +2075,7 @@ pub fn eval_extra_resources(ctx: &EvalContext) -> Vec { for (name, res) in &m.resources { for s in &res.diagnostics.unsubstituted_variables { out.push(make_resource_diagnostic( - "F1029", + "E1029", &format!("Found an embedded parameter \"{}\" outside of an \"Fn::Sub\" at {}", s.value, s.path), m, name, diff --git a/src/cel-engine/src/rules/structure.rs b/src/cel-engine/src/rules/structure.rs index f608796..d564c95 100644 --- a/src/cel-engine/src/rules/structure.rs +++ b/src/cel-engine/src/rules/structure.rs @@ -314,7 +314,7 @@ fn eval_structure(ctx: &EvalContext) -> Vec { && !defined_conditions.contains(cond.as_str()) { out.push(make_resource_diagnostic( - "F8002", + "E8002", &format!("Condition '{}' referenced by resource '{}' is not defined", cond, rname), m, rname, @@ -323,6 +323,22 @@ fn eval_structure(ctx: &EvalContext) -> Vec { )); } } + if let Some(outputs_obj) = input.get(FIELD_OUTPUTS).and_then(|o| o.as_object()) { + for (oname, out_val) in outputs_obj { + if let Some(cond) = out_val.get("condition").and_then(|c| c.as_str()) + && !defined_conditions.contains(cond) + { + out.push(make_resource_diagnostic( + "E8002", + &format!("Condition '{}' referenced by output '{}' is not defined", cond, oname), + m, + "", + "", + None, + )); + } + } + } } if let Some(desc) = input.get("template").and_then(|t| t.get("description")).and_then(|v| v.as_str()) @@ -632,7 +648,6 @@ fn eval_structure(ctx: &EvalContext) -> Vec { conds, resources, input.get(FIELD_OUTPUTS).and_then(|o| o.as_object()), - &mut HashSet::new(), ) { out.push(make_resource_diagnostic( "W8001", @@ -970,11 +985,7 @@ fn condition_is_referenced( conds: &serde_json::Map, resources: Option<&serde_json::Map>, outputs: Option<&serde_json::Map>, - visited: &mut HashSet, ) -> bool { - if !visited.insert(cname.to_string()) { - return false; - } // Direct usage by resource condition or condition_refs if let Some(res_map) = resources { for (_, res) in res_map { @@ -1001,14 +1012,17 @@ fn condition_is_referenced( } } } - // Transitive: another condition depends on this one via !Condition + // Transitive: a condition referenced by ANY other condition's body (via + // `Condition: `) is considered used, regardless of whether the + // referencing condition is itself used. This counts every in-Conditions- + // section reference, not just references from used conditions, so a + // chain of definitions never causes W8001 to fire on intermediate links. for (other, cond_val) in conds { if other == cname { continue; } if let Some(deps) = cond_val.get("deps").and_then(|d| d.as_array()) && deps.iter().any(|d| d.as_str() == Some(cname)) - && condition_is_referenced(other, conds, resources, outputs, visited) { return true; } diff --git a/src/cel-engine/tests/conformance.rs b/src/cel-engine/tests/conformance.rs index f7b5739..f618e80 100644 --- a/src/cel-engine/tests/conformance.rs +++ b/src/cel-engine/tests/conformance.rs @@ -458,15 +458,16 @@ mod rule_category_tests { fn intrinsics_bad_select() { let ids = validate_file("bad/functions_select.yaml"); // Parser no longer emits F1101 for malformed Select — it falls through - // to a plain map node. W1102 fires for non-integer index in valid 2-element Select. - let has_select_warning = ids.iter().any(|id| id == "W1102"); - assert!(has_select_warning, "Expected W1102 Select type warning, got: {:?}", ids); + // to a plain map node. E1017 fires for non-integer index, source-type + // mismatches, and negative index in Fn::Select. + let has_select_warning = ids.iter().any(|id| id == "E1017"); + assert!(has_select_warning, "Expected E1017 Select diagnostic, got: {:?}", ids); } #[test] fn intrinsics_bad_sub_needed() { let ids = validate_file("bad/sub_needed.yaml"); - assert!(has_rule(&ids, "F1029"), "Expected E1029 for Sub needed, got: {:?}", ids); + assert!(has_rule(&ids, "E1029"), "Expected E1029 for Sub needed, got: {:?}", ids); } #[test] @@ -560,13 +561,13 @@ mod rule_category_tests { #[test] fn conditions_undefined_condition() { let ids = validate_file("bad/undefined_condition.yaml"); - assert!(has_rule(&ids, "F8002"), "Expected condition error, got: {:?}", ids); + assert!(has_rule(&ids, "E8002"), "Expected condition error, got: {:?}", ids); } #[test] fn conditions_equals_wrong_arity() { let ids = validate_file("bad/equals_wrong_arity.yaml"); - assert!(has_rule(&ids, "F0014") || has_rule(&ids, "W8001"), "Expected Equals arity error, got: {:?}", ids); + assert!(has_rule(&ids, "E8003") || has_rule(&ids, "W8001"), "Expected Equals arity error, got: {:?}", ids); } #[test] @@ -604,10 +605,11 @@ mod rule_category_tests { } #[test] - fn good_both_forms_no_errors() { - let ids = validate_file("good/both_forms.yaml"); + fn both_forms_triggers_w9053() { + let ids = validate_file("bad/W9053_equivalent_both_forms.yaml"); let errors: Vec<_> = ids.iter().filter(|id| id.starts_with("E") || id.starts_with("F")).collect(); - assert!(errors.is_empty(), "No errors expected for good/both_forms.yaml, got: {:?}", errors); + assert!(errors.is_empty(), "No E/F errors expected for W9053_equivalent_both_forms.yaml, got: {:?}", errors); + assert!(ids.contains(&"W9053".to_string()), "Expected W9053 for equivalent conditions, got: {:?}", ids); } #[test] diff --git a/src/data-source/build.rs b/src/data-source/build.rs index 76ada25..e557fa0 100644 --- a/src/data-source/build.rs +++ b/src/data-source/build.rs @@ -57,6 +57,7 @@ const HANDWRITTEN_JSON: &[(&str, &str)] = &[ ("codepipeline_action_artifact_counts", "CODEPIPELINE_ACTION_ARTIFACT_COUNTS"), ("deprecated_resource_types", "DEPRECATED_RESOURCE_TYPES"), ("retention_period_requirements", "RETENTION_PERIOD_REQUIREMENTS"), + ("secretsmanager_arn_fields", "SECRETSMANAGER_ARN_FIELDS"), ("sensitive_ports", "SENSITIVE_PORTS"), ]; diff --git a/src/data-source/handwritten/secretsmanager_arn_fields.json b/src/data-source/handwritten/secretsmanager_arn_fields.json new file mode 100644 index 0000000..33ff6f1 --- /dev/null +++ b/src/data-source/handwritten/secretsmanager_arn_fields.json @@ -0,0 +1,10 @@ +{ + "secretsmanager_arn_fields": [ + "SecretArn", + "SecretARN", + "SecretsManagerSecretId", + "SecretsManagerOracleAsmSecretId", + "SecretsManagerSecurityDbEncryptionSecretId", + "SecretsManagerConfiguration" + ] +} diff --git a/src/data-source/src/embedding_format_bench.rs b/src/data-source/src/embedding_format_bench.rs index ab6a30a..cae8fdf 100644 --- a/src/data-source/src/embedding_format_bench.rs +++ b/src/data-source/src/embedding_format_bench.rs @@ -20,7 +20,7 @@ use data_source::types::{ CodepipelineArtifactCounts, DeprecatedResourceTypes, GetattData, KnownResourceTypes, PrimaryIdentifiers, - RetentionPeriodRequirements, SensitivePorts, StatefulResourceTypes, + RetentionPeriodRequirements, SecretsManagerArnFields, SensitivePorts, StatefulResourceTypes, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -52,6 +52,7 @@ const DATA_FILES: &[(&str, &str)] = &[ ("codepipeline_action_artifact_counts", "handwritten"), ("deprecated_resource_types", "handwritten"), ("retention_period_requirements", "handwritten"), + ("secretsmanager_arn_fields", "handwritten"), ("sensitive_ports", "handwritten"), ("generated_rules", "generated/cel-rules"), ]; @@ -215,6 +216,7 @@ fn try_postcard_encode(name: &str, json_bytes: &[u8]) -> Option> { "codepipeline_action_artifact_counts" => encode!(CodepipelineArtifactCounts), "deprecated_resource_types" => encode!(DeprecatedResourceTypes), "retention_period_requirements" => encode!(RetentionPeriodRequirements), + "secretsmanager_arn_fields" => encode!(SecretsManagerArnFields), "sensitive_ports" => encode!(SensitivePorts), _ => None, } @@ -257,6 +259,7 @@ fn bench_postcard_deserialize( "codepipeline_action_artifact_counts" => bench!(CodepipelineArtifactCounts), "deprecated_resource_types" => bench!(DeprecatedResourceTypes), "retention_period_requirements" => bench!(RetentionPeriodRequirements), + "secretsmanager_arn_fields" => bench!(SecretsManagerArnFields), "sensitive_ports" => bench!(SensitivePorts), _ => unreachable!("no postcard type for {name}"), } diff --git a/src/data-source/src/lib.rs b/src/data-source/src/lib.rs index 27dc2c5..f074617 100644 --- a/src/data-source/src/lib.rs +++ b/src/data-source/src/lib.rs @@ -184,6 +184,7 @@ const REQUIRED_HANDWRITTEN_FILES: &[&str] = &[ "codepipeline_action_artifact_counts", "deprecated_resource_types", "retention_period_requirements", + "secretsmanager_arn_fields", "sensitive_ports", ]; diff --git a/src/data-source/src/types.rs b/src/data-source/src/types.rs index 3942e3f..32d6e8c 100644 --- a/src/data-source/src/types.rs +++ b/src/data-source/src/types.rs @@ -60,6 +60,12 @@ pub struct SensitivePorts { pub sensitive_ports: Vec, } +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct SecretsManagerArnFields { + #[serde(default)] + pub secretsmanager_arn_fields: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rego-engine/handwritten/rego/intrinsics/base64_nested.rego b/src/rego-engine/handwritten/rego/intrinsics/base64_nested.rego new file mode 100644 index 0000000..d1b1ee3 --- /dev/null +++ b/src/rego-engine/handwritten/rego/intrinsics/base64_nested.rego @@ -0,0 +1,14 @@ +package intrinsics + +import rego.v1 + +# Fn::Base64 only accepts a string or a string-producing intrinsic. The set +# of valid nested intrinsics is captured at parse time in +# `res.base64DisallowedFunctions` — this rule surfaces those parser findings +# as a violation. +violation contains make_diag_at("E1059", "ERROR", name, + entry.path, + sprintf("Fn::Base64 does not support nested function '%s'", [entry.variable])) if { + some name, res in input.resources + some entry in res.base64DisallowedFunctions +} diff --git a/src/rego-engine/handwritten/rego/intrinsics/dynamic_ref_format.rego b/src/rego-engine/handwritten/rego/intrinsics/dynamic_ref_format.rego new file mode 100644 index 0000000..57da4f5 --- /dev/null +++ b/src/rego-engine/handwritten/rego/intrinsics/dynamic_ref_format.rego @@ -0,0 +1,146 @@ +package intrinsics + +import rego.v1 + +# Validates that every `{{resolve:...}}` substring matches the official +# per-flavor format. CloudFormation rejects malformed substrings at deploy +# time with a substitution error, so we surface the structural issue at +# author time. +# +# Accepted formats: +# {{resolve:ssm:[:]}} +# {{resolve:ssm-secure:[:]}} +# {{resolve:secretsmanager:[:SecretString[:json-key[:version-stage[:version-id]]]]}} +# {{resolve:secretsmanager:arn::secretsmanager:::secret:[:...]}} + +violation contains make_diag_full("E1050", "ERROR", name, + sprintf("Properties.%s", [prop]), + sprintf("Dynamic reference '%s' does not match the required format for '%s'", [match_str, ref_type]), + "Check the dynamic reference syntax", + "") if { + some name, res in input.resources + some prop, val in res.properties + [match_str, ref_type] := _find_malformed_dynref(val) +} + +_find_malformed_dynref(val) := [match_str, ref_type] if { + is_string(val) + match_str := _extract_dynref(val) + match_str != "" + ref_type := _dynref_type(match_str) + ref_type != "" + not _valid_dynref_format(match_str, ref_type) +} + +_find_malformed_dynref(val) := result if { + is_object(val) + some _, v in val + result := _find_malformed_dynref(v) +} + +_find_malformed_dynref(val) := result if { + is_array(val) + some item in val + result := _find_malformed_dynref(item) +} + +# Extract the first dynamic reference substring, including the closing braces. +_extract_dynref(s) := match_str if { + contains(s, "{{resolve:") + idx := indexof(s, "{{resolve:") + rest := substring(s, idx, -1) + end := indexof(rest, "}}") + end > 0 + match_str := substring(rest, 0, end + 2) +} + +_dynref_type(ref_str) := "ssm-secure" if { + contains(ref_str, "{{resolve:ssm-secure:") +} + +_dynref_type(ref_str) := "ssm" if { + contains(ref_str, "{{resolve:ssm:") + not contains(ref_str, "{{resolve:ssm-secure:") +} + +_dynref_type(ref_str) := "secretsmanager" if { + contains(ref_str, "{{resolve:secretsmanager:") +} + +_valid_dynref_format(ref_str, "ssm") if { + _valid_ssm_format(ref_str, "ssm") +} + +_valid_dynref_format(ref_str, "ssm-secure") if { + _valid_ssm_format(ref_str, "ssm-secure") +} + +# SSM parameter names allow letters, digits, underscore, dot, dash, slash. +# The optional version suffix must be a numeric identifier. +_valid_ssm_format(ref_str, flavor) if { + prefix := sprintf("{{resolve:%s:", [flavor]) + startswith(ref_str, prefix) + endswith(ref_str, "}}") + inner := trim_suffix(trim_prefix(ref_str, prefix), "}}") + inner != "" + parts := split(inner, ":") + count(parts) <= 2 + count(parts) >= 1 + parts[0] != "" + regex.match(`^[a-zA-Z0-9_.\-/]+$`, parts[0]) + _ssm_version_valid(parts) +} + +_ssm_version_valid(parts) if { + count(parts) == 1 +} + +_ssm_version_valid(parts) if { + count(parts) == 2 + regex.match(`^\d+$`, parts[1]) +} + +_valid_dynref_format(ref_str, "secretsmanager") if { + prefix := "{{resolve:secretsmanager:" + startswith(ref_str, prefix) + endswith(ref_str, "}}") + inner := trim_suffix(trim_prefix(ref_str, prefix), "}}") + inner != "" + _valid_secretsmanager_inner(inner) +} + +# ARN form: arn::secretsmanager:::secret: +# followed by the optional :SecretString[:json-key[:version-stage[:version-id]]] +# suffix. The minimum 7 colon-separated pieces correspond to the bare ARN; the +# maximum 11 add the SecretString clause. +_valid_secretsmanager_inner(inner) if { + startswith(inner, "arn:") + parts := split(inner, ":") + count(parts) >= 7 + count(parts) <= 11 +} + +_valid_secretsmanager_inner(inner) if { + not startswith(inner, "arn:") + parts := split(inner, ":") + count(parts) >= 1 + count(parts) <= 5 + parts[0] != "" + _sm_secret_string_valid(parts) +} + +_sm_secret_string_valid(parts) if { + count(parts) == 1 +} + +# When SecretString is specified, the second piece must literally be +# "SecretString". An empty second piece is also valid (omitted). +_sm_secret_string_valid(parts) if { + count(parts) >= 2 + parts[1] == "SecretString" +} + +_sm_secret_string_valid(parts) if { + count(parts) >= 2 + parts[1] == "" +} diff --git a/src/rego-engine/handwritten/rego/intrinsics/getatt.rego b/src/rego-engine/handwritten/rego/intrinsics/getatt.rego index 31485aa..ad61137 100644 --- a/src/rego-engine/handwritten/rego/intrinsics/getatt.rego +++ b/src/rego-engine/handwritten/rego/intrinsics/getatt.rego @@ -101,8 +101,8 @@ _property_format(path) := "AWS::Logs::LogGroup.Name" if { contains(path, "awslogs-group") } -# E1060: If condition name must exist in Conditions section -violation contains make_diag("F1060", "FATAL", name, +# E1028: Fn::If condition name must exist in Conditions section +violation contains make_diag("E1028", "ERROR", name, sprintf("Fn::If condition '%s' does not exist in Conditions section", [cond])) if { some name, res in input.resources some cond in res.conditionRefs diff --git a/src/rego-engine/handwritten/rego/intrinsics/intrinsic_params.rego b/src/rego-engine/handwritten/rego/intrinsics/intrinsic_params.rego index 95a9fa5..57c9ad8 100644 --- a/src/rego-engine/handwritten/rego/intrinsics/intrinsic_params.rego +++ b/src/rego-engine/handwritten/rego/intrinsics/intrinsic_params.rego @@ -2,16 +2,30 @@ package intrinsics import rego.v1 -# Valid AWS regions for GetAZs validation +# Valid AWS regions for GetAZs validation. +# MUST mirror `template_model::aws_regions::availability_zone_suffixes` in +# src/template-model/src/aws_regions.rs. Whenever a new GA region launches, +# update both this list and the canonical Rust source. _valid_regions := { - "af-south-1", "ap-east-1", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", - "ap-south-1", "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", - "ap-southeast-4", "ca-central-1", "ca-west-1", "eu-central-1", "eu-central-2", - "eu-north-1", "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", - "il-central-1", "me-central-1", "me-south-1", "sa-east-1", - "us-east-1", "us-east-2", "us-west-1", "us-west-2", - "us-gov-east-1", "us-gov-west-1", + "af-south-1", + "ap-east-1", "ap-east-2", + "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", + "ap-south-1", "ap-south-2", + "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", "ap-southeast-6", "ap-southeast-7", + "ca-central-1", "ca-west-1", "cn-north-1", "cn-northwest-1", + "eu-central-1", "eu-central-2", + "eu-north-1", + "eu-south-1", "eu-south-2", + "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", + "me-central-1", "me-south-1", + "mx-central-1", + "sa-east-1", + "us-east-1", "us-east-2", + "us-gov-east-1", "us-gov-west-1", + "us-west-1", "us-west-2", } # E1015: GetAZs parameter must be a valid region if non-empty string literal diff --git a/src/rego-engine/handwritten/rego/intrinsics/language_extensions.rego b/src/rego-engine/handwritten/rego/intrinsics/language_extensions.rego index accb061..2d5b39a 100644 --- a/src/rego-engine/handwritten/rego/intrinsics/language_extensions.rego +++ b/src/rego-engine/handwritten/rego/intrinsics/language_extensions.rego @@ -26,6 +26,23 @@ violation contains make_diag("E1032", "ERROR", name, _has_intrinsic_in_properties(res, "Fn::ForEach") } +# E1032: Section-level Fn::ForEach:: keys in Resources or Outputs +# are processed only when the AWS::LanguageExtensions transform is declared. +# Without it CloudFormation rejects the deployment. +violation contains make_diag("E1032", "ERROR", name, + "Fn::ForEach requires the AWS::LanguageExtensions transform") if { + not _has_language_extensions + some name, _ in input.resources + startswith(name, "Fn::ForEach::") +} + +violation contains make_diag("E1032", "ERROR", name, + "Fn::ForEach requires the AWS::LanguageExtensions transform") if { + not _has_language_extensions + some name, _ in input.outputs + startswith(name, "Fn::ForEach::") +} + _has_language_extensions if { some t in input.template.transforms t == "AWS::LanguageExtensions" diff --git a/src/rego-engine/handwritten/rego/intrinsics/pseudo_params.rego b/src/rego-engine/handwritten/rego/intrinsics/pseudo_params.rego new file mode 100644 index 0000000..bc8c75b --- /dev/null +++ b/src/rego-engine/handwritten/rego/intrinsics/pseudo_params.rego @@ -0,0 +1,19 @@ +package intrinsics + +import rego.v1 + +# Canonical CloudFormation pseudo-parameter set. Defined once at package +# level so every file in `package intrinsics` (ref.rego, sub.rego, +# raw_pseudo_param.rego) shares one truth. MUST mirror the canonical Rust +# constant `PSEUDO_PARAMETERS` in src/template-model/src/consts.rs — when +# AWS adds a pseudo-parameter, update both. +pseudo_parameters := { + "AWS::AccountId", + "AWS::NotificationARNs", + "AWS::NoValue", + "AWS::Partition", + "AWS::Region", + "AWS::StackId", + "AWS::StackName", + "AWS::URLSuffix", +} diff --git a/src/rego-engine/handwritten/rego/intrinsics/raw_pseudo_param.rego b/src/rego-engine/handwritten/rego/intrinsics/raw_pseudo_param.rego new file mode 100644 index 0000000..1f7bf7f --- /dev/null +++ b/src/rego-engine/handwritten/rego/intrinsics/raw_pseudo_param.rego @@ -0,0 +1,24 @@ +package intrinsics + +import rego.v1 + +# A property value that is *exactly* a pseudo-parameter name (e.g. the whole +# string is `"AWS::Region"`) is NOT substituted by CloudFormation — only +# `{Ref: AWS::Region}` resolves. This typically indicates a forgotten Fn::Sub +# wrapper or a missing Ref, so we surface it as a warning. The comparison is +# exact equality, not substring, so a property containing the pseudo-parameter +# name as part of a larger literal does not trigger. +violation contains make_diag_full("W1054", "WARN", name, + sprintf("Properties.%s", [prop]), + sprintf("String value '%s' is the pseudo-parameter '%s' used as a literal — use Ref to resolve it instead", [val, pseudo]), + sprintf("Use {Ref: %s} instead of the literal string", [pseudo]), + "") if { + some name, res in input.resources + some prop, val in res.properties + is_string(val) + some pseudo in pseudo_parameters + val == pseudo + # Dynamic-reference substrings are deploy-time-resolved; the pseudo-param + # name they contain is part of the resolution pattern, not authored text. + not contains(val, "{{resolve:") +} diff --git a/src/rego-engine/handwritten/rego/intrinsics/ref.rego b/src/rego-engine/handwritten/rego/intrinsics/ref.rego index 512f06c..d66bc3c 100644 --- a/src/rego-engine/handwritten/rego/intrinsics/ref.rego +++ b/src/rego-engine/handwritten/rego/intrinsics/ref.rego @@ -2,10 +2,7 @@ package intrinsics import rego.v1 -pseudo_params := { - "AWS::AccountId", "AWS::NotificationARNs", "AWS::NoValue", - "AWS::Partition", "AWS::Region", "AWS::StackId", "AWS::StackName", "AWS::URLSuffix" -} +# `pseudo_parameters` is defined in pseudo_params.rego (shared across this package). # Ref target must exist violation contains make_diag_full("F1010", "FATAL", name, "", @@ -18,7 +15,7 @@ violation contains make_diag_full("F1010", "FATAL", name, "", target := edge.target not target in object.keys(input.resources) not target in object.keys(input.parameters) - not target in pseudo_params + not target in pseudo_parameters not target in object.get(input, "samImplicitResources", []) } @@ -40,12 +37,12 @@ _all_valid_targets := sort(array.concat( sort([k | some k in object.keys(input.resources)]), sort([k | some k in object.keys(input.parameters)]) ), - sort([k | some k in pseudo_params]) + sort([k | some k in pseudo_parameters]) )) _valid_ref_targets(name) := targets if { res_keys := sort([k | some k in object.keys(input.resources); k != name]) - targets := array.concat(res_keys, sort([p | some p in pseudo_params])) + targets := array.concat(res_keys, sort([p | some p in pseudo_parameters])) } # Ref format mismatch — Ref to resource whose type doesn't match destination format diff --git a/src/rego-engine/handwritten/rego/intrinsics/secretsmanager_arn.rego b/src/rego-engine/handwritten/rego/intrinsics/secretsmanager_arn.rego new file mode 100644 index 0000000..f0ab20f --- /dev/null +++ b/src/rego-engine/handwritten/rego/intrinsics/secretsmanager_arn.rego @@ -0,0 +1,33 @@ +package intrinsics + +import rego.v1 + +# A Secrets Manager dynamic reference (`{{resolve:secretsmanager:...}}`) +# expands at deploy time to the secret VALUE, not its ARN. Templates that +# place such a substring on an ARN-typed property therefore deploy a value +# where an ARN is required, breaking the resource at runtime. +# +# The set of property names is loaded from +# `data-source/handwritten/secretsmanager_arn_fields.json` and exposed as +# `data.secretsmanager_arn_fields.secretsmanager_arn_fields` — same source +# the CEL engine consumes, so both engines stay in lockstep. +violation contains make_diag_full("W1051", "WARN", name, + sprintf("Properties.%s", [prop]), + sprintf("Dynamic reference to Secrets Manager resolves to the secret value, not the ARN — field '%s' expects an ARN", [prop]), + "Use the secret ARN directly instead of a dynamic reference", + "") if { + some name, res in input.resources + some prop, val in res.properties + prop in data.secretsmanager_arn_fields + _value_contains_sm_dynref(val) +} + +_value_contains_sm_dynref(val) if { + is_string(val) + contains(val, "{{resolve:secretsmanager:") +} + +_value_contains_sm_dynref(val) if { + is_object(val) + contains(val.__dynamic, "{{resolve:secretsmanager:") +} diff --git a/src/rego-engine/handwritten/rego/intrinsics/select.rego b/src/rego-engine/handwritten/rego/intrinsics/select.rego index 538700f..73b5b9c 100644 --- a/src/rego-engine/handwritten/rego/intrinsics/select.rego +++ b/src/rego-engine/handwritten/rego/intrinsics/select.rego @@ -2,8 +2,9 @@ package intrinsics import rego.v1 -# E1050: Select index must be a non-negative integer -violation contains make_diag("F1050", "FATAL", name, +# Select index must be a non-negative integer — CloudFormation rejects +# negative indices on deploy. +violation contains make_diag("E1017", "ERROR", name, "Fn::Select index must be a non-negative integer") if { some name, res in input.resources some key, val in res.properties diff --git a/src/rego-engine/handwritten/rego/intrinsics/split_dynref.rego b/src/rego-engine/handwritten/rego/intrinsics/split_dynref.rego new file mode 100644 index 0000000..57549fe --- /dev/null +++ b/src/rego-engine/handwritten/rego/intrinsics/split_dynref.rego @@ -0,0 +1,14 @@ +package intrinsics + +import rego.v1 + +# Fn::Split's first argument is the delimiter — CloudFormation does not +# resolve dynamic references inside it before splitting, so a `{{resolve:...}}` +# substring would be passed through as a literal delimiter and produce wrong +# segments at deploy time. +violation contains make_diag_at("E1058", "ERROR", name, + path, + "Fn::Split delimiter must not be a dynamic reference") if { + some name, res in input.resources + some path in res.splitDynamicRefDelimiters +} diff --git a/src/rego-engine/handwritten/rego/intrinsics/sub.rego b/src/rego-engine/handwritten/rego/intrinsics/sub.rego index 7175a1e..e2b5cca 100644 --- a/src/rego-engine/handwritten/rego/intrinsics/sub.rego +++ b/src/rego-engine/handwritten/rego/intrinsics/sub.rego @@ -2,11 +2,7 @@ package intrinsics import rego.v1 -# E1018: Sub variables must resolve to parameter, resource, or pseudo-parameter -pseudo_params_sub := { - "AWS::AccountId", "AWS::NotificationARNs", "AWS::NoValue", - "AWS::Partition", "AWS::Region", "AWS::StackId", "AWS::StackName", "AWS::URLSuffix" -} +# `pseudo_parameters` is defined in pseudo_params.rego (shared across this package). violation contains make_diag("F1018", "FATAL", name, sprintf("Fn::Sub variable '${%s}' does not reference a valid resource, parameter, or pseudo-parameter", [target])) if { @@ -16,5 +12,5 @@ violation contains make_diag("F1018", "FATAL", name, target := edge.target not target in object.keys(input.resources) not target in object.keys(input.parameters) - not target in pseudo_params_sub + not target in pseudo_parameters } diff --git a/src/rego-engine/handwritten/rego/intrinsics/sub_needed.rego b/src/rego-engine/handwritten/rego/intrinsics/sub_needed.rego index 363f35f..f65e2c7 100644 --- a/src/rego-engine/handwritten/rego/intrinsics/sub_needed.rego +++ b/src/rego-engine/handwritten/rego/intrinsics/sub_needed.rego @@ -2,8 +2,12 @@ package intrinsics import rego.v1 -# E1029: Sub needed — ${Variable} in strings outside Fn::Sub -violation contains make_diag_full("F1029", "FATAL", name, +# A `${Variable}` substring appearing in any string outside an Fn::Sub is +# treated as a literal by CloudFormation — the substitution placeholder +# requires Fn::Sub. The parser collects these unsubstituted variables; +# this rule surfaces them as a violation so authors know the value will +# not be interpolated at deploy time. +violation contains make_diag_full("E1029", "ERROR", name, entry.path, sprintf("Found an embedded parameter \"%s\" outside of an \"Fn::Sub\" at %s", [entry.variable, entry.path]), "Wrap the string with Fn::Sub", diff --git a/src/rego-engine/handwritten/rego/intrinsics/sub_unused_keys.rego b/src/rego-engine/handwritten/rego/intrinsics/sub_unused_keys.rego new file mode 100644 index 0000000..a3b1a31 --- /dev/null +++ b/src/rego-engine/handwritten/rego/intrinsics/sub_unused_keys.rego @@ -0,0 +1,14 @@ +package intrinsics + +import rego.v1 + +# A key in the Fn::Sub variable map that does not appear as `${Key}` in the +# template string is dead code — the value is parsed but never substituted. +# This is a strong signal of an authoring mistake (typo, leftover after a +# rename) so we surface it as a warning. +violation contains make_diag_at("W1019", "WARN", name, + entry.path, + sprintf("Parameter '%s' in Fn::Sub variable map is not referenced in the template string", [entry.variable])) if { + some name, res in input.resources + some entry in res.unusedSubKeys +} diff --git a/src/rego-engine/handwritten/rego/structure/conditions.rego b/src/rego-engine/handwritten/rego/structure/conditions.rego index 6d943a7..7a5b02d 100644 --- a/src/rego-engine/handwritten/rego/structure/conditions.rego +++ b/src/rego-engine/handwritten/rego/structure/conditions.rego @@ -2,8 +2,8 @@ package structure import rego.v1 -# F8002: Condition referenced by a resource must be defined in the Conditions section. -violation contains make_diag("F8002", "FATAL", name, +# E8002: Condition referenced by a resource must be defined in the Conditions section. +violation contains make_diag("E8002", "ERROR", name, sprintf("Condition '%s' referenced by resource '%s' is not defined", [cond, name])) if { some name, res in input.resources cond := res.condition @@ -11,3 +11,13 @@ violation contains make_diag("F8002", "FATAL", name, is_string(cond) not cond in object.keys(input.conditions) } + +# E8002: Condition referenced by an output must be defined in the Conditions section. +violation contains make_diag("E8002", "ERROR", "", + sprintf("Condition '%s' referenced by output '%s' is not defined", [cond, name])) if { + some name, out in input.outputs + cond := out.condition + cond != null + is_string(cond) + not cond in object.keys(input.conditions) +} diff --git a/src/rego-engine/handwritten/rego/structure/unused.rego b/src/rego-engine/handwritten/rego/structure/unused.rego index a79a89b..5cfc3ba 100644 --- a/src/rego-engine/handwritten/rego/structure/unused.rego +++ b/src/rego-engine/handwritten/rego/structure/unused.rego @@ -82,50 +82,16 @@ _condition_used(cname) if { c == cname } -# Transitive: condition is used if another condition depends on it and that condition is directly used +# A condition referenced by ANY other condition's body (via `Condition: `) +# is considered used, regardless of whether the referencing condition is itself +# used. This counts every in-Conditions-section reference, not just references +# from used conditions, so a chain of definitions never causes W8001 to fire +# on intermediate links. _condition_used(cname) if { some other in object.keys(input.conditions) other != cname some dep in object.get(input.conditions[other], "deps", []) dep == cname - _condition_directly_used(other) -} - -# Two-level transitive: A → B → C where C is directly used -_condition_used(cname) if { - some mid in object.keys(input.conditions) - mid != cname - some dep1 in object.get(input.conditions[mid], "deps", []) - dep1 == cname - some other in object.keys(input.conditions) - other != mid - other != cname - some dep2 in object.get(input.conditions[other], "deps", []) - dep2 == mid - _condition_directly_used(other) -} - -# Direct usage checks (non-recursive) -_condition_directly_used(cname) if { - some _, res in input.resources - res.condition == cname -} - -_condition_directly_used(cname) if { - some _, out in input.outputs - out.condition == cname -} - -_condition_directly_used(cname) if { - some _, res in input.resources - some c in res.conditionRefs - c == cname -} - -_condition_directly_used(cname) if { - some _, out in input.outputs - some c in object.get(out, "conditionRefs", []) - c == cname } # W7001: Unused mappings (not referenced by any Fn::FindInMap) diff --git a/src/rego-engine/src/builtins.rs b/src/rego-engine/src/builtins.rs index d1b18fa..aabb6c2 100644 --- a/src/rego-engine/src/builtins.rs +++ b/src/rego-engine/src/builtins.rs @@ -1487,15 +1487,13 @@ fn collect_unreachable_branches( let mut true_assumptions = assumptions.to_vec(); true_assumptions.push((cond.clone(), true)); if !model.conditions.is_satisfiable(&true_assumptions) { + let explanation = build_unreachable_explanation(cond, true, assumptions); let mut map = serde_json::Map::new(); map.insert("resourceId".into(), serde_json::Value::String(resource_id.to_string())); map.insert("path".into(), serde_json::Value::String(format!("{}.{}.1", path, FN_IF))); map.insert( "message".into(), - serde_json::Value::String(format!( - "['Fn::If', 1] is not reachable. When setting condition '{}' to True", - cond - )), + serde_json::Value::String(format!("['Fn::If', 1] is not reachable. {}", explanation)), ); results.push(json_to_value(&serde_json::Value::Object(map))); } @@ -1503,20 +1501,7 @@ fn collect_unreachable_branches( let mut false_assumptions = assumptions.to_vec(); false_assumptions.push((cond.clone(), false)); if !model.conditions.is_satisfiable(&false_assumptions) { - let existing: Vec = assumptions - .iter() - .filter(|(name, _)| name != cond) - .map(|(name, val)| format!("condition '{}' is {}", name, if *val { "True" } else { "False" })) - .collect(); - let explanation = if existing.is_empty() { - format!("When setting condition '{}' to False from current status True", cond) - } else { - format!( - "When setting condition '{}' to False. Where existing status for {}", - cond, - existing.join(" and ") - ) - }; + let explanation = build_unreachable_explanation(cond, false, assumptions); let mut map = serde_json::Map::new(); map.insert("resourceId".into(), serde_json::Value::String(resource_id.to_string())); map.insert("path".into(), serde_json::Value::String(format!("{}.{}.2", path, FN_IF))); @@ -1565,6 +1550,25 @@ fn collect_unreachable_branches( } } +fn build_unreachable_explanation(condition: &str, target_value: bool, assumptions: &[(String, bool)]) -> String { + let setting = if target_value { "True" } else { "False" }; + let existing: Vec = assumptions + .iter() + .filter(|(name, _)| name != condition) + .map(|(name, val)| format!("'{}' is {}", name, if *val { "True" } else { "False" })) + .collect(); + if existing.is_empty() { + format!("When setting condition '{}' to {}", condition, setting) + } else { + format!( + "When setting condition '{}' to {}. Where existing status for {}", + condition, + setting, + existing.join(" and ") + ) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rego-engine/src/engine.rs b/src/rego-engine/src/engine.rs index 2a6be66..0f357c6 100644 --- a/src/rego-engine/src/engine.rs +++ b/src/rego-engine/src/engine.rs @@ -66,6 +66,7 @@ static REGORUS_DATA: LazyLock> = LazyLock::new(|| { ("data/codepipeline_action_artifact_counts", &*embedded::CODEPIPELINE_ACTION_ARTIFACT_COUNTS_BYTES), ("data/deprecated_resource_types", &*embedded::DEPRECATED_RESOURCE_TYPES_BYTES), ("data/retention_period_requirements", &*embedded::RETENTION_PERIOD_REQUIREMENTS_BYTES), + ("data/secretsmanager_arn_fields", &*embedded::SECRETSMANAGER_ARN_FIELDS_BYTES), ("data/sensitive_ports", &*embedded::SENSITIVE_PORTS_BYTES), ] }); diff --git a/src/rego-engine/tests/integration.rs b/src/rego-engine/tests/integration.rs index 8754fd5..a8362df 100644 --- a/src/rego-engine/tests/integration.rs +++ b/src/rego-engine/tests/integration.rs @@ -44,7 +44,6 @@ fn e2e_all_good_fixtures_no_errors() { "good/ecs_fargate_valid.yaml", "good/cloudfront_valid.yaml", "good/iam_valid.yaml", - "good/both_forms.yaml", "good/complex_conditions.yaml", "good/deletion_policies.yaml", "good/mappings_valid.yaml", @@ -504,7 +503,7 @@ fn e2e_invalid_mapping_structure() { #[test] fn e2e_undefined_condition() { let report = validate_fixture("bad/undefined_condition.yaml"); - assert!(has_rule(&report, "F8002"), "Undefined condition should trigger F8002, got: {:?}", report.diagnostics); + assert!(has_rule(&report, "E8002"), "Undefined condition should trigger E8002, got: {:?}", report.diagnostics); } #[test] @@ -569,7 +568,7 @@ fn e2e_if_wrong_arity() { fn e2e_equals_wrong_arity() { let report = validate_fixture("bad/equals_wrong_arity.yaml"); assert!( - has_rule(&report, "F0014"), + has_rule(&report, "E8003"), "Fn::Equals with 3 elements should trigger parse error, got: {:?}", report.diagnostics ); @@ -623,9 +622,9 @@ fn e2e_w1020_prefix_sub_no_trigger() { } #[test] -fn e2e_e1029_nested_intrinsic_syntax() { +fn e2e_w1056_nested_intrinsic_syntax() { let report = validate_fixture("bad/sub_nested_intrinsic.yaml"); - assert!(has_rule(&report, "F1029"), "Expected F1029 for nested intrinsic syntax"); + assert!(has_rule(&report, "W1056"), "Expected W1056 for nested intrinsic syntax"); } #[test] diff --git a/src/resources/expected/all_templates.json b/src/resources/expected/all_templates.json index 36eb12a..b35d03a 100644 --- a/src/resources/expected/all_templates.json +++ b/src/resources/expected/all_templates.json @@ -1,16 +1,16 @@ { - "bad/E1150_network_interfaces_groupset_multi.yaml": { - "filePath": "bad/E1150_network_interfaces_groupset_multi.yaml", + "bad/E1029_raw_sub_outside.yaml": { + "filePath": "bad/E1029_raw_sub_outside.yaml", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, - "errors": 4, + "errors": 1, "warnings": 1, - "informational": 3, + "informational": 2, "debug": 0 }, "suppressed": 0, @@ -42,133 +42,50 @@ }, "diagnostics": [ { - "ruleId": "E1150", - "severity": "ERROR", - "message": "'sg-BADBAD1' does not match format 'AWS::EC2::SecurityGroup.Id'", - "source": "CFN_LINT", - "resourceId": "Instance", - "resourceType": "AWS::EC2::Instance", - "propertyPath": "Properties.NetworkInterfaces.0.GroupSet.0", - "category": "Intrinsic Function", - "startLine": 7, - "startColumn": 3, - "endLine": 7, - "endColumn": 11, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", - "ruleDescription": "Validate security group format", - "phase": "SCHEMA", - "section": "Resources", - "context": { - "actualValue": "sg-BADBAD1" - } - }, - { - "ruleId": "E1150", - "severity": "ERROR", - "message": "'sg-BADBAD2' does not match format 'AWS::EC2::SecurityGroup.Id'", - "source": "CFN_LINT", - "resourceId": "Instance", - "resourceType": "AWS::EC2::Instance", - "propertyPath": "Properties.NetworkInterfaces.0.GroupSet.1", - "category": "Intrinsic Function", - "startLine": 7, - "startColumn": 3, - "endLine": 7, - "endColumn": 11, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", - "ruleDescription": "Validate security group format", - "phase": "SCHEMA", - "section": "Resources", - "context": { - "actualValue": "sg-BADBAD2" - } - }, - { - "ruleId": "E1150", - "severity": "ERROR", - "message": "'sg-BADBAD1' is not a 'AWS::EC2::SecurityGroup.Id' with pattern '^sg-([a-fA-F0-9]{8}|[a-fA-F0-9]{17})$'", + "ruleId": "W2001", + "severity": "WARN", + "message": "Parameter 'Env' is not referenced anywhere in the template", "source": "CFN_LINT", - "resourceId": "Instance", - "resourceType": "AWS::EC2::Instance", - "propertyPath": "Properties.NetworkInterfaces.GroupSet", - "category": "Intrinsic Function", - "startLine": 7, - "startColumn": 3, - "endLine": 7, + "category": "Best Practice", + "startLine": 4, + "startColumn": 1, + "endLine": 4, "endColumn": 11, - "ruleDescription": "Validate security group format", + "ruleDescription": "Check if Parameters are Used", "phase": "LINT", - "section": "Resources" + "section": "Parameters" }, { - "ruleId": "E1150", + "ruleId": "E1029", "severity": "ERROR", - "message": "'sg-BADBAD2' is not a 'AWS::EC2::SecurityGroup.Id' with pattern '^sg-([a-fA-F0-9]{8}|[a-fA-F0-9]{17})$'", - "source": "CFN_LINT", - "resourceId": "Instance", - "resourceType": "AWS::EC2::Instance", - "propertyPath": "Properties.NetworkInterfaces.GroupSet", + "message": "Found an embedded parameter \"${Env}\" outside of an \"Fn::Sub\" at Properties.BucketName", + "source": "SCHEMA", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "suggestedFix": "Wrap the string with Fn::Sub", "category": "Intrinsic Function", - "startLine": 7, - "startColumn": 3, - "endLine": 7, - "endColumn": 11, - "ruleDescription": "Validate security group format", - "phase": "LINT", - "section": "Resources" - }, - { - "ruleId": "W9010", - "severity": "WARN", - "message": "Hardcoded AMI ID \u2014 use a parameter or mapping for portability", - "source": "ENGINE", - "resourceId": "Instance", - "resourceType": "AWS::EC2::Instance", - "propertyPath": "Properties.ImageId", - "category": "Security", - "startLine": 7, + "startLine": 8, "startColumn": 3, - "endLine": 7, + "endLine": 8, "endColumn": 11, - "ruleDescription": "Hardcoded AMI ID", + "ruleDescription": "Substitution variable ${X} requires Fn::Sub", "phase": "LINT", "section": "Resources" }, { "ruleId": "I9001", "severity": "INFO", - "message": "Property 'ImageId' is create-only; updating it will cause resource replacement", - "source": "ENGINE", - "resourceId": "Instance", - "resourceType": "AWS::EC2::Instance", - "propertyPath": "Properties.ImageId", - "category": "Best Practice", - "startLine": 7, - "startColumn": 3, - "endLine": 7, - "endColumn": 11, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", - "ruleDescription": "Create-only property updated triggers resource replacement", - "phase": "SCHEMA", - "section": "Resources", - "context": { - "lifecycle": "create-only" - } - }, - { - "ruleId": "I9001", - "severity": "INFO", - "message": "Property 'NetworkInterfaces' is create-only; updating it will cause resource replacement", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", "source": "ENGINE", - "resourceId": "Instance", - "resourceType": "AWS::EC2::Instance", - "propertyPath": "Properties.NetworkInterfaces", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", "category": "Best Practice", - "startLine": 7, + "startLine": 8, "startColumn": 3, - "endLine": 7, + "endLine": 8, "endColumn": 11, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", "ruleDescription": "Create-only property updated triggers resource replacement", "phase": "SCHEMA", "section": "Resources", @@ -179,16 +96,16 @@ { "ruleId": "I9040", "severity": "INFO", - "message": "Resource 'Instance' of type 'AWS::EC2::Instance' supports Tags but none are configured", + "message": "Resource 'MyBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", "source": "ENGINE", - "resourceId": "Instance", - "resourceType": "AWS::EC2::Instance", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", "propertyPath": "Properties.Tags", "suggestedFix": "Add Tags to improve resource organization and cost tracking", "category": "Best Practice", - "startLine": 7, + "startLine": 8, "startColumn": 3, - "endLine": 7, + "endLine": 8, "endColumn": 11, "ruleDescription": "Resource should have Tags", "phase": "LINT", @@ -196,18 +113,18 @@ } ] }, - "bad/E3019_four_way_group.yaml": { - "filePath": "bad/E3019_four_way_group.yaml", + "bad/E1029_sub_needed_custom_excludes.yaml": { + "filePath": "bad/E1029_sub_needed_custom_excludes.yaml", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, - "resourcesScanned": 4, + "rulesEvaluated": 295, + "resourcesScanned": 1, "counts": { "fatal": 0, - "errors": 4, - "warnings": 0, - "informational": 8, + "errors": 1, + "warnings": 1, + "informational": 2, "debug": 0 }, "suppressed": 0, @@ -239,35 +156,51 @@ }, "diagnostics": [ { - "ruleId": "E3019", - "severity": "ERROR", - "message": "Primary identifiers {'BucketName': 'shared-name'} should have unique values across the resources {'A', 'B', 'C', 'D'}", + "ruleId": "W2001", + "severity": "WARN", + "message": "Parameter 'Stage' is not referenced anywhere in the template", "source": "CFN_LINT", - "resourceId": "A", - "resourceType": "AWS::S3::Bucket", - "propertyPath": "Properties.BucketName", - "category": "Resource", - "startLine": 7, + "category": "Best Practice", + "startLine": 4, + "startColumn": 1, + "endLine": 4, + "endColumn": 11, + "ruleDescription": "Check if Parameters are Used", + "phase": "LINT", + "section": "Parameters" + }, + { + "ruleId": "E1029", + "severity": "ERROR", + "message": "Found an embedded parameter \"${Stage}\" outside of an \"Fn::Sub\" at Properties.RoleName", + "source": "SCHEMA", + "resourceId": "TestRole", + "resourceType": "AWS::IAM::Role", + "propertyPath": "Properties.RoleName", + "suggestedFix": "Wrap the string with Fn::Sub", + "category": "Intrinsic Function", + "startLine": 8, "startColumn": 3, - "endLine": 7, - "endColumn": 4, - "ruleDescription": "Validate that all resources have unique primary identifiers", + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Substitution variable ${X} requires Fn::Sub", "phase": "LINT", "section": "Resources" }, { "ruleId": "I9001", "severity": "INFO", - "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "message": "Property 'RoleName' is create-only; updating it will cause resource replacement", "source": "ENGINE", - "resourceId": "A", - "resourceType": "AWS::S3::Bucket", - "propertyPath": "Properties.BucketName", + "resourceId": "TestRole", + "resourceType": "AWS::IAM::Role", + "propertyPath": "Properties.RoleName", "category": "Best Practice", - "startLine": 7, + "startLine": 8, "startColumn": 3, - "endLine": 7, - "endColumn": 4, + "endLine": 8, + "endColumn": 11, + "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iam.git", "ruleDescription": "Create-only property updated triggers resource replacement", "phase": "SCHEMA", "section": "Resources", @@ -278,106 +211,209 @@ { "ruleId": "I9040", "severity": "INFO", - "message": "Resource 'A' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "message": "Resource 'TestRole' of type 'AWS::IAM::Role' supports Tags but none are configured", "source": "ENGINE", - "resourceId": "A", - "resourceType": "AWS::S3::Bucket", + "resourceId": "TestRole", + "resourceType": "AWS::IAM::Role", "propertyPath": "Properties.Tags", "suggestedFix": "Add Tags to improve resource organization and cost tracking", "category": "Best Practice", - "startLine": 7, + "startLine": 8, "startColumn": 3, - "endLine": 7, - "endColumn": 4, + "endLine": 8, + "endColumn": 11, "ruleDescription": "Resource should have Tags", "phase": "LINT", "section": "Resources" + } + ] + }, + "bad/E1030_length_bad_argument.yaml": { + "filePath": "bad/E1030_length_bad_argument.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 2, + "warnings": 0, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ { - "ruleId": "E3019", + "ruleId": "E1019", "severity": "ERROR", - "message": "Primary identifiers {'BucketName': 'shared-name'} should have unique values across the resources {'A', 'B', 'C', 'D'}", - "source": "CFN_LINT", - "resourceId": "B", - "resourceType": "AWS::S3::Bucket", - "propertyPath": "Properties.BucketName", - "category": "Resource", - "startLine": 11, - "startColumn": 3, - "endLine": 11, - "endColumn": 4, - "ruleDescription": "Validate that all resources have unique primary identifiers", - "phase": "LINT", - "section": "Resources" + "message": "Fn::Sub variable 'count' must resolve to a string \u2014 provide a string literal, scalar (number/boolean), or a string-producing intrinsic (Fn::Base64, Fn::FindInMap, Fn::GetAtt, Fn::GetAZs, Fn::If, Fn::ImportValue, Fn::Join, Fn::Select, Fn::Split, Fn::Sub, Ref, Fn::Transform)", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Sub variable map values must be strings or string-producing intrinsics", + "phase": "PARSE" + }, + { + "ruleId": "E1030", + "severity": "ERROR", + "message": "Fn::Length argument must be an array or one of: Ref, Fn::FindInMap, Fn::Split, Fn::If, Fn::GetAZs", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Length requires AWS::LanguageExtensions transform and a list argument", + "phase": "PARSE" }, { "ruleId": "I9001", "severity": "INFO", "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", "source": "ENGINE", - "resourceId": "B", + "resourceId": "MyBucket", "resourceType": "AWS::S3::Bucket", "propertyPath": "Properties.BucketName", "category": "Best Practice", - "startLine": 11, + "startLine": 6, "startColumn": 3, - "endLine": 11, - "endColumn": 4, + "endLine": 6, + "endColumn": 11, "ruleDescription": "Create-only property updated triggers resource replacement", "phase": "SCHEMA", "section": "Resources", "context": { - "lifecycle": "create-only" + "lifecycle": "create-only", + "resolutionSource": "dynamic (Sub:bucket-${count})" } }, { "ruleId": "I9040", "severity": "INFO", - "message": "Resource 'B' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "message": "Resource 'MyBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", "source": "ENGINE", - "resourceId": "B", + "resourceId": "MyBucket", "resourceType": "AWS::S3::Bucket", "propertyPath": "Properties.Tags", "suggestedFix": "Add Tags to improve resource organization and cost tracking", "category": "Best Practice", - "startLine": 11, + "startLine": 6, "startColumn": 3, - "endLine": 11, - "endColumn": 4, + "endLine": 6, + "endColumn": 11, "ruleDescription": "Resource should have Tags", "phase": "LINT", "section": "Resources" + } + ] + }, + "bad/E1031_tojsonstring_bad_argument.yaml": { + "filePath": "bad/E1031_tojsonstring_bad_argument.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 1, + "errors": 1, + "warnings": 0, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ { - "ruleId": "E3019", + "ruleId": "E1031", "severity": "ERROR", - "message": "Primary identifiers {'BucketName': 'shared-name'} should have unique values across the resources {'A', 'B', 'C', 'D'}", - "source": "CFN_LINT", - "resourceId": "C", + "message": "Fn::ToJsonString argument must be a non-empty array or object", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::ToJsonString requires AWS::LanguageExtensions transform", + "phase": "PARSE" + }, + { + "ruleId": "F3031", + "severity": "FATAL", + "message": "'\"literal\"' does not match pattern '^([a-z0-9][a-z0-9.-]*[a-z0-9])?$'", + "source": "SCHEMA", + "resourceId": "MyBucket", "resourceType": "AWS::S3::Bucket", "propertyPath": "Properties.BucketName", - "category": "Resource", - "startLine": 15, + "category": "Schema", + "startLine": 6, "startColumn": 3, - "endLine": 15, - "endColumn": 4, - "ruleDescription": "Validate that all resources have unique primary identifiers", - "phase": "LINT", - "section": "Resources" + "endLine": 6, + "endColumn": 11, + "ruleDescription": "Value does not match pattern", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "actualValue": "\"literal\"", + "expectedConstraint": "^([a-z0-9][a-z0-9.-]*[a-z0-9])?$" + } }, { "ruleId": "I9001", "severity": "INFO", "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", "source": "ENGINE", - "resourceId": "C", + "resourceId": "MyBucket", "resourceType": "AWS::S3::Bucket", "propertyPath": "Properties.BucketName", "category": "Best Practice", - "startLine": 15, + "startLine": 6, "startColumn": 3, - "endLine": 15, - "endColumn": 4, + "endLine": 6, + "endColumn": 11, "ruleDescription": "Create-only property updated triggers resource replacement", "phase": "SCHEMA", "section": "Resources", @@ -388,88 +424,125 @@ { "ruleId": "I9040", "severity": "INFO", - "message": "Resource 'C' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "message": "Resource 'MyBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", "source": "ENGINE", - "resourceId": "C", + "resourceId": "MyBucket", "resourceType": "AWS::S3::Bucket", "propertyPath": "Properties.Tags", "suggestedFix": "Add Tags to improve resource organization and cost tracking", "category": "Best Practice", - "startLine": 15, + "startLine": 6, "startColumn": 3, - "endLine": 15, - "endColumn": 4, + "endLine": 6, + "endColumn": 11, "ruleDescription": "Resource should have Tags", "phase": "LINT", "section": "Resources" + } + ] + }, + "bad/E1033_getstackoutput_missing_outputname.yaml": { + "filePath": "bad/E1033_getstackoutput_missing_outputname.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 1, + "warnings": 0, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ { - "ruleId": "E3019", + "ruleId": "E1033", "severity": "ERROR", - "message": "Primary identifiers {'BucketName': 'shared-name'} should have unique values across the resources {'A', 'B', 'C', 'D'}", - "source": "CFN_LINT", - "resourceId": "D", - "resourceType": "AWS::S3::Bucket", - "propertyPath": "Properties.BucketName", - "category": "Resource", - "startLine": 19, - "startColumn": 3, - "endLine": 19, - "endColumn": 4, - "ruleDescription": "Validate that all resources have unique primary identifiers", - "phase": "LINT", - "section": "Resources" + "message": "Fn::GetStackOutput is missing required key 'OutputName'", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::GetStackOutput requires AWS::LanguageExtensions and StackName/OutputName keys", + "phase": "PARSE" }, { "ruleId": "I9001", "severity": "INFO", "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", "source": "ENGINE", - "resourceId": "D", + "resourceId": "MyBucket", "resourceType": "AWS::S3::Bucket", "propertyPath": "Properties.BucketName", "category": "Best Practice", - "startLine": 19, + "startLine": 6, "startColumn": 3, - "endLine": 19, - "endColumn": 4, + "endLine": 6, + "endColumn": 11, "ruleDescription": "Create-only property updated triggers resource replacement", "phase": "SCHEMA", "section": "Resources", "context": { - "lifecycle": "create-only" + "lifecycle": "create-only", + "resolutionSource": "parameter 'cross-stack output' (type String)" } }, { "ruleId": "I9040", "severity": "INFO", - "message": "Resource 'D' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "message": "Resource 'MyBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", "source": "ENGINE", - "resourceId": "D", + "resourceId": "MyBucket", "resourceType": "AWS::S3::Bucket", "propertyPath": "Properties.Tags", "suggestedFix": "Add Tags to improve resource organization and cost tracking", "category": "Best Practice", - "startLine": 19, + "startLine": 6, "startColumn": 3, - "endLine": 19, - "endColumn": 4, + "endLine": 6, + "endColumn": 11, "ruleDescription": "Resource should have Tags", "phase": "LINT", "section": "Resources" } ] }, - "bad/F2002_ssm_parameter_type_invalid.yaml": { - "filePath": "bad/F2002_ssm_parameter_type_invalid.yaml", + "bad/E1033_getstackoutput_no_langext.yaml": { + "filePath": "bad/E1033_getstackoutput_no_langext.yaml", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { - "fatal": 1, - "errors": 0, + "fatal": 0, + "errors": 1, "warnings": 0, "informational": 2, "debug": 0 @@ -503,49 +576,2117 @@ }, "diagnostics": [ { - "ruleId": "F2002", - "severity": "FATAL", - "message": "Parameter 'BadType' has invalid Type 'AWS::SSM::Parameter::Type'", + "ruleId": "E1033", + "severity": "ERROR", + "message": "Fn::GetStackOutput requires the AWS::LanguageExtensions transform", "source": "SCHEMA", - "category": "Parameter", - "ruleDescription": "Parameter Type must be valid", - "phase": "SCHEMA" + "category": "Intrinsic Function", + "ruleDescription": "Fn::GetStackOutput requires AWS::LanguageExtensions and StackName/OutputName keys", + "phase": "PARSE" }, { "ruleId": "I9001", "severity": "INFO", "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", "source": "ENGINE", - "resourceId": "Bucket", + "resourceId": "MyBucket", "resourceType": "AWS::S3::Bucket", "propertyPath": "Properties.BucketName", "category": "Best Practice", - "startLine": 9, + "startLine": 5, "startColumn": 3, - "endLine": 9, - "endColumn": 9, + "endLine": 5, + "endColumn": 11, "ruleDescription": "Create-only property updated triggers resource replacement", "phase": "SCHEMA", "section": "Resources", "context": { "lifecycle": "create-only", - "resolutionSource": "parameter 'parameter BadType value unknown' (type AWS::SSM::Parameter::Type)" + "resolutionSource": "parameter 'cross-stack output' (type String)" } }, { "ruleId": "I9040", "severity": "INFO", - "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "message": "Resource 'MyBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", "source": "ENGINE", - "resourceId": "Bucket", + "resourceId": "MyBucket", "resourceType": "AWS::S3::Bucket", "propertyPath": "Properties.Tags", "suggestedFix": "Add Tags to improve resource organization and cost tracking", "category": "Best Practice", - "startLine": 9, + "startLine": 5, "startColumn": 3, - "endLine": 9, - "endColumn": 9, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E1050_dynamic_ref_malformed.yaml": { + "filePath": "bad/E1050_dynamic_ref_malformed.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 2, + "counts": { + "fatal": 0, + "errors": 2, + "warnings": 0, + "informational": 7, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E1050", + "severity": "ERROR", + "message": "Dynamic reference '{{resolve:ssm:my param with spaces}}' does not match the required format for 'ssm'", + "source": "SCHEMA", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "suggestedFix": "Check the dynamic reference syntax", + "category": "Intrinsic Function", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Dynamic reference must match the SSM, ssm-secure, or Secrets Manager format", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only", + "resolutionSource": "dynamic (dynamic reference: {{resolve:ssm:my param with spaces}})" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'MyBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "E1050", + "severity": "ERROR", + "message": "Dynamic reference '{{resolve:secretsmanager:my-secret:InvalidField}}' does not match the required format for 'secretsmanager'", + "source": "SCHEMA", + "resourceId": "MyQueue", + "resourceType": "AWS::SQS::Queue", + "propertyPath": "Properties.QueueName", + "suggestedFix": "Check the dynamic reference syntax", + "category": "Intrinsic Function", + "startLine": 10, + "startColumn": 3, + "endLine": 10, + "endColumn": 10, + "ruleDescription": "Dynamic reference must match the SSM, ssm-secure, or Secrets Manager format", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I3011", + "severity": "INFO", + "message": "'DeletionPolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)", + "source": "CFN_LINT", + "resourceId": "MyQueue", + "resourceType": "AWS::SQS::Queue", + "category": "Best Practice", + "startLine": 10, + "startColumn": 3, + "endLine": 10, + "endColumn": 10, + "ruleDescription": "Check stateful resources have a set UpdateReplacePolicy/DeletionPolicy", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I3011", + "severity": "INFO", + "message": "'UpdateReplacePolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)", + "source": "CFN_LINT", + "resourceId": "MyQueue", + "resourceType": "AWS::SQS::Queue", + "category": "Best Practice", + "startLine": 10, + "startColumn": 3, + "endLine": 10, + "endColumn": 10, + "ruleDescription": "Check stateful resources have a set UpdateReplacePolicy/DeletionPolicy", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I3013", + "severity": "INFO", + "message": "'MessageRetentionPeriod' 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)", + "source": "CFN_LINT", + "resourceId": "MyQueue", + "resourceType": "AWS::SQS::Queue", + "propertyPath": "Properties.MessageRetentionPeriod", + "category": "Best Practice", + "startLine": 10, + "startColumn": 3, + "endLine": 10, + "endColumn": 10, + "ruleDescription": "Check resources with auto expiring content have explicit retention period", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'QueueName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "MyQueue", + "resourceType": "AWS::SQS::Queue", + "propertyPath": "Properties.QueueName", + "category": "Best Practice", + "startLine": 10, + "startColumn": 3, + "endLine": 10, + "endColumn": 10, + "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-sqs.git", + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only", + "resolutionSource": "dynamic (dynamic reference: {{resolve:secretsmanager:my-secret:InvalidField}})" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'MyQueue' of type 'AWS::SQS::Queue' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "MyQueue", + "resourceType": "AWS::SQS::Queue", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 10, + "startColumn": 3, + "endLine": 10, + "endColumn": 10, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E1058_split_dynamic_ref.yaml": { + "filePath": "bad/E1058_split_dynamic_ref.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 1, + "warnings": 0, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E1058", + "severity": "ERROR", + "message": "Fn::Split delimiter must not be a dynamic reference", + "source": "ENGINE", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Intrinsic Function", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Fn::Split delimiter must not be a dynamic reference", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only", + "resolutionSource": "dynamic (Select with unresolvable arguments)" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'MyBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E1059_base64_invalid_nested.yaml": { + "filePath": "bad/E1059_base64_invalid_nested.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 2, + "counts": { + "fatal": 0, + "errors": 2, + "warnings": 0, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E1021", + "severity": "ERROR", + "message": "Fn::Base64 argument must be a string or a string-producing intrinsic (Fn::Base64, Fn::Cidr, Fn::FindInMap, Fn::GetAtt, Fn::GetStackOutput, Fn::If, Fn::ImportValue, Fn::Join, Fn::Length, Fn::Select, Fn::Sub, Fn::ToJsonString, Fn::Transform, Ref)", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Base64 argument must be a string or a string-producing intrinsic", + "phase": "PARSE" + }, + { + "ruleId": "E1059", + "severity": "ERROR", + "message": "Fn::Base64 does not support nested function 'Fn::Contains'", + "source": "ENGINE", + "resourceId": "MyFunction", + "resourceType": "AWS::Lambda::Function", + "propertyPath": "Properties.Environment.Variables.Encoded", + "category": "Intrinsic Function", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 13, + "ruleDescription": "Fn::Base64 nested function must be on the allowed list", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'MyFunction' of type 'AWS::Lambda::Function' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "MyFunction", + "resourceType": "AWS::Lambda::Function", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 13, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'LambdaRole' of type 'AWS::IAM::Role' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "LambdaRole", + "resourceType": "AWS::IAM::Role", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 22, + "startColumn": 3, + "endLine": 22, + "endColumn": 13, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E1106_condition_cycle.yaml": { + "filePath": "bad/E1106_condition_cycle.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 1, + "warnings": 2, + "informational": 1, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E1106", + "severity": "ERROR", + "message": "Cycle detected in condition reference graph: CondA -> CondB -> CondA", + "source": "ENGINE", + "category": "Structure", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 8, + "ruleDescription": "Cycle detected between condition definitions in the Conditions section", + "phase": "PARSE" + }, + { + "ruleId": "W8003", + "severity": "WARN", + "message": "Fn::Equals in condition 'CondA' will always return True", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 8, + "ruleDescription": "Fn::Equals will always return true or false", + "phase": "PARSE" + }, + { + "ruleId": "W8003", + "severity": "WARN", + "message": "Fn::Equals in condition 'CondB' will always return True", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 9, + "startColumn": 3, + "endLine": 9, + "endColumn": 8, + "ruleDescription": "Fn::Equals will always return true or false", + "phase": "PARSE" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'MyBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 14, + "startColumn": 3, + "endLine": 14, + "endColumn": 11, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E1150_network_interfaces_groupset_multi.yaml": { + "filePath": "bad/E1150_network_interfaces_groupset_multi.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 4, + "warnings": 1, + "informational": 3, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E1150", + "severity": "ERROR", + "message": "'sg-BADBAD1' does not match format 'AWS::EC2::SecurityGroup.Id'", + "source": "CFN_LINT", + "resourceId": "Instance", + "resourceType": "AWS::EC2::Instance", + "propertyPath": "Properties.NetworkInterfaces.0.GroupSet.0", + "category": "Intrinsic Function", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 11, + "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", + "ruleDescription": "Validate security group format", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "actualValue": "sg-BADBAD1" + } + }, + { + "ruleId": "E1150", + "severity": "ERROR", + "message": "'sg-BADBAD2' does not match format 'AWS::EC2::SecurityGroup.Id'", + "source": "CFN_LINT", + "resourceId": "Instance", + "resourceType": "AWS::EC2::Instance", + "propertyPath": "Properties.NetworkInterfaces.0.GroupSet.1", + "category": "Intrinsic Function", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 11, + "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", + "ruleDescription": "Validate security group format", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "actualValue": "sg-BADBAD2" + } + }, + { + "ruleId": "E1150", + "severity": "ERROR", + "message": "'sg-BADBAD1' is not a 'AWS::EC2::SecurityGroup.Id' with pattern '^sg-([a-fA-F0-9]{8}|[a-fA-F0-9]{17})$'", + "source": "CFN_LINT", + "resourceId": "Instance", + "resourceType": "AWS::EC2::Instance", + "propertyPath": "Properties.NetworkInterfaces.GroupSet", + "category": "Intrinsic Function", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 11, + "ruleDescription": "Validate security group format", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "E1150", + "severity": "ERROR", + "message": "'sg-BADBAD2' is not a 'AWS::EC2::SecurityGroup.Id' with pattern '^sg-([a-fA-F0-9]{8}|[a-fA-F0-9]{17})$'", + "source": "CFN_LINT", + "resourceId": "Instance", + "resourceType": "AWS::EC2::Instance", + "propertyPath": "Properties.NetworkInterfaces.GroupSet", + "category": "Intrinsic Function", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 11, + "ruleDescription": "Validate security group format", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "W9010", + "severity": "WARN", + "message": "Hardcoded AMI ID \u2014 use a parameter or mapping for portability", + "source": "ENGINE", + "resourceId": "Instance", + "resourceType": "AWS::EC2::Instance", + "propertyPath": "Properties.ImageId", + "category": "Security", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 11, + "ruleDescription": "Hardcoded AMI ID", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'ImageId' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "Instance", + "resourceType": "AWS::EC2::Instance", + "propertyPath": "Properties.ImageId", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 11, + "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'NetworkInterfaces' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "Instance", + "resourceType": "AWS::EC2::Instance", + "propertyPath": "Properties.NetworkInterfaces", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 11, + "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Instance' of type 'AWS::EC2::Instance' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Instance", + "resourceType": "AWS::EC2::Instance", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 11, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E3019_four_way_group.yaml": { + "filePath": "bad/E3019_four_way_group.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 4, + "counts": { + "fatal": 0, + "errors": 4, + "warnings": 0, + "informational": 8, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E3019", + "severity": "ERROR", + "message": "Primary identifiers {'BucketName': 'shared-name'} should have unique values across the resources {'A', 'B', 'C', 'D'}", + "source": "CFN_LINT", + "resourceId": "A", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Resource", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 4, + "ruleDescription": "Validate that all resources have unique primary identifiers", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "A", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 4, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'A' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "A", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 4, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "E3019", + "severity": "ERROR", + "message": "Primary identifiers {'BucketName': 'shared-name'} should have unique values across the resources {'A', 'B', 'C', 'D'}", + "source": "CFN_LINT", + "resourceId": "B", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Resource", + "startLine": 11, + "startColumn": 3, + "endLine": 11, + "endColumn": 4, + "ruleDescription": "Validate that all resources have unique primary identifiers", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "B", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 11, + "startColumn": 3, + "endLine": 11, + "endColumn": 4, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'B' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "B", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 11, + "startColumn": 3, + "endLine": 11, + "endColumn": 4, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "E3019", + "severity": "ERROR", + "message": "Primary identifiers {'BucketName': 'shared-name'} should have unique values across the resources {'A', 'B', 'C', 'D'}", + "source": "CFN_LINT", + "resourceId": "C", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Resource", + "startLine": 15, + "startColumn": 3, + "endLine": 15, + "endColumn": 4, + "ruleDescription": "Validate that all resources have unique primary identifiers", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "C", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 15, + "startColumn": 3, + "endLine": 15, + "endColumn": 4, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'C' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "C", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 15, + "startColumn": 3, + "endLine": 15, + "endColumn": 4, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "E3019", + "severity": "ERROR", + "message": "Primary identifiers {'BucketName': 'shared-name'} should have unique values across the resources {'A', 'B', 'C', 'D'}", + "source": "CFN_LINT", + "resourceId": "D", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Resource", + "startLine": 19, + "startColumn": 3, + "endLine": 19, + "endColumn": 4, + "ruleDescription": "Validate that all resources have unique primary identifiers", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "D", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 19, + "startColumn": 3, + "endLine": 19, + "endColumn": 4, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'D' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "D", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 19, + "startColumn": 3, + "endLine": 19, + "endColumn": 4, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E8001_invalid_condition_shape.yaml": { + "filePath": "bad/E8001_invalid_condition_shape.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 1, + "warnings": 2, + "informational": 1, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E8001", + "severity": "ERROR", + "message": "Condition 'BadString' must be a single-key mapping with one of: Fn::Equals, Fn::And, Fn::Or, Fn::Not, Condition", + "source": "SCHEMA", + "category": "Structure", + "ruleDescription": "Conditions section must have valid structure", + "phase": "PARSE" + }, + { + "ruleId": "W2001", + "severity": "WARN", + "message": "Parameter 'Env' is not referenced anywhere in the template", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 3, + "startColumn": 1, + "endLine": 3, + "endColumn": 11, + "ruleDescription": "Check if Parameters are Used", + "phase": "LINT", + "section": "Parameters" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'BadString' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 6, + "startColumn": 1, + "endLine": 6, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 9, + "startColumn": 3, + "endLine": 9, + "endColumn": 9, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E8004_and_too_few.yaml": { + "filePath": "bad/E8004_and_too_few.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 1, + "warnings": 1, + "informational": 1, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And must have between 2 and 10 elements, found 1", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", + "phase": "PARSE" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'OnlyOne' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 6, + "startColumn": 1, + "endLine": 6, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 15, + "startColumn": 3, + "endLine": 15, + "endColumn": 9, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E8004_and_too_many.yaml": { + "filePath": "bad/E8004_and_too_many.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 1, + "warnings": 1, + "informational": 1, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And must have between 2 and 10 elements, found 11", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", + "phase": "PARSE" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'TooMany' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 26, + "startColumn": 1, + "endLine": 26, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 41, + "startColumn": 3, + "endLine": 41, + "endColumn": 9, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E8005_not_too_many.yaml": { + "filePath": "bad/E8005_not_too_many.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 1, + "warnings": 1, + "informational": 1, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E8005", + "severity": "ERROR", + "message": "Fn::Not: must have exactly 1 element, got 2", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Not must take exactly one boolean condition", + "phase": "PARSE" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'NotBad' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 6, + "startColumn": 1, + "endLine": 6, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 16, + "startColumn": 3, + "endLine": 16, + "endColumn": 9, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E8006_or_too_few.yaml": { + "filePath": "bad/E8006_or_too_few.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 1, + "warnings": 1, + "informational": 1, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E8006", + "severity": "ERROR", + "message": "Fn::Or must have between 2 and 10 elements, found 1", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Or must take between 2 and 10 boolean conditions", + "phase": "PARSE" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'OnlyOne' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 6, + "startColumn": 1, + "endLine": 6, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 15, + "startColumn": 3, + "endLine": 15, + "endColumn": 9, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/E8007_condition_undefined.yaml": { + "filePath": "bad/E8007_condition_undefined.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 1, + "warnings": 1, + "informational": 1, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E8007", + "severity": "ERROR", + "message": "Condition 'Nope' is not defined", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Condition function value must be a string referencing a defined condition", + "phase": "PARSE" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'Combined' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 6, + "startColumn": 1, + "endLine": 6, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 16, + "startColumn": 3, + "endLine": 16, + "endColumn": 9, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/F2002_ssm_parameter_type_invalid.yaml": { + "filePath": "bad/F2002_ssm_parameter_type_invalid.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 1, + "errors": 0, + "warnings": 0, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "F2002", + "severity": "FATAL", + "message": "Parameter 'BadType' has invalid Type 'AWS::SSM::Parameter::Type'", + "source": "SCHEMA", + "category": "Parameter", + "ruleDescription": "Parameter Type must be valid", + "phase": "SCHEMA" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 9, + "startColumn": 3, + "endLine": 9, + "endColumn": 9, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only", + "resolutionSource": "parameter 'parameter BadType value unknown' (type AWS::SSM::Parameter::Type)" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 9, + "startColumn": 3, + "endLine": 9, + "endColumn": 9, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/W1019_sub_unused_key.yaml": { + "filePath": "bad/W1019_sub_unused_key.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 2, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "W1019", + "severity": "WARN", + "message": "Parameter 'AnotherUnused' in Fn::Sub variable map is not referenced in the template string", + "source": "CFN_LINT", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Fn::Sub parameter map keys must be referenced in the template string", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "W1019", + "severity": "WARN", + "message": "Parameter 'UnusedKey' in Fn::Sub variable map is not referenced in the template string", + "source": "CFN_LINT", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Fn::Sub parameter map keys must be referenced in the template string", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'MyBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "MyBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/W1051_secretsmanager_at_arn.yaml": { + "filePath": "bad/W1051_secretsmanager_at_arn.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 2, + "errors": 0, + "warnings": 3, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "F3002", + "severity": "FATAL", + "message": "Additional properties are not allowed ('SecretsManagerAccessRoleArn' was unexpected)", + "source": "SCHEMA", + "resourceId": "Endpoint", + "resourceType": "AWS::DMS::Endpoint", + "propertyPath": "Properties.SecretsManagerAccessRoleArn", + "category": "Schema", + "startLine": 8, + "startColumn": 3, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Additional properties are not allowed", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "property": "SecretsManagerAccessRoleArn", + "extra": { + "allowed_properties": [ + "CertificateArn", + "DatabaseName", + "DocDbSettings", + "DynamoDbSettings", + "ElasticsearchSettings", + "EndpointIdentifier", + "EndpointType", + "EngineName", + "ExternalId", + "ExtraConnectionAttributes", + "GcpMySQLSettings", + "IbmDb2Settings", + "Id", + "KafkaSettings", + "KinesisSettings", + "KmsKeyId", + "MicrosoftSqlServerSettings", + "MongoDbSettings", + "MySqlSettings", + "NeptuneSettings", + "OracleSettings", + "Password", + "Port", + "PostgreSqlSettings", + "RedisSettings", + "RedshiftSettings", + "ResourceIdentifier", + "S3Settings", + "ServerName", + "SslMode", + "SybaseSettings", + "Tags", + "Username" + ] + } + } + }, + { + "ruleId": "F3002", + "severity": "FATAL", + "message": "Additional properties are not allowed ('SecretsManagerSecretId' was unexpected)", + "source": "SCHEMA", + "resourceId": "Endpoint", + "resourceType": "AWS::DMS::Endpoint", + "propertyPath": "Properties.SecretsManagerSecretId", + "category": "Schema", + "startLine": 8, + "startColumn": 3, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Additional properties are not allowed", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "property": "SecretsManagerSecretId", + "resolutionSource": "dynamic (dynamic reference: {{resolve:secretsmanager:MySecret:SecretString:secret_arn}})", + "extra": { + "allowed_properties": [ + "CertificateArn", + "DatabaseName", + "DocDbSettings", + "DynamoDbSettings", + "ElasticsearchSettings", + "EndpointIdentifier", + "EndpointType", + "EngineName", + "ExternalId", + "ExtraConnectionAttributes", + "GcpMySQLSettings", + "IbmDb2Settings", + "Id", + "KafkaSettings", + "KinesisSettings", + "KmsKeyId", + "MicrosoftSqlServerSettings", + "MongoDbSettings", + "MySqlSettings", + "NeptuneSettings", + "OracleSettings", + "Password", + "Port", + "PostgreSqlSettings", + "RedisSettings", + "RedshiftSettings", + "ResourceIdentifier", + "S3Settings", + "ServerName", + "SslMode", + "SybaseSettings", + "Tags", + "Username" + ] + } + } + }, + { + "ruleId": "W1051", + "severity": "WARN", + "message": "Dynamic reference to Secrets Manager resolves to the secret value, not the ARN \u2014 field 'SecretsManagerSecretId' expects an ARN", + "source": "CFN_LINT", + "resourceId": "Endpoint", + "resourceType": "AWS::DMS::Endpoint", + "propertyPath": "Properties.SecretsManagerSecretId", + "suggestedFix": "Use the secret ARN directly instead of a dynamic reference", + "category": "Best Practice", + "startLine": 8, + "startColumn": 3, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Secrets Manager dynamic reference resolves to value, not ARN \u2014 placing it where an ARN is expected is incorrect", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "W9002", + "severity": "WARN", + "message": "Property 'SecretsManagerAccessRoleArn' has a hardcoded ARN \u2014 use Ref, GetAtt, or a parameter instead", + "source": "ENGINE", + "resourceId": "Endpoint", + "resourceType": "AWS::DMS::Endpoint", + "propertyPath": "Properties.SecretsManagerAccessRoleArn", + "category": "Best Practice", + "startLine": 8, + "startColumn": 3, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Hardcoded ARN property", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "W9013", + "severity": "WARN", + "message": "Hardcoded account ID in ARN \u2014 use AWS::AccountId pseudo-parameter", + "source": "ENGINE", + "resourceId": "Endpoint", + "resourceType": "AWS::DMS::Endpoint", + "category": "Security", + "startLine": 8, + "startColumn": 3, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Hardcoded account ID in ARN", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I3042", + "severity": "INFO", + "message": "Hardcoded partition 'aws' in ARN \u2014 use AWS::Partition pseudo-parameter for portability", + "source": "CFN_LINT", + "resourceId": "Endpoint", + "resourceType": "AWS::DMS::Endpoint", + "category": "Best Practice", + "startLine": 8, + "startColumn": 3, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "ARNs should use correctly placed Pseudo Parameters", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Endpoint' of type 'AWS::DMS::Endpoint' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Endpoint", + "resourceType": "AWS::DMS::Endpoint", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 8, + "startColumn": 3, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/W1054_raw_pseudo_param.yaml": { + "filePath": "bad/W1054_raw_pseudo_param.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 2, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 2, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "W1054", + "severity": "WARN", + "message": "String value 'AWS::Region' is the pseudo-parameter 'AWS::Region' used as a literal \u2014 use Ref to resolve it instead", + "source": "CFN_LINT", + "resourceId": "MyTopic", + "resourceType": "AWS::SNS::Topic", + "propertyPath": "Properties.DisplayName", + "suggestedFix": "Use {Ref: AWS::Region} instead of the literal string", + "category": "Best Practice", + "startLine": 9, + "startColumn": 3, + "endLine": 9, + "endColumn": 10, + "ruleDescription": "Pseudo-parameter referenced as a raw string instead of via Ref", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'MyTopic' of type 'AWS::SNS::Topic' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "MyTopic", + "resourceType": "AWS::SNS::Topic", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 9, + "startColumn": 3, + "endLine": 9, + "endColumn": 10, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "W1054", + "severity": "WARN", + "message": "String value 'AWS::AccountId' is the pseudo-parameter 'AWS::AccountId' used as a literal \u2014 use Ref to resolve it instead", + "source": "CFN_LINT", + "resourceId": "MyParameter", + "resourceType": "AWS::SSM::Parameter", + "propertyPath": "Properties.Value", + "suggestedFix": "Use {Ref: AWS::AccountId} instead of the literal string", + "category": "Best Practice", + "startLine": 13, + "startColumn": 3, + "endLine": 13, + "endColumn": 14, + "ruleDescription": "Pseudo-parameter referenced as a raw string instead of via Ref", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'MyParameter' of type 'AWS::SSM::Parameter' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "MyParameter", + "resourceType": "AWS::SSM::Parameter", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 13, + "startColumn": 3, + "endLine": 13, + "endColumn": 14, "ruleDescription": "Resource should have Tags", "phase": "LINT", "section": "Resources" @@ -557,7 +2698,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 9, "counts": { "fatal": 0, @@ -1229,12 +3370,348 @@ } ] }, + "bad/W8001_unused_lang_ext_conditions.json": { + "filePath": "bad/W8001_unused_lang_ext_conditions.json", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 4, + "informational": 0, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'IsParamAEnabled' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 34, + "startColumn": 5, + "endLine": 34, + "endColumn": 16, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'IsParamBEnabled' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 34, + "startColumn": 5, + "endLine": 34, + "endColumn": 16, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'IsParamCEnabled' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 34, + "startColumn": 5, + "endLine": 34, + "endColumn": 16, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'IsParamDEnabled' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 34, + "startColumn": 5, + "endLine": 34, + "endColumn": 16, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + } + ] + }, + "bad/W9053_equivalent_both_forms.yaml": { + "filePath": "bad/W9053_equivalent_both_forms.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 13, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 4, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'Combined' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 7, + "startColumn": 1, + "endLine": 7, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "W9053", + "severity": "WARN", + "message": "Condition 'IsProdShort' is equivalent to condition 'IsProd' \u2014 consider using one", + "source": "ENGINE", + "category": "Best Practice", + "startLine": 12, + "startColumn": 3, + "endLine": 12, + "endColumn": 14, + "ruleDescription": "Conditions are semantically equivalent and can be consolidated", + "phase": "PARSE" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "BucketShort", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 18, + "startColumn": 3, + "endLine": 18, + "endColumn": 14, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only", + "resolutionSource": "parameter with AllowedValues" + } + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "BucketLong", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 25, + "startColumn": 3, + "endLine": 25, + "endColumn": 13, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only", + "resolutionSource": "parameter with AllowedValues" + } + }, + { + "ruleId": "W1028", + "severity": "WARN", + "message": "['Fn::If', 1] is not reachable. When setting condition 'IsProd' to True", + "source": "CFN_LINT", + "resourceId": "WithIf", + "resourceType": "Custom::IntrinsicTest", + "propertyPath": "Properties.Long.Fn::If.1", + "category": "Best Practice", + "startLine": 60, + "startColumn": 3, + "endLine": 60, + "endColumn": 9, + "ruleDescription": "Check Fn::If has a path that cannot be reached", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "W1028", + "severity": "WARN", + "message": "['Fn::If', 1] is not reachable. When setting condition 'IsProd' to True", + "source": "CFN_LINT", + "resourceId": "WithIf", + "resourceType": "Custom::IntrinsicTest", + "propertyPath": "Properties.Short.Fn::If.1", + "category": "Best Practice", + "startLine": 60, + "startColumn": 3, + "endLine": 60, + "endColumn": 9, + "ruleDescription": "Check Fn::If has a path that cannot be reached", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/W9053_equivalent_conditions.yaml": { + "filePath": "bad/W9053_equivalent_conditions.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 2, + "informational": 1, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'IsProduction' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 10, + "startColumn": 1, + "endLine": 10, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "W9053", + "severity": "WARN", + "message": "Condition 'IsProduction' is equivalent to condition 'IsProd' \u2014 consider using one", + "source": "ENGINE", + "category": "Best Practice", + "startLine": 13, + "startColumn": 3, + "endLine": 13, + "endColumn": 15, + "ruleDescription": "Conditions are semantically equivalent and can be consolidated", + "phase": "PARSE" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 16, + "startColumn": 3, + "endLine": 16, + "endColumn": 9, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, "bad/aurora_with_allocated_storage.yaml": { "filePath": "bad/aurora_with_allocated_storage.yaml", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -1401,7 +3878,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -1500,7 +3977,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -1600,7 +4077,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -1745,7 +4222,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -1890,12 +4367,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { - "fatal": 10, - "errors": 1, - "warnings": 8, + "fatal": 6, + "errors": 9, + "warnings": 9, "informational": 8, "debug": 0 }, @@ -1928,21 +4405,57 @@ }, "diagnostics": [ { - "ruleId": "F1104", - "severity": "FATAL", + "ruleId": "E1028", + "severity": "ERROR", "message": "Fn::If references undefined condition 'isDev'", "source": "SCHEMA", - "category": "Structure", - "ruleDescription": "Duplicate logical ID in template", + "category": "Intrinsic Function", + "ruleDescription": "Fn::If condition must exist in Conditions section", "phase": "PARSE" }, { - "ruleId": "F1104", - "severity": "FATAL", + "ruleId": "E1028", + "severity": "ERROR", "message": "Fn::If references undefined condition 'isProd'", "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::If condition must exist in Conditions section", + "phase": "PARSE" + }, + { + "ruleId": "E8001", + "severity": "ERROR", + "message": "Condition 'BadCondition' must be a single-key mapping with one of: Fn::Equals, Fn::And, Fn::Or, Fn::Not, Condition", + "source": "SCHEMA", + "category": "Structure", + "ruleDescription": "Conditions section must have valid structure", + "phase": "PARSE" + }, + { + "ruleId": "E8001", + "severity": "ERROR", + "message": "Condition 'HasParam' must be a single-key mapping with one of: Fn::Equals, Fn::And, Fn::Or, Fn::Not, Condition", + "source": "SCHEMA", + "category": "Structure", + "ruleDescription": "Conditions section must have valid structure", + "phase": "PARSE" + }, + { + "ruleId": "E8001", + "severity": "ERROR", + "message": "Condition 'NullCondition' must be a single-key mapping with one of: Fn::Equals, Fn::And, Fn::Or, Fn::Not, Condition", + "source": "SCHEMA", + "category": "Structure", + "ruleDescription": "Conditions section must have valid structure", + "phase": "PARSE" + }, + { + "ruleId": "E8001", + "severity": "ERROR", + "message": "Condition 'TooManyConditions' must be a single-key mapping with one of: Fn::Equals, Fn::And, Fn::Or, Fn::Not, Condition", + "source": "SCHEMA", "category": "Structure", - "ruleDescription": "Duplicate logical ID in template", + "ruleDescription": "Conditions section must have valid structure", "phase": "PARSE" }, { @@ -2016,38 +4529,17 @@ "section": "Conditions" }, { - "ruleId": "F1060", - "severity": "FATAL", - "message": "Fn::If condition 'isDev' does not exist in Conditions section", - "source": "SCHEMA", - "resourceId": "EC2Instance", - "resourceType": "AWS::EC2::Instance", - "category": "Intrinsic Function", - "startLine": 54, - "startColumn": 3, - "endLine": 54, - "endColumn": 14, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", - "ruleDescription": "Fn::If condition must exist in Conditions", - "phase": "SCHEMA", - "section": "Resources" - }, - { - "ruleId": "F1060", - "severity": "FATAL", - "message": "Fn::If condition 'isProd' does not exist in Conditions section", - "source": "SCHEMA", - "resourceId": "EC2Instance", - "resourceType": "AWS::EC2::Instance", - "category": "Intrinsic Function", - "startLine": 54, + "ruleId": "W9053", + "severity": "WARN", + "message": "Condition 'UnusedCondition' is equivalent to condition 'CreateProdResources' \u2014 consider using one", + "source": "ENGINE", + "category": "Best Practice", + "startLine": 43, "startColumn": 3, - "endLine": 54, - "endColumn": 14, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", - "ruleDescription": "Fn::If condition must exist in Conditions", - "phase": "SCHEMA", - "section": "Resources" + "endLine": 43, + "endColumn": 18, + "ruleDescription": "Conditions are semantically equivalent and can be consolidated", + "phase": "PARSE" }, { "ruleId": "F3002", @@ -2195,6 +4687,38 @@ } } }, + { + "ruleId": "E1028", + "severity": "ERROR", + "message": "Fn::If condition 'isDev' does not exist in Conditions section", + "source": "SCHEMA", + "resourceId": "EC2Instance", + "resourceType": "AWS::EC2::Instance", + "category": "Intrinsic Function", + "startLine": 54, + "startColumn": 3, + "endLine": 54, + "endColumn": 14, + "ruleDescription": "Fn::If condition must exist in Conditions section", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "E1028", + "severity": "ERROR", + "message": "Fn::If condition 'isProd' does not exist in Conditions section", + "source": "SCHEMA", + "resourceId": "EC2Instance", + "resourceType": "AWS::EC2::Instance", + "category": "Intrinsic Function", + "startLine": 54, + "startColumn": 3, + "endLine": 54, + "endColumn": 14, + "ruleDescription": "Fn::If condition must exist in Conditions section", + "phase": "LINT", + "section": "Resources" + }, { "ruleId": "W9010", "severity": "WARN", @@ -2473,7 +4997,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'EnableGeoBlocking' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'EnableGeoBlocking' to False", "source": "CFN_LINT", "resourceId": "CloudFrontDistribution", "resourceType": "AWS::CloudFront::Distribution", @@ -2532,16 +5056,16 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { - "fatal": 9, - "errors": 0, - "warnings": 6, + "fatal": 1, + "errors": 9, + "warnings": 4, "informational": 0, "debug": 0 }, - "suppressed": 0, + "suppressed": 10, "strict": false, "severityLevel": "DEBUG" }, @@ -2570,75 +5094,75 @@ }, "diagnostics": [ { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: 'And' is not of type 'array'", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And must have between 2 and 10 elements, found 1", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: element 0: 'Test' is not of type 'boolean'", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And must have between 2 and 10 elements, found 11", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: element 0: {'Condition': 'TestAndToMany', 'Extra': 'ThisIsBad'} is not of type 'boolean'", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: 'And' is not of type 'array'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: element 1: 'Test2' is not of type 'boolean'", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: element 0: 'Test' is not of type 'boolean'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: element 1: {'Bad': 'TestAndToMany'} is not of type 'boolean'", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: element 0: {'Condition': 'TestAndToMany', 'Extra': 'ThisIsBad'} is not of type 'boolean'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: expected maximum item count: 10, found: 11", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: element 1: 'Test2' is not of type 'boolean'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: expected minimum item count: 2, found: 1", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: element 1: {'Bad': 'TestAndToMany'} is not of type 'boolean'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8004", + "severity": "ERROR", "message": "Fn::And: null is not of type 'array'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { @@ -2698,32 +5222,17 @@ "section": "Conditions" }, { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'TestAndToLittle' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 4, - "startColumn": 1, - "endLine": 4, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'TestAndToMany' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 4, - "startColumn": 1, - "endLine": 4, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" + "ruleId": "E1106", + "severity": "ERROR", + "message": "Cycle detected in condition reference graph: TestAndToLittle -> TestAndToMany -> TestAndToLittle", + "source": "ENGINE", + "category": "Structure", + "startLine": 6, + "startColumn": 3, + "endLine": 6, + "endColumn": 18, + "ruleDescription": "Cycle detected between condition definitions in the Conditions section", + "phase": "PARSE" }, { "ruleId": "F0001", @@ -2746,12 +5255,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { - "fatal": 17, - "errors": 0, - "warnings": 15, + "fatal": 2, + "errors": 19, + "warnings": 14, "informational": 1, "debug": 0 }, @@ -2802,138 +5311,174 @@ "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: 'And' is not of type 'array'", + "ruleId": "E8001", + "severity": "ERROR", + "message": "Condition 'TestIfNotArray' must be a single-key mapping with one of: Fn::Equals, Fn::And, Fn::Or, Fn::Not, Condition", + "source": "SCHEMA", + "category": "Structure", + "ruleDescription": "Conditions section must have valid structure", + "phase": "PARSE" + }, + { + "ruleId": "E8001", + "severity": "ERROR", + "message": "Condition 'TestIfWrongCount' must be a single-key mapping with one of: Fn::Equals, Fn::And, Fn::Or, Fn::Not, Condition", + "source": "SCHEMA", + "category": "Structure", + "ruleDescription": "Conditions section must have valid structure", + "phase": "PARSE" + }, + { + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals operand 1 is not a valid type \u2014 must be a string literal or one of: Ref, Fn::FindInMap, Fn::Sub, Fn::Join, Fn::Select, Fn::Split, Fn::Length, Fn::ToJsonString", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: element 0: 'Test' is not of type 'boolean'", + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals operand 2 is not a valid type \u2014 must be a string literal or one of: Ref, Fn::FindInMap, Fn::Sub, Fn::Join, Fn::Select, Fn::Split, Fn::Length, Fn::ToJsonString", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: element 0: {\"Condition\":\"TestAndString\",\"Extra\":\"ThisIsBad\"} is not of type 'boolean'", + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals: 'Value' is not of type 'array'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: element 1: 'Test2' is not of type 'boolean'", + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals: argument 0: [{\"Ref\":\"AWS::Region\"}] is not of type 'string'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: element 1: {\"Bad\":\"TestAndString\"} is not of type 'boolean'", + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals: argument 1: {\"Bad\":\"Value\"} is not of type 'string'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: expected minimum item count: 2, found: 1", + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals: expected maximum item count: 2, found: 3", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: null is not of type 'array'", + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals: expected minimum item count: 2, found: 1", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::Equals: 'Value' is not of type 'array'", + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals: null is not of type 'array'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::Equals: argument 0: [{\"Ref\":\"AWS::Region\"}] is not of type 'string'", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And must have between 2 and 10 elements, found 1", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::Equals: argument 1: {\"Bad\":\"Value\"} is not of type 'string'", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: 'And' is not of type 'array'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::Equals: expected maximum item count: 2, found: 3", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: element 0: 'Test' is not of type 'boolean'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::Equals: expected minimum item count: 2, found: 1", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: element 0: {\"Condition\":\"TestAndString\",\"Extra\":\"ThisIsBad\"} is not of type 'boolean'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::Equals: null is not of type 'array'", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: element 1: 'Test2' is not of type 'boolean'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: element 1: {\"Bad\":\"TestAndString\"} is not of type 'boolean'", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", + "phase": "PARSE" + }, + { + "ruleId": "E8004", + "severity": "ERROR", + "message": "Fn::And: null is not of type 'array'", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::And must take between 2 and 10 boolean conditions", + "phase": "PARSE" + }, + { + "ruleId": "E8005", + "severity": "ERROR", "message": "Fn::Not: must have exactly 1 element, got 0", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Not must take exactly one boolean condition", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8005", + "severity": "ERROR", "message": "Fn::Not: null is not of type 'array'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Not must take exactly one boolean condition", "phase": "PARSE" }, { @@ -2992,20 +5537,6 @@ "phase": "LINT", "section": "Conditions" }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'TestAndString' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 8, - "startColumn": 5, - "endLine": 8, - "endColumn": 16, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, { "ruleId": "W8001", "severity": "WARN", @@ -3171,16 +5702,16 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { - "fatal": 9, - "errors": 0, + "fatal": 1, + "errors": 10, "warnings": 8, "informational": 0, "debug": 0 }, - "suppressed": 0, + "suppressed": 2, "strict": false, "severityLevel": "DEBUG" }, @@ -3209,75 +5740,93 @@ }, "diagnostics": [ { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals operand 1 is not a valid type \u2014 must be a string literal or one of: Ref, Fn::FindInMap, Fn::Sub, Fn::Join, Fn::Select, Fn::Split, Fn::Length, Fn::ToJsonString", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", + "phase": "PARSE" + }, + { + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals operand 2 is not a valid type \u2014 must be a string literal or one of: Ref, Fn::FindInMap, Fn::Sub, Fn::Join, Fn::Select, Fn::Split, Fn::Length, Fn::ToJsonString", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", + "phase": "PARSE" + }, + { + "ruleId": "E8003", + "severity": "ERROR", "message": "Fn::Equals: 'Value' is not of type 'array'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8003", + "severity": "ERROR", "message": "Fn::Equals: argument 0: [{'Ref': 'AWS::Region'}] is not of type 'string'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8003", + "severity": "ERROR", "message": "Fn::Equals: argument 0: {'Ref': 'AWS::Region', 'Fn::Select': ['Environments', 0]} is not of type 'string'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8003", + "severity": "ERROR", "message": "Fn::Equals: argument 1: ['Not a List'] is not of type 'string'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8003", + "severity": "ERROR", "message": "Fn::Equals: argument 1: {'Bad': 'Value'} is not of type 'string'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8003", + "severity": "ERROR", "message": "Fn::Equals: expected maximum item count: 2, found: 3", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8003", + "severity": "ERROR", "message": "Fn::Equals: expected minimum item count: 2, found: 1", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8003", + "severity": "ERROR", "message": "Fn::Equals: null is not of type 'array'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { @@ -3413,7 +5962,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -3552,7 +6101,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -3748,12 +6297,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 8, "counts": { "fatal": 4, "errors": 20, - "warnings": 8, + "warnings": 7, "informational": 15, "debug": 0 }, @@ -3845,20 +6394,6 @@ "phase": "LINT", "section": "Conditions" }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'isProductionOrStaging' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 13, - "startColumn": 1, - "endLine": 13, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, { "ruleId": "E1151", "severity": "ERROR", @@ -4205,7 +6740,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimary' to False. Where existing status for condition 'isPrimaryAndProduction' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimary' to False. Where existing status for 'isPrimaryAndProduction' is True", "source": "CFN_LINT", "resourceId": "myInstance2", "resourceType": "AWS::EC2::Instance", @@ -4530,7 +7065,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimary' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimary' to False", "source": "CFN_LINT", "resourceId": "myInstance4", "resourceType": "AWS::EC2::Instance", @@ -4704,11 +7239,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, - "errors": 0, + "errors": 1, "warnings": 1, "informational": 0, "debug": 0 @@ -4741,6 +7276,15 @@ } }, "diagnostics": [ + { + "ruleId": "E8001", + "severity": "ERROR", + "message": "Conditions section must be a mapping of condition names to boolean expressions", + "source": "SCHEMA", + "category": "Structure", + "ruleDescription": "Conditions section must have valid structure", + "phase": "PARSE" + }, { "ruleId": "W2001", "severity": "WARN", @@ -4776,12 +7320,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, - "errors": 0, - "warnings": 2, + "errors": 1, + "warnings": 1, "informational": 0, "debug": 0 }, @@ -4814,23 +7358,18 @@ }, "diagnostics": [ { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'isPrimaryAndProduction' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 7, - "startColumn": 1, - "endLine": 7, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" + "ruleId": "E8007", + "severity": "ERROR", + "message": "Condition 'isPrimary' is not defined", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Condition function value must be a string referencing a defined condition", + "phase": "PARSE" }, { "ruleId": "W8001", "severity": "WARN", - "message": "Condition 'isProduction' is not used by any resource or Fn::If", + "message": "Condition 'isPrimaryAndProduction' is not used by any resource or Fn::If", "source": "CFN_LINT", "category": "Best Practice", "startLine": 7, @@ -4862,7 +7401,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -5071,7 +7610,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -5129,7 +7668,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 6, @@ -5513,7 +8052,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 6, @@ -5897,11 +8436,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 5, - "errors": 0, + "errors": 1, "warnings": 2, "informational": 0, "debug": 0 @@ -5934,6 +8473,15 @@ } }, "diagnostics": [ + { + "ruleId": "E1022", + "severity": "ERROR", + "message": "Fn::Join list element must be a string or a string-producing intrinsic (Fn::Base64, Fn::FindInMap, Fn::GetAtt, Fn::GetStackOutput, Fn::If, Fn::ImportValue, Fn::Join, Fn::Select, Fn::Sub, Fn::Transform, Ref)", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Join requires a string delimiter and a list of strings or string-producing intrinsics", + "phase": "PARSE" + }, { "ruleId": "F1020", "severity": "FATAL", @@ -6073,7 +8621,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 11, "counts": { "fatal": 5, @@ -6963,7 +9511,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -7059,7 +9607,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 2, @@ -7186,10 +9734,10 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { - "fatal": 0, + "fatal": 2, "errors": 0, "warnings": 0, "informational": 2, @@ -7241,6 +9789,32 @@ "phase": "LINT", "section": "Resources" }, + { + "ruleId": "F0000", + "severity": "FATAL", + "message": "Duplicate key 'mySnsTopic'", + "source": "SCHEMA", + "category": "Structure", + "startLine": 10, + "startColumn": 3, + "endLine": 10, + "endColumn": 13, + "ruleDescription": "Duplicate key in template", + "phase": "PARSE" + }, + { + "ruleId": "F0000", + "severity": "FATAL", + "message": "Duplicate key 'mySnsTopic'", + "source": "SCHEMA", + "category": "Structure", + "startLine": 14, + "startColumn": 3, + "endLine": 14, + "endColumn": 13, + "ruleDescription": "Duplicate key in template", + "phase": "PARSE" + }, { "ruleId": "I9040", "severity": "INFO", @@ -7266,7 +9840,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -7420,7 +9994,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -7477,7 +10051,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsA' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsA' to False", "source": "CFN_LINT", "resourceId": "BucketA", "resourceType": "AWS::S3::Bucket", @@ -7550,7 +10124,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsA' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsA' to False", "source": "CFN_LINT", "resourceId": "BucketB", "resourceType": "AWS::S3::Bucket", @@ -7610,7 +10184,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -7742,7 +10316,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -7838,7 +10412,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -7970,7 +10544,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -8050,7 +10624,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -8172,7 +10746,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -8403,7 +10977,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -8626,7 +11200,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -8789,7 +11363,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -8960,11 +11534,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { - "fatal": 1, - "errors": 0, + "fatal": 0, + "errors": 1, "warnings": 1, "informational": 1, "debug": 0 @@ -8998,12 +11572,12 @@ }, "diagnostics": [ { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8003", + "severity": "ERROR", "message": "Fn::Equals: expected maximum item count: 2, found: 3", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { @@ -9045,7 +11619,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -9257,7 +11831,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -9562,7 +12136,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -9642,7 +12216,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -9680,154 +12254,165 @@ }, "diagnostics": [ { - "ruleId": "W1020", - "severity": "WARN", - "message": "Fn::Sub isn't needed because there are no variables", - "source": "CFN_LINT", - "resourceId": "myTable", - "resourceType": "AWS::DynamoDB::Table", - "propertyPath": "Properties.TableName", - "category": "Best Practice", - "startLine": 7, - "startColumn": 3, - "endLine": 7, - "endColumn": 10, - "ruleDescription": "Sub isn't needed if it doesn't have a variable defined", - "phase": "LINT", - "section": "Resources" - }, - { - "ruleId": "W9003", - "severity": "WARN", - "message": "'5' is not of type 'integer' \u2014 automatically coerced (string \u2192 integer)", - "source": "ENGINE", - "resourceId": "myTable", - "resourceType": "AWS::DynamoDB::Table", - "propertyPath": "Properties.ProvisionedThroughput.WriteCapacityUnits", - "category": "Best Practice", - "startLine": 7, - "startColumn": 3, - "endLine": 7, - "endColumn": 10, - "ruleDescription": "Property type coercion warning", - "phase": "SCHEMA", - "section": "Resources" - }, - { - "ruleId": "I3011", - "severity": "INFO", - "message": "'DeletionPolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)", - "source": "CFN_LINT", - "resourceId": "myTable", - "resourceType": "AWS::DynamoDB::Table", - "category": "Best Practice", - "startLine": 7, - "startColumn": 3, - "endLine": 7, - "endColumn": 10, - "ruleDescription": "Check stateful resources have a set UpdateReplacePolicy/DeletionPolicy", - "phase": "LINT", - "section": "Resources" - }, - { - "ruleId": "I3011", - "severity": "INFO", - "message": "'UpdateReplacePolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)", - "source": "CFN_LINT", - "resourceId": "myTable", - "resourceType": "AWS::DynamoDB::Table", - "category": "Best Practice", - "startLine": 7, - "startColumn": 3, - "endLine": 7, - "endColumn": 10, - "ruleDescription": "Check stateful resources have a set UpdateReplacePolicy/DeletionPolicy", + "ruleId": "W1020", + "severity": "WARN", + "message": "Fn::Sub isn't needed because there are no variables", + "source": "CFN_LINT", + "resourceId": "myTable", + "resourceType": "AWS::DynamoDB::Table", + "propertyPath": "Properties.TableName", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 10, + "ruleDescription": "Sub isn't needed if it doesn't have a variable defined", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "W9003", + "severity": "WARN", + "message": "'5' is not of type 'integer' \u2014 automatically coerced (string \u2192 integer)", + "source": "ENGINE", + "resourceId": "myTable", + "resourceType": "AWS::DynamoDB::Table", + "propertyPath": "Properties.ProvisionedThroughput.WriteCapacityUnits", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 10, + "ruleDescription": "Property type coercion warning", + "phase": "SCHEMA", + "section": "Resources" + }, + { + "ruleId": "I3011", + "severity": "INFO", + "message": "'DeletionPolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)", + "source": "CFN_LINT", + "resourceId": "myTable", + "resourceType": "AWS::DynamoDB::Table", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 10, + "ruleDescription": "Check stateful resources have a set UpdateReplacePolicy/DeletionPolicy", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I3011", + "severity": "INFO", + "message": "'UpdateReplacePolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)", + "source": "CFN_LINT", + "resourceId": "myTable", + "resourceType": "AWS::DynamoDB::Table", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 10, + "ruleDescription": "Check stateful resources have a set UpdateReplacePolicy/DeletionPolicy", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'TableName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "myTable", + "resourceType": "AWS::DynamoDB::Table", + "propertyPath": "Properties.TableName", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 10, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only", + "resolutionSource": "dynamic (Sub:TableName)" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'myTable' of type 'AWS::DynamoDB::Table' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "myTable", + "resourceType": "AWS::DynamoDB::Table", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 7, + "startColumn": 3, + "endLine": 7, + "endColumn": 10, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "bad/functions/foreach_no_transform.yaml": { + "filePath": "bad/functions/foreach_no_transform.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 1, + "errors": 2, + "warnings": 2, + "informational": 0, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "E1032", + "severity": "ERROR", + "message": "Fn::ForEach requires the AWS::LanguageExtensions transform", + "source": "SCHEMA", + "resourceId": "Fn::ForEach::BucketOutputs", + "category": "Intrinsic Function", + "ruleDescription": "Fn::ForEach requires AWS::LanguageExtensions transform", "phase": "LINT", "section": "Resources" }, - { - "ruleId": "I9001", - "severity": "INFO", - "message": "Property 'TableName' is create-only; updating it will cause resource replacement", - "source": "ENGINE", - "resourceId": "myTable", - "resourceType": "AWS::DynamoDB::Table", - "propertyPath": "Properties.TableName", - "category": "Best Practice", - "startLine": 7, - "startColumn": 3, - "endLine": 7, - "endColumn": 10, - "ruleDescription": "Create-only property updated triggers resource replacement", - "phase": "SCHEMA", - "section": "Resources", - "context": { - "lifecycle": "create-only", - "resolutionSource": "dynamic (Sub:TableName)" - } - }, - { - "ruleId": "I9040", - "severity": "INFO", - "message": "Resource 'myTable' of type 'AWS::DynamoDB::Table' supports Tags but none are configured", - "source": "ENGINE", - "resourceId": "myTable", - "resourceType": "AWS::DynamoDB::Table", - "propertyPath": "Properties.Tags", - "suggestedFix": "Add Tags to improve resource organization and cost tracking", - "category": "Best Practice", - "startLine": 7, - "startColumn": 3, - "endLine": 7, - "endColumn": 10, - "ruleDescription": "Resource should have Tags", - "phase": "LINT", - "section": "Resources" - } - ] - }, - "bad/functions/foreach_no_transform.yaml": { - "filePath": "bad/functions/foreach_no_transform.yaml", - "status": "OK", - "engineVersion": "1.1.0", - "metadata": { - "rulesEvaluated": 273, - "resourcesScanned": 1, - "counts": { - "fatal": 1, - "errors": 0, - "warnings": 2, - "informational": 0, - "debug": 0 - }, - "suppressed": 0, - "strict": false, - "severityLevel": "DEBUG" - }, - "performance": { - "schemaInit": { - "durationMs": 0.0 - }, - "engineInit": { - "durationMs": 0.0 - }, - "modelBuild": { - "durationMs": 0.0 - }, - "schemaValidate": { - "durationMs": 0.0 - }, - "ruleEvaluation": { - "durationMs": 0.0 - }, - "diagnosticFinalize": { - "durationMs": 0.0 - }, - "validateTotal": { - "durationMs": 0.0 - } - }, - "diagnostics": [ { "ruleId": "W2001", "severity": "WARN", @@ -9871,6 +12456,22 @@ "ruleDescription": "Logical IDs must be alphanumeric", "phase": "SCHEMA", "section": "Resources" + }, + { + "ruleId": "E1032", + "severity": "ERROR", + "message": "Fn::ForEach requires the AWS::LanguageExtensions transform", + "source": "SCHEMA", + "resourceId": "Fn::ForEach::Buckets", + "resourceType": "", + "category": "Intrinsic Function", + "startLine": 12, + "startColumn": 5, + "endLine": 12, + "endColumn": 25, + "ruleDescription": "Fn::ForEach requires AWS::LanguageExtensions transform", + "phase": "LINT", + "section": "Resources" } ] }, @@ -9879,16 +12480,16 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { - "fatal": 1, - "errors": 0, + "fatal": 0, + "errors": 3, "warnings": 1, "informational": 3, "debug": 0 }, - "suppressed": 0, + "suppressed": 1, "strict": false, "severityLevel": "DEBUG" }, @@ -9917,12 +12518,30 @@ }, "diagnostics": [ { - "ruleId": "F1105", - "severity": "FATAL", - "message": "'Fn::ImportValue' is not allowed inside 'Fn::Equals'", + "ruleId": "E1016", + "severity": "ERROR", + "message": "Fn::ImportValue argument must be a string or a string-producing intrinsic (Fn::Base64, Fn::FindInMap, Fn::If, Fn::Join, Fn::Select, Fn::Sub, Ref) \u2014 it must not be a list or another Fn::ImportValue", + "source": "CFN_LINT", + "category": "Intrinsic Function", + "ruleDescription": "ImportValue validation of parameters", + "phase": "PARSE" + }, + { + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals operand 2 is not a valid type \u2014 must be a string literal or one of: Ref, Fn::FindInMap, Fn::Sub, Fn::Join, Fn::Select, Fn::Split, Fn::Length, Fn::ToJsonString", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Invalid nesting of intrinsic functions", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", + "phase": "PARSE" + }, + { + "ruleId": "E8003", + "severity": "ERROR", + "message": "Fn::Equals: argument 1: {'Fn::ImportValue': 'PrimaryRegion'} is not of type 'string'", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { @@ -10006,7 +12625,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 0, @@ -10294,11 +12913,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { - "fatal": 5, - "errors": 1, + "fatal": 0, + "errors": 6, "warnings": 5, "informational": 8, "debug": 0 @@ -10332,8 +12951,8 @@ }, "diagnostics": [ { - "ruleId": "F1029", - "severity": "FATAL", + "ruleId": "E1029", + "severity": "ERROR", "message": "Found an embedded parameter \"${AMIId}\" outside of an \"Fn::Sub\" at Properties.ImageId", "source": "SCHEMA", "resourceId": "myInstance", @@ -10345,9 +12964,8 @@ "startColumn": 3, "endLine": 9, "endColumn": 13, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", - "ruleDescription": "Sub is required if a variable is used in a string", - "phase": "SCHEMA", + "ruleDescription": "Substitution variable ${X} requires Fn::Sub", + "phase": "LINT", "section": "Resources" }, { @@ -10427,8 +13045,8 @@ "section": "Resources" }, { - "ruleId": "F1029", - "severity": "FATAL", + "ruleId": "E1029", + "severity": "ERROR", "message": "Found an embedded parameter \"${AMIId}\" outside of an \"Fn::Sub\" at Properties.PolicyDocument.Statement.0.Resource", "source": "SCHEMA", "resourceId": "myPolicy", @@ -10440,14 +13058,13 @@ "startColumn": 3, "endLine": 14, "endColumn": 11, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iam.git", - "ruleDescription": "Sub is required if a variable is used in a string", - "phase": "SCHEMA", + "ruleDescription": "Substitution variable ${X} requires Fn::Sub", + "phase": "LINT", "section": "Resources" }, { - "ruleId": "F1029", - "severity": "FATAL", + "ruleId": "E1029", + "severity": "ERROR", "message": "Found an embedded parameter \"${AWS::AccountId}\" outside of an \"Fn::Sub\" at Properties.PolicyDocument.Statement.0.Resource", "source": "SCHEMA", "resourceId": "myPolicy", @@ -10459,14 +13076,13 @@ "startColumn": 3, "endLine": 14, "endColumn": 11, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iam.git", - "ruleDescription": "Sub is required if a variable is used in a string", - "phase": "SCHEMA", + "ruleDescription": "Substitution variable ${X} requires Fn::Sub", + "phase": "LINT", "section": "Resources" }, { - "ruleId": "F1029", - "severity": "FATAL", + "ruleId": "E1029", + "severity": "ERROR", "message": "Found an embedded parameter \"${AMIId}\" outside of an \"Fn::Sub\" at Properties.PolicyDocument.Statement.1.NotResource", "source": "SCHEMA", "resourceId": "myPolicy", @@ -10478,14 +13094,13 @@ "startColumn": 3, "endLine": 14, "endColumn": 11, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iam.git", - "ruleDescription": "Sub is required if a variable is used in a string", - "phase": "SCHEMA", + "ruleDescription": "Substitution variable ${X} requires Fn::Sub", + "phase": "LINT", "section": "Resources" }, { - "ruleId": "F1029", - "severity": "FATAL", + "ruleId": "E1029", + "severity": "ERROR", "message": "Found an embedded parameter \"${AWS::AccountId}\" outside of an \"Fn::Sub\" at Properties.PolicyDocument.Statement.1.NotResource", "source": "SCHEMA", "resourceId": "myPolicy", @@ -10497,9 +13112,8 @@ "startColumn": 3, "endLine": 14, "endColumn": 11, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iam.git", - "ruleDescription": "Sub is required if a variable is used in a string", - "phase": "SCHEMA", + "ruleDescription": "Substitution variable ${X} requires Fn::Sub", + "phase": "LINT", "section": "Resources" }, { @@ -10682,11 +13296,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { - "fatal": 3, - "errors": 1, + "fatal": 2, + "errors": 4, "warnings": 0, "informational": 2, "debug": 0 @@ -10720,11 +13334,29 @@ }, "diagnostics": [ { - "ruleId": "F1105", - "severity": "FATAL", - "message": "'Fn::GetAtt' is not allowed inside 'Fn::FindInMap'", + "ruleId": "E1011", + "severity": "ERROR", + "message": "Fn::FindInMap SecondLevelKey (third element) must be a string, integer, or one of Ref, Fn::FindInMap", "source": "SCHEMA", "category": "Intrinsic Function", + "ruleDescription": "Fn::FindInMap operands must be strings or one of Ref/Fn::FindInMap", + "phase": "PARSE" + }, + { + "ruleId": "E1021", + "severity": "ERROR", + "message": "Fn::Base64 argument must be a string or a string-producing intrinsic (Fn::Base64, Fn::Cidr, Fn::FindInMap, Fn::GetAtt, Fn::GetStackOutput, Fn::If, Fn::ImportValue, Fn::Join, Fn::Length, Fn::Select, Fn::Sub, Fn::ToJsonString, Fn::Transform, Ref)", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Base64 argument must be a string or a string-producing intrinsic", + "phase": "PARSE" + }, + { + "ruleId": "E1101", + "severity": "ERROR", + "message": "'Fn::GetAtt' is not allowed inside 'Fn::FindInMap'", + "source": "ENGINE", + "category": "Intrinsic Function", "ruleDescription": "Invalid nesting of intrinsic functions", "phase": "PARSE" }, @@ -10826,7 +13458,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 1, @@ -11326,7 +13958,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -11530,7 +14162,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 5, @@ -12138,12 +14770,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 2, - "errors": 8, - "warnings": 1, + "errors": 12, + "warnings": 0, "informational": 12, "debug": 0 }, @@ -12176,12 +14808,39 @@ }, "diagnostics": [ { - "ruleId": "W1102", - "severity": "WARN", + "ruleId": "E1017", + "severity": "ERROR", + "message": "Fn::Select source (second element) must be a list or a list-producing intrinsic (Fn::FindInMap, Fn::GetAtt, Fn::GetAZs, Fn::If, Fn::Split, Fn::Cidr, Ref)", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Select requires exactly two operands and a list source", + "phase": "PARSE" + }, + { + "ruleId": "E1017", + "severity": "ERROR", + "message": "Fn::Select: 'foo' is not of type 'array'", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Select requires exactly two operands and a list source", + "phase": "PARSE" + }, + { + "ruleId": "E1017", + "severity": "ERROR", + "message": "Fn::Select: expected maximum item count: 2, found: 3", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Select requires exactly two operands and a list source", + "phase": "PARSE" + }, + { + "ruleId": "E1017", + "severity": "ERROR", "message": "Fn::Select: index (first argument) must be an integer or an intrinsic function", - "source": "ENGINE", - "category": "Best Practice", - "ruleDescription": "Invalid intrinsic function usage", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Select requires exactly two operands and a list source", "phase": "PARSE" }, { @@ -12649,7 +15308,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 19, "counts": { "fatal": 28, @@ -13814,7 +16473,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "conditionLoadBalancer", "resourceType": "AWS::ElasticLoadBalancing::LoadBalancer", @@ -13831,7 +16490,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "conditionLoadBalancer", "resourceType": "AWS::ElasticLoadBalancing::LoadBalancer", @@ -13848,7 +16507,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "conditionLoadBalancer", "resourceType": "AWS::ElasticLoadBalancing::LoadBalancer", @@ -14328,7 +16987,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 6, "counts": { "fatal": 0, @@ -14573,7 +17232,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -14726,7 +17385,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -14788,7 +17447,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -14907,7 +17566,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 0, @@ -15003,7 +17662,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 2, @@ -15147,7 +17806,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -15241,7 +17900,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -15326,7 +17985,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -15420,7 +18079,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -15621,7 +18280,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -15754,7 +18413,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -15904,7 +18563,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -16016,7 +18675,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 1, @@ -16251,7 +18910,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -16419,7 +19078,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -16568,7 +19227,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -16640,7 +19299,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -16796,7 +19455,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -16857,7 +19516,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -16918,7 +19577,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -16979,7 +19638,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -17040,7 +19699,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -17083,7 +19742,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -17199,7 +19858,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 2, @@ -17262,7 +19921,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -17423,7 +20082,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 1, @@ -17678,7 +20337,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -17776,7 +20435,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 0, @@ -17949,7 +20608,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -18032,7 +20691,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 5, @@ -18214,7 +20873,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 4, @@ -18328,7 +20987,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -18795,7 +21454,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 10, @@ -19139,7 +21798,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 2, @@ -19331,7 +21990,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -19449,7 +22108,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 6, "counts": { "fatal": 1, @@ -19991,7 +22650,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 2, @@ -20381,7 +23040,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -20893,11 +23552,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { - "fatal": 2, - "errors": 0, + "fatal": 0, + "errors": 2, "warnings": 7, "informational": 14, "debug": 0 @@ -21099,8 +23758,8 @@ } }, { - "ruleId": "F8002", - "severity": "FATAL", + "ruleId": "E8002", + "severity": "ERROR", "message": "Condition 'CreateAuxiliaryNetworks' referenced by resource 'AuxiliaryPublicSubnetRouteTableAssociation1' is not defined", "source": "SCHEMA", "resourceId": "AuxiliaryPublicSubnetRouteTableAssociation1", @@ -21110,9 +23769,8 @@ "startColumn": 3, "endLine": 39, "endColumn": 46, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", "ruleDescription": "Condition referenced by resource is not defined", - "phase": "SCHEMA", + "phase": "LINT", "section": "Resources" }, { @@ -21202,7 +23860,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'isPublic' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'isPublic' to False", "source": "CFN_LINT", "resourceId": "ProxySubnetRouteTableAssociation", "resourceType": "AWS::EC2::SubnetRouteTableAssociation", @@ -21353,8 +24011,8 @@ } }, { - "ruleId": "F8002", - "severity": "FATAL", + "ruleId": "E8002", + "severity": "ERROR", "message": "Condition 'CreateAuxiliaryNetworks' referenced by resource 'AuxilliaryCustomSubnetRouteTableAssociation' is not defined", "source": "SCHEMA", "resourceId": "AuxilliaryCustomSubnetRouteTableAssociation", @@ -21364,9 +24022,8 @@ "startColumn": 3, "endLine": 69, "endColumn": 46, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", "ruleDescription": "Condition referenced by resource is not defined", - "phase": "SCHEMA", + "phase": "LINT", "section": "Resources" }, { @@ -21426,7 +24083,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 9, "counts": { "fatal": 2, @@ -22331,7 +24988,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -22519,7 +25176,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 0, @@ -22829,7 +25486,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 3, @@ -23165,7 +25822,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -23277,7 +25934,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -23320,7 +25977,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -23406,7 +26063,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -23513,7 +26170,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "Stack3", "resourceType": "AWS::CloudFormation::Stack", @@ -23530,7 +26187,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'IsUsWest2' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'IsUsWest2' to True. Where existing status for 'IsUsEast1' is False", "source": "CFN_LINT", "resourceId": "Stack3", "resourceType": "AWS::CloudFormation::Stack", @@ -23547,7 +26204,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsWest2' to False. Where existing status for condition 'IsUsEast1' is False", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsWest2' to False. Where existing status for 'IsUsEast1' is False", "source": "CFN_LINT", "resourceId": "Stack3", "resourceType": "AWS::CloudFormation::Stack", @@ -23635,7 +26292,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 2, @@ -23813,7 +26470,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -23925,7 +26582,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -24019,7 +26676,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -24113,7 +26770,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 0, @@ -24711,11 +27368,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { - "fatal": 7, - "errors": 1, + "fatal": 5, + "errors": 3, "warnings": 2, "informational": 2, "debug": 0 @@ -24749,12 +27406,12 @@ }, "diagnostics": [ { - "ruleId": "F1104", - "severity": "FATAL", + "ruleId": "E1028", + "severity": "ERROR", "message": "Fn::If references undefined condition 'cCondition'", "source": "SCHEMA", - "category": "Structure", - "ruleDescription": "Duplicate logical ID in template", + "category": "Intrinsic Function", + "ruleDescription": "Fn::If condition must exist in Conditions section", "phase": "PARSE" }, { @@ -24858,23 +27515,6 @@ "phase": "LINT", "section": "Resources" }, - { - "ruleId": "F1060", - "severity": "FATAL", - "message": "Fn::If condition 'cCondition' does not exist in Conditions section", - "source": "SCHEMA", - "resourceId": "rIamPolicy", - "resourceType": "AWS::IAM::Policy", - "category": "Intrinsic Function", - "startLine": 36, - "startColumn": 3, - "endLine": 36, - "endColumn": 13, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iam.git", - "ruleDescription": "Fn::If condition must exist in Conditions", - "phase": "SCHEMA", - "section": "Resources" - }, { "ruleId": "F3012", "severity": "FATAL", @@ -24933,6 +27573,22 @@ "expectedConstraint": "object" } }, + { + "ruleId": "E1028", + "severity": "ERROR", + "message": "Fn::If condition 'cCondition' does not exist in Conditions section", + "source": "SCHEMA", + "resourceId": "rIamPolicy", + "resourceType": "AWS::IAM::Policy", + "category": "Intrinsic Function", + "startLine": 36, + "startColumn": 3, + "endLine": 36, + "endColumn": 13, + "ruleDescription": "Fn::If condition must exist in Conditions section", + "phase": "LINT", + "section": "Resources" + }, { "ruleId": "F3003", "severity": "FATAL", @@ -24993,7 +27649,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -25058,7 +27714,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 0, @@ -25335,7 +27991,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -25415,7 +28071,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 6, @@ -25783,7 +28439,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 2, @@ -26123,7 +28779,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 2, @@ -26361,7 +29017,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 13, "counts": { "fatal": 0, @@ -26726,7 +29382,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "RootRole5", "resourceType": "AWS::IAM::Role", @@ -26821,7 +29477,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "RootRole6", "resourceType": "AWS::IAM::Role", @@ -26916,7 +29572,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "Bucket1", "resourceType": "AWS::S3::Bucket", @@ -26968,7 +29624,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "Bucket2", "resourceType": "AWS::S3::Bucket", @@ -27104,7 +29760,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 2, @@ -27198,7 +29854,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'cPrimaryRegion' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'cPrimaryRegion' to False", "source": "CFN_LINT", "resourceId": "myPolicy2", "resourceType": "AWS::IAM::Policy", @@ -27240,7 +29896,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -27283,7 +29939,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 1, @@ -27405,7 +30061,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 2, @@ -27574,7 +30230,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "ExampleLambda1", "resourceType": "AWS::Lambda::Function", @@ -27663,7 +30319,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 2, @@ -27803,7 +30459,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 3, @@ -27963,7 +30619,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 8, "counts": { "fatal": 0, @@ -28697,7 +31353,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -28837,7 +31493,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -28945,7 +31601,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -29038,7 +31694,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 11, "counts": { "fatal": 7, @@ -30331,7 +32987,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 9, "counts": { "fatal": 9, @@ -30753,7 +33409,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 2, @@ -30865,7 +33521,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -31083,7 +33739,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -31190,7 +33846,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 2, @@ -31627,7 +34283,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -31695,7 +34351,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 2, @@ -32164,7 +34820,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 11, "counts": { "fatal": 0, @@ -32617,7 +35273,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimaryRegion' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimaryRegion' to False", "source": "CFN_LINT", "resourceId": "MyCNAMERecordSetConditions", "resourceType": "AWS::Route53::RecordSet", @@ -33104,12 +35760,168 @@ } ] }, + "bad/rules_contradict_condition.yaml": { + "filePath": "bad/rules_contradict_condition.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 2, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 2, + "informational": 3, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'ProdBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "ProdBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 32, + "startColumn": 3, + "endLine": 32, + "endColumn": 13, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "W1001", + "severity": "WARN", + "message": "Reference to 'ProdBucket' which is conditional on 'IsProd' - target may not exist", + "source": "CFN_LINT", + "resourceId": "DevBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "suggestedFix": "Add a Condition to the referencing resource that implies the target's condition", + "category": "Best Practice", + "startLine": 35, + "startColumn": 3, + "endLine": 35, + "endColumn": 12, + "ruleDescription": "Ref/GetAtt to resource that is available when conditions are applied", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "W2503", + "severity": "WARN", + "message": "Resource 'DevBucket' (condition 'IsDev') references 'ProdBucket' (condition 'IsProd'), but these conditions are mutually exclusive \u2014 this reference will always fail", + "source": "ENGINE", + "resourceId": "DevBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 35, + "startColumn": 3, + "endLine": 35, + "endColumn": 12, + "relatedResources": [ + { + "resource": { + "id": "ProdBucket", + "resourceType": "AWS::S3::Bucket" + }, + "location": { + "startLine": 32, + "startColumn": 3, + "endLine": 32, + "endColumn": 13 + }, + "message": "Conditional resource 'ProdBucket' (condition 'IsProd')" + } + ], + "ruleDescription": "Resource references conditional resource with mutually exclusive condition", + "phase": "LINT", + "section": "Resources", + "context": { + "extra": { + "source_condition": "IsDev" + } + } + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "DevBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 35, + "startColumn": 3, + "endLine": 35, + "endColumn": 12, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only", + "resolutionSource": "GetAtt ProdBucket.Arn" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'DevBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "DevBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 35, + "startColumn": 3, + "endLine": 35, + "endColumn": 12, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, "bad/s3_tiering_bad_days.yaml": { "filePath": "bad/s3_tiering_bad_days.yaml", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -33189,7 +36001,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 37, @@ -34154,7 +36966,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 2, @@ -34344,7 +37156,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 2, @@ -34511,7 +37323,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -34617,7 +37429,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 2, @@ -34788,7 +37600,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -34972,7 +37784,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 1, @@ -35355,7 +38167,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -35512,7 +38324,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 1, @@ -35844,7 +38656,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -35958,7 +38770,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 6, @@ -36320,7 +39132,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -36462,7 +39274,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 2, @@ -36570,7 +39382,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -36607,27 +39419,6 @@ } }, "diagnostics": [ - { - "ruleId": "W3041", - "severity": "WARN", - "message": "Write-only property 'Certificate' of 'CertAuth' is referenced in output 'WriteOnlyOutput'", - "source": "ENGINE", - "resourceId": "CertAuth", - "resourceType": "AWS::ACMPCA::CertificateAuthorityActivation", - "propertyPath": "Properties.Certificate", - "category": "Best Practice", - "startLine": 5, - "startColumn": 3, - "endLine": 5, - "endColumn": 11, - "documentationUrl": "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/AWS_ACMPCA.html", - "ruleDescription": "Write-only property referenced in output", - "phase": "SCHEMA", - "section": "Resources", - "context": { - "lifecycle": "write-only" - } - }, { "ruleId": "W9002", "severity": "WARN", @@ -36661,6 +39452,27 @@ "phase": "LINT", "section": "Resources" }, + { + "ruleId": "W9054", + "severity": "WARN", + "message": "Write-only property 'Certificate' of 'CertAuth' is referenced in output 'WriteOnlyOutput'", + "source": "ENGINE", + "resourceId": "CertAuth", + "resourceType": "AWS::ACMPCA::CertificateAuthorityActivation", + "propertyPath": "Properties.Certificate", + "category": "Best Practice", + "startLine": 5, + "startColumn": 3, + "endLine": 5, + "endColumn": 11, + "documentationUrl": "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/AWS_ACMPCA.html", + "ruleDescription": "Write-only property referenced in output", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "write-only" + } + }, { "ruleId": "I3042", "severity": "INFO", @@ -36705,7 +39517,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 1, @@ -36823,7 +39635,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -36933,7 +39745,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -37033,7 +39845,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -37133,7 +39945,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -37216,7 +40028,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 8, "counts": { "fatal": 0, @@ -37692,7 +40504,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -37885,7 +40697,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -38143,7 +40955,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -38243,7 +41055,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -38371,7 +41183,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -38573,11 +41385,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { - "fatal": 1, - "errors": 0, + "fatal": 0, + "errors": 1, "warnings": 1, "informational": 2, "debug": 0 @@ -38625,8 +41437,8 @@ "section": "Parameters" }, { - "ruleId": "F1029", - "severity": "FATAL", + "ruleId": "E1029", + "severity": "ERROR", "message": "Found an embedded parameter \"${MyParam}\" outside of an \"Fn::Sub\" at Properties.BucketName", "source": "SCHEMA", "resourceId": "Bucket", @@ -38638,8 +41450,8 @@ "startColumn": 3, "endLine": 7, "endColumn": 9, - "ruleDescription": "Sub is required if a variable is used in a string", - "phase": "SCHEMA", + "ruleDescription": "Substitution variable ${X} requires Fn::Sub", + "phase": "LINT", "section": "Resources" }, { @@ -38687,12 +41499,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { - "fatal": 1, + "fatal": 0, "errors": 0, - "warnings": 0, + "warnings": 1, "informational": 3, "debug": 0 }, @@ -38725,12 +41537,12 @@ }, "diagnostics": [ { - "ruleId": "F1029", - "severity": "FATAL", + "ruleId": "W1056", + "severity": "WARN", "message": "Fn::Sub template contains '${!' which suggests nested intrinsic syntax \u2014 use the second argument map instead", - "source": "SCHEMA", - "category": "Intrinsic Function", - "ruleDescription": "Sub is required if a variable is used in a string", + "source": "ENGINE", + "category": "Best Practice", + "ruleDescription": "Fn::Sub template contains escaped intrinsic syntax suggesting a nesting mistake", "phase": "PARSE" }, { @@ -38797,7 +41609,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -38955,7 +41767,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -39266,7 +42078,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 0, @@ -39913,7 +42725,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 2, @@ -39986,7 +42798,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 2, @@ -40059,7 +42871,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -40117,7 +42929,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -40193,7 +43005,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -40273,7 +43085,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -40333,7 +43145,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 6, "counts": { "fatal": 0, @@ -40425,11 +43237,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { - "fatal": 1, - "errors": 0, + "fatal": 0, + "errors": 1, "warnings": 0, "informational": 1, "debug": 0 @@ -40463,8 +43275,8 @@ }, "diagnostics": [ { - "ruleId": "F8002", - "severity": "FATAL", + "ruleId": "E8002", + "severity": "ERROR", "message": "Condition 'DoesNotExist' referenced by resource 'R' is not defined", "source": "SCHEMA", "resourceId": "R", @@ -40475,7 +43287,7 @@ "endLine": 4, "endColumn": 4, "ruleDescription": "Condition referenced by resource is not defined", - "phase": "SCHEMA", + "phase": "LINT", "section": "Resources" }, { @@ -40503,7 +43315,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, @@ -40606,7 +43418,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 1, @@ -40763,7 +43575,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -40898,12 +43710,92 @@ } ] }, + "good/W9053_no_equivalent_conditions.yaml": { + "filePath": "good/W9053_no_equivalent_conditions.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 2, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 0, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 17, + "startColumn": 3, + "endLine": 17, + "endColumn": 9, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'DevBucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "DevBucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 20, + "startColumn": 3, + "endLine": 20, + "endColumn": 12, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, "good/aurora_dbinstance.yaml": { "filePath": "good/aurora_dbinstance.yaml", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -41048,160 +43940,12 @@ } ] }, - "good/both_forms.yaml": { - "filePath": "good/both_forms.yaml", - "status": "OK", - "engineVersion": "1.1.0", - "metadata": { - "rulesEvaluated": 273, - "resourcesScanned": 13, - "counts": { - "fatal": 0, - "errors": 0, - "warnings": 4, - "informational": 2, - "debug": 0 - }, - "suppressed": 0, - "strict": false, - "severityLevel": "DEBUG" - }, - "performance": { - "schemaInit": { - "durationMs": 0.0 - }, - "engineInit": { - "durationMs": 0.0 - }, - "modelBuild": { - "durationMs": 0.0 - }, - "schemaValidate": { - "durationMs": 0.0 - }, - "ruleEvaluation": { - "durationMs": 0.0 - }, - "diagnosticFinalize": { - "durationMs": 0.0 - }, - "validateTotal": { - "durationMs": 0.0 - } - }, - "diagnostics": [ - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'Combined' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 7, - "startColumn": 1, - "endLine": 7, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'IsProdShort' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 7, - "startColumn": 1, - "endLine": 7, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, - { - "ruleId": "I9001", - "severity": "INFO", - "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", - "source": "ENGINE", - "resourceId": "BucketShort", - "resourceType": "AWS::S3::Bucket", - "propertyPath": "Properties.BucketName", - "category": "Best Practice", - "startLine": 18, - "startColumn": 3, - "endLine": 18, - "endColumn": 14, - "ruleDescription": "Create-only property updated triggers resource replacement", - "phase": "SCHEMA", - "section": "Resources", - "context": { - "lifecycle": "create-only", - "resolutionSource": "parameter with AllowedValues" - } - }, - { - "ruleId": "I9001", - "severity": "INFO", - "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", - "source": "ENGINE", - "resourceId": "BucketLong", - "resourceType": "AWS::S3::Bucket", - "propertyPath": "Properties.BucketName", - "category": "Best Practice", - "startLine": 25, - "startColumn": 3, - "endLine": 25, - "endColumn": 13, - "ruleDescription": "Create-only property updated triggers resource replacement", - "phase": "SCHEMA", - "section": "Resources", - "context": { - "lifecycle": "create-only", - "resolutionSource": "parameter with AllowedValues" - } - }, - { - "ruleId": "W1028", - "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'IsProd' to True", - "source": "CFN_LINT", - "resourceId": "WithIf", - "resourceType": "Custom::IntrinsicTest", - "propertyPath": "Properties.Long.Fn::If.1", - "category": "Best Practice", - "startLine": 60, - "startColumn": 3, - "endLine": 60, - "endColumn": 9, - "ruleDescription": "Check Fn::If has a path that cannot be reached", - "phase": "LINT", - "section": "Resources" - }, - { - "ruleId": "W1028", - "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'IsProd' to True", - "source": "CFN_LINT", - "resourceId": "WithIf", - "resourceType": "Custom::IntrinsicTest", - "propertyPath": "Properties.Short.Fn::If.1", - "category": "Best Practice", - "startLine": 60, - "startColumn": 3, - "endLine": 60, - "endColumn": 9, - "ruleDescription": "Check Fn::If has a path that cannot be reached", - "phase": "LINT", - "section": "Resources" - } - ] - }, "good/cdk_bootstrap_version_rule.json": { "filePath": "good/cdk_bootstrap_version_rule.json", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -41263,7 +44007,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -41345,7 +44089,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -41456,7 +44200,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -41700,7 +44444,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -41824,7 +44568,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'PrimaryRegion' to False. Where existing status for condition 'EnableGeoBlockingAlias' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'PrimaryRegion' to False. Where existing status for 'EnableGeoBlockingAlias' is True", "source": "CFN_LINT", "resourceId": "CloudFrontDistribution", "resourceType": "AWS::CloudFront::Distribution", @@ -41883,10 +44627,10 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { - "fatal": 2, + "fatal": 0, "errors": 0, "warnings": 0, "informational": 0, @@ -41919,38 +44663,19 @@ "durationMs": 0.0 } }, - "diagnostics": [ - { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: element 0: [{'Ref': 'Users'}, ''] is not of type 'boolean'", - "source": "SCHEMA", - "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", - "phase": "PARSE" - }, - { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::And: element 1: [{'Ref': 'Users'}, 'another'] is not of type 'boolean'", - "source": "SCHEMA", - "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", - "phase": "PARSE" - } - ] + "diagnostics": [] }, "good/core/conditions.yaml": { "filePath": "good/core/conditions.yaml", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 8, "counts": { "fatal": 0, "errors": 0, - "warnings": 13, + "warnings": 12, "informational": 15, "debug": 0 }, @@ -42024,20 +44749,6 @@ "phase": "LINT", "section": "Conditions" }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'isProductionOrStaging' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 13, - "startColumn": 1, - "endLine": 13, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, { "ruleId": "I9001", "severity": "INFO", @@ -42195,7 +44906,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimary' to False. Where existing status for condition 'isPrimaryAndProduction' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimary' to False. Where existing status for 'isPrimaryAndProduction' is True", "source": "CFN_LINT", "resourceId": "myInstance2", "resourceType": "AWS::EC2::Instance", @@ -42303,7 +45014,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimary' to False. Where existing status for condition 'isPrimaryAndProduction' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimary' to False. Where existing status for 'isPrimaryAndProduction' is True", "source": "CFN_LINT", "resourceId": "myInstance4", "resourceType": "AWS::EC2::Instance", @@ -42436,7 +45147,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimary' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'isPrimary' to False", "source": "CFN_LINT", "resourceId": "AMIIDLookup", "resourceType": "AWS::Lambda::Function", @@ -42493,7 +45204,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -42551,7 +45262,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -42609,7 +45320,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -42708,7 +45419,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -42766,7 +45477,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -42824,7 +45535,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -42882,7 +45593,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -42976,7 +45687,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 8, @@ -43530,7 +46241,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 5, @@ -43929,7 +46640,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 2, @@ -44177,7 +46888,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 2, @@ -44425,7 +47136,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -44497,7 +47208,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -44680,7 +47391,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -44794,7 +47505,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -44908,7 +47619,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -45012,7 +47723,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -45218,7 +47929,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -45406,12 +48117,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 1, "errors": 0, - "warnings": 2, + "warnings": 4, "informational": 0, "debug": 0 }, @@ -45443,6 +48154,24 @@ } }, "diagnostics": [ + { + "ruleId": "W9032", + "severity": "WARN", + "message": "Fn::ForEach::BucketOutputs collection must be a literal list of strings to expand statically; macro left unexpanded", + "source": "ENGINE", + "category": "Best Practice", + "ruleDescription": "Fn::ForEach macro could not be expanded statically", + "phase": "PARSE" + }, + { + "ruleId": "W9032", + "severity": "WARN", + "message": "Fn::ForEach::Buckets collection must be a literal list of strings to expand statically; macro left unexpanded", + "source": "ENGINE", + "category": "Best Practice", + "ruleDescription": "Fn::ForEach macro could not be expanded statically", + "phase": "PARSE" + }, { "ruleId": "W2001", "severity": "WARN", @@ -45494,7 +48223,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -45537,7 +48266,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 1, @@ -45697,7 +48426,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -45817,7 +48546,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 3, @@ -46336,7 +49065,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 6, "counts": { "fatal": 3, @@ -46704,128 +49433,12 @@ } ] }, - "good/functions/sub_needed_custom_excludes.yaml": { - "filePath": "good/functions/sub_needed_custom_excludes.yaml", - "status": "OK", - "engineVersion": "1.1.0", - "metadata": { - "rulesEvaluated": 273, - "resourcesScanned": 1, - "counts": { - "fatal": 1, - "errors": 0, - "warnings": 1, - "informational": 2, - "debug": 0 - }, - "suppressed": 0, - "strict": false, - "severityLevel": "DEBUG" - }, - "performance": { - "schemaInit": { - "durationMs": 0.0 - }, - "engineInit": { - "durationMs": 0.0 - }, - "modelBuild": { - "durationMs": 0.0 - }, - "schemaValidate": { - "durationMs": 0.0 - }, - "ruleEvaluation": { - "durationMs": 0.0 - }, - "diagnosticFinalize": { - "durationMs": 0.0 - }, - "validateTotal": { - "durationMs": 0.0 - } - }, - "diagnostics": [ - { - "ruleId": "W2001", - "severity": "WARN", - "message": "Parameter 'Stage' is not referenced anywhere in the template", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 4, - "startColumn": 1, - "endLine": 4, - "endColumn": 11, - "ruleDescription": "Check if Parameters are Used", - "phase": "LINT", - "section": "Parameters" - }, - { - "ruleId": "F1029", - "severity": "FATAL", - "message": "Found an embedded parameter \"${Stage}\" outside of an \"Fn::Sub\" at Properties.RoleName", - "source": "SCHEMA", - "resourceId": "TestRole", - "resourceType": "AWS::IAM::Role", - "propertyPath": "Properties.RoleName", - "suggestedFix": "Wrap the string with Fn::Sub", - "category": "Intrinsic Function", - "startLine": 8, - "startColumn": 3, - "endLine": 8, - "endColumn": 11, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iam.git", - "ruleDescription": "Sub is required if a variable is used in a string", - "phase": "SCHEMA", - "section": "Resources" - }, - { - "ruleId": "I9001", - "severity": "INFO", - "message": "Property 'RoleName' is create-only; updating it will cause resource replacement", - "source": "ENGINE", - "resourceId": "TestRole", - "resourceType": "AWS::IAM::Role", - "propertyPath": "Properties.RoleName", - "category": "Best Practice", - "startLine": 8, - "startColumn": 3, - "endLine": 8, - "endColumn": 11, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iam.git", - "ruleDescription": "Create-only property updated triggers resource replacement", - "phase": "SCHEMA", - "section": "Resources", - "context": { - "lifecycle": "create-only" - } - }, - { - "ruleId": "I9040", - "severity": "INFO", - "message": "Resource 'TestRole' of type 'AWS::IAM::Role' supports Tags but none are configured", - "source": "ENGINE", - "resourceId": "TestRole", - "resourceType": "AWS::IAM::Role", - "propertyPath": "Properties.Tags", - "suggestedFix": "Add Tags to improve resource organization and cost tracking", - "category": "Best Practice", - "startLine": 8, - "startColumn": 3, - "endLine": 8, - "endColumn": 11, - "ruleDescription": "Resource should have Tags", - "phase": "LINT", - "section": "Resources" - } - ] - }, "good/functions/sub_needed_transform.yaml": { "filePath": "good/functions/sub_needed_transform.yaml", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -46868,7 +49481,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -47032,7 +49645,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 9, "counts": { "fatal": 0, @@ -47498,7 +50111,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 6, "counts": { "fatal": 0, @@ -47896,7 +50509,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 13, "counts": { "fatal": 0, @@ -48312,12 +50925,130 @@ } ] }, + "good/good_conditions_shape.yaml": { + "filePath": "good/good_conditions_shape.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 4, + "informational": 1, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'BothProdAndUsEast' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 8, + "startColumn": 1, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'EitherProdOrUsEast' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 8, + "startColumn": 1, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'TenOr' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 8, + "startColumn": 1, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "W8001", + "severity": "WARN", + "message": "Condition 'TripleAnd' is not used by any resource or Fn::If", + "source": "CFN_LINT", + "category": "Best Practice", + "startLine": 8, + "startColumn": 1, + "endLine": 8, + "endColumn": 11, + "ruleDescription": "Check if Conditions are Used", + "phase": "LINT", + "section": "Conditions" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Bucket' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Bucket", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 46, + "startColumn": 3, + "endLine": 46, + "endColumn": 9, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, "good/iam_valid.yaml": { "filePath": "good/iam_valid.yaml", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -48355,36 +51086,251 @@ }, "diagnostics": [ { - "ruleId": "I3510", + "ruleId": "I3510", + "severity": "INFO", + "message": "Action 's3:GetObject' requires a resource matching 'arn:*:s3:*:*:accesspoint/.*' but none of the resources match", + "source": "CFN_LINT", + "resourceId": "Policy", + "resourceType": "AWS::IAM::Policy", + "propertyPath": "Properties.PolicyDocument", + "category": "Security", + "startLine": 4, + "startColumn": 3, + "endLine": 4, + "endColumn": 9, + "ruleDescription": "Validate statement resources match the actions", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'Role' of type 'AWS::IAM::Role' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "Role", + "resourceType": "AWS::IAM::Role", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 17, + "startColumn": 3, + "endLine": 17, + "endColumn": 7, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "good/intrinsic_shapes_ok.yaml": { + "filePath": "good/intrinsic_shapes_ok.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 6, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 0, + "informational": 9, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "I9001", "severity": "INFO", - "message": "Action 's3:GetObject' requires a resource matching 'arn:*:s3:*:*:accesspoint/.*' but none of the resources match", - "source": "CFN_LINT", - "resourceId": "Policy", - "resourceType": "AWS::IAM::Policy", - "propertyPath": "Properties.PolicyDocument", - "category": "Security", - "startLine": 4, + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "SplitExample", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 6, "startColumn": 3, - "endLine": 4, - "endColumn": 9, - "ruleDescription": "Validate statement resources match the actions", + "endLine": 6, + "endColumn": 15, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'SplitExample' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "SplitExample", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 6, + "startColumn": 3, + "endLine": 6, + "endColumn": 15, + "ruleDescription": "Resource should have Tags", "phase": "LINT", "section": "Resources" }, { "ruleId": "I9040", "severity": "INFO", - "message": "Resource 'Role' of type 'AWS::IAM::Role' supports Tags but none are configured", + "message": "Resource 'Base64Example' of type 'AWS::Lambda::Function' supports Tags but none are configured", "source": "ENGINE", - "resourceId": "Role", + "resourceId": "Base64Example", + "resourceType": "AWS::Lambda::Function", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 16, + "startColumn": 3, + "endLine": 16, + "endColumn": 16, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "SubExample", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 33, + "startColumn": 3, + "endLine": 33, + "endColumn": 13, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'SubExample' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "SubExample", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 33, + "startColumn": 3, + "endLine": 33, + "endColumn": 13, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "DynRefExample", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 43, + "startColumn": 3, + "endLine": 43, + "endColumn": 16, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only", + "resolutionSource": "dynamic (dynamic reference: {{resolve:ssm:/my/param:1}})" + } + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'BucketName' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "RefExample", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.BucketName", + "category": "Best Practice", + "startLine": 52, + "startColumn": 3, + "endLine": 52, + "endColumn": 13, + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'RefExample' of type 'AWS::S3::Bucket' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "RefExample", + "resourceType": "AWS::S3::Bucket", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 52, + "startColumn": 3, + "endLine": 52, + "endColumn": 13, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'LambdaRole' of type 'AWS::IAM::Role' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "LambdaRole", "resourceType": "AWS::IAM::Role", "propertyPath": "Properties.Tags", "suggestedFix": "Add Tags to improve resource organization and cost tracking", "category": "Best Practice", - "startLine": 17, + "startLine": 57, "startColumn": 3, - "endLine": 17, - "endColumn": 7, + "endLine": 57, + "endColumn": 13, "ruleDescription": "Resource should have Tags", "phase": "LINT", "section": "Resources" @@ -48396,7 +51342,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -48528,7 +51474,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -48659,7 +51605,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -48731,7 +51677,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -48888,7 +51834,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -48964,7 +51910,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -49026,7 +51972,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -49088,16 +52034,16 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { - "fatal": 1, + "fatal": 0, "errors": 0, "warnings": 5, "informational": 15, "debug": 0 }, - "suppressed": 1, + "suppressed": 0, "strict": false, "severityLevel": "DEBUG" }, @@ -49125,15 +52071,6 @@ } }, "diagnostics": [ - { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::Equals: argument 1: true is not of type 'string'", - "source": "SCHEMA", - "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", - "phase": "PARSE" - }, { "ruleId": "W2001", "severity": "WARN", @@ -49496,7 +52433,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -49684,7 +52621,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -49860,7 +52797,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -49942,7 +52879,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -50032,7 +52969,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 3, @@ -50314,7 +53251,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -50525,131 +53462,17 @@ } ] }, - "good/parameters/used_transform_language_extension.json": { - "filePath": "good/parameters/used_transform_language_extension.json", - "status": "OK", - "engineVersion": "1.1.0", - "metadata": { - "rulesEvaluated": 273, - "resourcesScanned": 1, - "counts": { - "fatal": 0, - "errors": 0, - "warnings": 5, - "informational": 0, - "debug": 0 - }, - "suppressed": 0, - "strict": false, - "severityLevel": "DEBUG" - }, - "performance": { - "schemaInit": { - "durationMs": 0.0 - }, - "engineInit": { - "durationMs": 0.0 - }, - "modelBuild": { - "durationMs": 0.0 - }, - "schemaValidate": { - "durationMs": 0.0 - }, - "ruleEvaluation": { - "durationMs": 0.0 - }, - "diagnosticFinalize": { - "durationMs": 0.0 - }, - "validateTotal": { - "durationMs": 0.0 - } - }, - "diagnostics": [ - { - "ruleId": "W2001", - "severity": "WARN", - "message": "Parameter 'ParamA' is not referenced anywhere in the template", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 4, - "startColumn": 5, - "endLine": 4, - "endColumn": 16, - "ruleDescription": "Check if Parameters are Used", - "phase": "LINT", - "section": "Parameters" - }, - { - "ruleId": "W2001", - "severity": "WARN", - "message": "Parameter 'ParamB' is not referenced anywhere in the template", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 4, - "startColumn": 5, - "endLine": 4, - "endColumn": 16, - "ruleDescription": "Check if Parameters are Used", - "phase": "LINT", - "section": "Parameters" - }, - { - "ruleId": "W2001", - "severity": "WARN", - "message": "Parameter 'ParamC' is not referenced anywhere in the template", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 4, - "startColumn": 5, - "endLine": 4, - "endColumn": 16, - "ruleDescription": "Check if Parameters are Used", - "phase": "LINT", - "section": "Parameters" - }, - { - "ruleId": "W2001", - "severity": "WARN", - "message": "Parameter 'ParamD' is not referenced anywhere in the template", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 4, - "startColumn": 5, - "endLine": 4, - "endColumn": 16, - "ruleDescription": "Check if Parameters are Used", - "phase": "LINT", - "section": "Parameters" - }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'Fn::ForEach::CheckTrue' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 34, - "startColumn": 5, - "endLine": 34, - "endColumn": 16, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - } - ] - }, "good/parameters/used_transform_removed.yaml": { "filePath": "good/parameters/used_transform_removed.yaml", "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, "errors": 0, - "warnings": 3, + "warnings": 0, "informational": 1, "debug": 0 }, @@ -50681,48 +53504,6 @@ } }, "diagnostics": [ - { - "ruleId": "W2001", - "severity": "WARN", - "message": "Parameter 'AppStack' is not referenced anywhere in the template", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 5, - "startColumn": 1, - "endLine": 5, - "endColumn": 11, - "ruleDescription": "Check if Parameters are Used", - "phase": "LINT", - "section": "Parameters" - }, - { - "ruleId": "W2001", - "severity": "WARN", - "message": "Parameter 'CognitoStackName' is not referenced anywhere in the template", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 5, - "startColumn": 1, - "endLine": 5, - "endColumn": 11, - "ruleDescription": "Check if Parameters are Used", - "phase": "LINT", - "section": "Parameters" - }, - { - "ruleId": "W2001", - "severity": "WARN", - "message": "Parameter 'ECSStack' is not referenced anywhere in the template", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 5, - "startColumn": 1, - "endLine": 5, - "endColumn": 11, - "ruleDescription": "Check if Parameters are Used", - "phase": "LINT", - "section": "Parameters" - }, { "ruleId": "I9040", "severity": "INFO", @@ -50748,7 +53529,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -50950,7 +53731,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 0, @@ -51386,7 +54167,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 6, "counts": { "fatal": 0, @@ -51594,7 +54375,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'isPublic' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'isPublic' to False", "source": "CFN_LINT", "resourceId": "ProxySubnetRouteTableAssociation", "resourceType": "AWS::EC2::SubnetRouteTableAssociation", @@ -51835,7 +54616,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 0, @@ -52166,7 +54947,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -52209,7 +54990,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -52271,7 +55052,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -52357,7 +55138,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 0, @@ -52648,7 +55429,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "Stack3", "resourceType": "AWS::CloudFormation::Stack", @@ -52682,7 +55463,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "Stack3", "resourceType": "AWS::CloudFormation::Stack", @@ -52770,7 +55551,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -52852,7 +55633,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -52942,7 +55723,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "DDBTable2", "resourceType": "AWS::DynamoDB::Table", @@ -52959,7 +55740,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "DDBTable2", "resourceType": "AWS::DynamoDB::Table", @@ -53030,7 +55811,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 10, "counts": { "fatal": 0, @@ -53573,7 +56354,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsTesting' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsTesting' to False", "source": "CFN_LINT", "resourceId": "FourthReplicationGroup", "resourceType": "AWS::ElastiCache::ReplicationGroup", @@ -53966,7 +56747,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -54110,7 +56891,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -54175,7 +56956,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -54257,7 +57038,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 0, @@ -54516,7 +57297,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -54578,7 +57359,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 1, @@ -54828,7 +57609,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -54952,7 +57733,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 0, @@ -55172,7 +57953,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "Bucket1", "resourceType": "AWS::S3::Bucket", @@ -55349,7 +58130,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -55453,7 +58234,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 1, @@ -55650,7 +58431,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -55693,7 +58474,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 2, @@ -55899,7 +58680,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 0, @@ -56034,7 +58815,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 0, @@ -56300,7 +59081,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 0, @@ -56584,7 +59365,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 1, @@ -56763,7 +59544,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 0, @@ -57249,7 +60030,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -57311,7 +60092,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -57394,7 +60175,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -57456,7 +60237,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 6, "counts": { "fatal": 0, @@ -58022,7 +60803,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -58143,7 +60924,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -58181,244 +60962,230 @@ }, "diagnostics": [ { - "ruleId": "W1028", - "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'myCondition' to False from current status True", - "source": "CFN_LINT", - "resourceId": "TestPipeline", - "resourceType": "AWS::CodePipeline::Pipeline", - "propertyPath": "Properties.Stages.2.Actions.0.Fn::If.2", - "category": "Best Practice", - "startLine": 6, - "startColumn": 3, - "endLine": 6, - "endColumn": 15, - "ruleDescription": "Check Fn::If has a path that cannot be reached", - "phase": "LINT", - "section": "Resources" - }, - { - "ruleId": "W9002", - "severity": "WARN", - "message": "Property 'RoleArn' has a hardcoded ARN \u2014 use Ref, GetAtt, or a parameter instead", - "source": "ENGINE", - "resourceId": "TestPipeline", - "resourceType": "AWS::CodePipeline::Pipeline", - "propertyPath": "Properties.RoleArn", - "category": "Best Practice", - "startLine": 6, - "startColumn": 3, - "endLine": 6, - "endColumn": 15, - "ruleDescription": "Hardcoded ARN property", - "phase": "LINT", - "section": "Resources" - }, - { - "ruleId": "W9013", - "severity": "WARN", - "message": "Hardcoded account ID in ARN \u2014 use AWS::AccountId pseudo-parameter", - "source": "ENGINE", - "resourceId": "TestPipeline", - "resourceType": "AWS::CodePipeline::Pipeline", - "category": "Security", - "startLine": 6, - "startColumn": 3, - "endLine": 6, - "endColumn": 15, - "ruleDescription": "Hardcoded account ID in ARN", - "phase": "LINT", - "section": "Resources" - }, - { - "ruleId": "I3042", - "severity": "INFO", - "message": "Hardcoded partition 'aws' in ARN \u2014 use AWS::Partition pseudo-parameter for portability", - "source": "CFN_LINT", - "resourceId": "TestPipeline", - "resourceType": "AWS::CodePipeline::Pipeline", - "category": "Best Practice", - "startLine": 6, - "startColumn": 3, - "endLine": 6, - "endColumn": 15, - "ruleDescription": "ARNs should use correctly placed Pseudo Parameters", - "phase": "LINT", - "section": "Resources" - }, - { - "ruleId": "I9001", - "severity": "INFO", - "message": "Property 'Name' is create-only; updating it will cause resource replacement", - "source": "ENGINE", - "resourceId": "TestPipeline", - "resourceType": "AWS::CodePipeline::Pipeline", - "propertyPath": "Properties.Name", - "category": "Best Practice", - "startLine": 6, - "startColumn": 3, - "endLine": 6, - "endColumn": 15, - "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-codepipeline", - "ruleDescription": "Create-only property updated triggers resource replacement", - "phase": "SCHEMA", - "section": "Resources", - "context": { - "lifecycle": "create-only" - } - }, - { - "ruleId": "I9040", - "severity": "INFO", - "message": "Resource 'TestPipeline' of type 'AWS::CodePipeline::Pipeline' supports Tags but none are configured", - "source": "ENGINE", - "resourceId": "TestPipeline", - "resourceType": "AWS::CodePipeline::Pipeline", - "propertyPath": "Properties.Tags", - "suggestedFix": "Add Tags to improve resource organization and cost tracking", - "category": "Best Practice", - "startLine": 6, - "startColumn": 3, - "endLine": 6, - "endColumn": 15, - "ruleDescription": "Resource should have Tags", - "phase": "LINT", - "section": "Resources" - } - ] - }, - "good/resources_cognito_userpool_tag_is_string_map.yaml": { - "filePath": "good/resources_cognito_userpool_tag_is_string_map.yaml", - "status": "OK", - "engineVersion": "1.1.0", - "metadata": { - "rulesEvaluated": 273, - "resourcesScanned": 1, - "counts": { - "fatal": 0, - "errors": 0, - "warnings": 0, - "informational": 2, - "debug": 0 - }, - "suppressed": 0, - "strict": false, - "severityLevel": "DEBUG" - }, - "performance": { - "schemaInit": { - "durationMs": 0.0 - }, - "engineInit": { - "durationMs": 0.0 - }, - "modelBuild": { - "durationMs": 0.0 - }, - "schemaValidate": { - "durationMs": 0.0 - }, - "ruleEvaluation": { - "durationMs": 0.0 - }, - "diagnosticFinalize": { - "durationMs": 0.0 - }, - "validateTotal": { - "durationMs": 0.0 - } - }, - "diagnostics": [ - { - "ruleId": "I3011", - "severity": "INFO", - "message": "'DeletionPolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)", - "source": "CFN_LINT", - "resourceId": "MyCognitoUserPool", - "resourceType": "AWS::Cognito::UserPool", - "category": "Best Practice", - "startLine": 4, - "startColumn": 3, - "endLine": 4, - "endColumn": 20, - "ruleDescription": "Check stateful resources have a set UpdateReplacePolicy/DeletionPolicy", - "phase": "LINT", - "section": "Resources" - }, - { - "ruleId": "I3011", - "severity": "INFO", - "message": "'UpdateReplacePolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)", - "source": "CFN_LINT", - "resourceId": "MyCognitoUserPool", - "resourceType": "AWS::Cognito::UserPool", - "category": "Best Practice", - "startLine": 4, - "startColumn": 3, - "endLine": 4, - "endColumn": 20, - "ruleDescription": "Check stateful resources have a set UpdateReplacePolicy/DeletionPolicy", - "phase": "LINT", - "section": "Resources" - } - ] - }, - "good/resources_deletionpolicy.yaml": { - "filePath": "good/resources_deletionpolicy.yaml", - "status": "OK", - "engineVersion": "1.1.0", - "metadata": { - "rulesEvaluated": 273, - "resourcesScanned": 3, - "counts": { - "fatal": 0, - "errors": 0, - "warnings": 2, - "informational": 3, - "debug": 0 - }, - "suppressed": 0, - "strict": false, - "severityLevel": "DEBUG" - }, - "performance": { - "schemaInit": { - "durationMs": 0.0 - }, - "engineInit": { - "durationMs": 0.0 - }, - "modelBuild": { - "durationMs": 0.0 - }, - "schemaValidate": { - "durationMs": 0.0 - }, - "ruleEvaluation": { - "durationMs": 0.0 - }, - "diagnosticFinalize": { - "durationMs": 0.0 - }, - "validateTotal": { - "durationMs": 0.0 - } - }, - "diagnostics": [ - { - "ruleId": "W8001", + "ruleId": "W1028", "severity": "WARN", - "message": "Condition 'IsUsEast1' is not used by any resource or Fn::If", + "message": "['Fn::If', 2] is not reachable. When setting condition 'myCondition' to False", "source": "CFN_LINT", + "resourceId": "TestPipeline", + "resourceType": "AWS::CodePipeline::Pipeline", + "propertyPath": "Properties.Stages.2.Actions.0.Fn::If.2", "category": "Best Practice", - "startLine": 3, - "startColumn": 1, - "endLine": 3, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", + "startLine": 6, + "startColumn": 3, + "endLine": 6, + "endColumn": 15, + "ruleDescription": "Check Fn::If has a path that cannot be reached", "phase": "LINT", - "section": "Conditions" + "section": "Resources" + }, + { + "ruleId": "W9002", + "severity": "WARN", + "message": "Property 'RoleArn' has a hardcoded ARN \u2014 use Ref, GetAtt, or a parameter instead", + "source": "ENGINE", + "resourceId": "TestPipeline", + "resourceType": "AWS::CodePipeline::Pipeline", + "propertyPath": "Properties.RoleArn", + "category": "Best Practice", + "startLine": 6, + "startColumn": 3, + "endLine": 6, + "endColumn": 15, + "ruleDescription": "Hardcoded ARN property", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "W9013", + "severity": "WARN", + "message": "Hardcoded account ID in ARN \u2014 use AWS::AccountId pseudo-parameter", + "source": "ENGINE", + "resourceId": "TestPipeline", + "resourceType": "AWS::CodePipeline::Pipeline", + "category": "Security", + "startLine": 6, + "startColumn": 3, + "endLine": 6, + "endColumn": 15, + "ruleDescription": "Hardcoded account ID in ARN", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I3042", + "severity": "INFO", + "message": "Hardcoded partition 'aws' in ARN \u2014 use AWS::Partition pseudo-parameter for portability", + "source": "CFN_LINT", + "resourceId": "TestPipeline", + "resourceType": "AWS::CodePipeline::Pipeline", + "category": "Best Practice", + "startLine": 6, + "startColumn": 3, + "endLine": 6, + "endColumn": 15, + "ruleDescription": "ARNs should use correctly placed Pseudo Parameters", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I9001", + "severity": "INFO", + "message": "Property 'Name' is create-only; updating it will cause resource replacement", + "source": "ENGINE", + "resourceId": "TestPipeline", + "resourceType": "AWS::CodePipeline::Pipeline", + "propertyPath": "Properties.Name", + "category": "Best Practice", + "startLine": 6, + "startColumn": 3, + "endLine": 6, + "endColumn": 15, + "documentationUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-codepipeline", + "ruleDescription": "Create-only property updated triggers resource replacement", + "phase": "SCHEMA", + "section": "Resources", + "context": { + "lifecycle": "create-only" + } + }, + { + "ruleId": "I9040", + "severity": "INFO", + "message": "Resource 'TestPipeline' of type 'AWS::CodePipeline::Pipeline' supports Tags but none are configured", + "source": "ENGINE", + "resourceId": "TestPipeline", + "resourceType": "AWS::CodePipeline::Pipeline", + "propertyPath": "Properties.Tags", + "suggestedFix": "Add Tags to improve resource organization and cost tracking", + "category": "Best Practice", + "startLine": 6, + "startColumn": 3, + "endLine": 6, + "endColumn": 15, + "ruleDescription": "Resource should have Tags", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "good/resources_cognito_userpool_tag_is_string_map.yaml": { + "filePath": "good/resources_cognito_userpool_tag_is_string_map.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 1, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 0, + "informational": 2, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 + }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ + { + "ruleId": "I3011", + "severity": "INFO", + "message": "'DeletionPolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)", + "source": "CFN_LINT", + "resourceId": "MyCognitoUserPool", + "resourceType": "AWS::Cognito::UserPool", + "category": "Best Practice", + "startLine": 4, + "startColumn": 3, + "endLine": 4, + "endColumn": 20, + "ruleDescription": "Check stateful resources have a set UpdateReplacePolicy/DeletionPolicy", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "I3011", + "severity": "INFO", + "message": "'UpdateReplacePolicy' is a required property (The default action when replacing/removing a resource is to delete it. Set explicit values for stateful resource)", + "source": "CFN_LINT", + "resourceId": "MyCognitoUserPool", + "resourceType": "AWS::Cognito::UserPool", + "category": "Best Practice", + "startLine": 4, + "startColumn": 3, + "endLine": 4, + "endColumn": 20, + "ruleDescription": "Check stateful resources have a set UpdateReplacePolicy/DeletionPolicy", + "phase": "LINT", + "section": "Resources" + } + ] + }, + "good/resources_deletionpolicy.yaml": { + "filePath": "good/resources_deletionpolicy.yaml", + "status": "OK", + "engineVersion": "1.1.0", + "metadata": { + "rulesEvaluated": 295, + "resourcesScanned": 3, + "counts": { + "fatal": 0, + "errors": 0, + "warnings": 1, + "informational": 3, + "debug": 0 + }, + "suppressed": 0, + "strict": false, + "severityLevel": "DEBUG" + }, + "performance": { + "schemaInit": { + "durationMs": 0.0 + }, + "engineInit": { + "durationMs": 0.0 + }, + "modelBuild": { + "durationMs": 0.0 + }, + "schemaValidate": { + "durationMs": 0.0 + }, + "ruleEvaluation": { + "durationMs": 0.0 + }, + "diagnosticFinalize": { + "durationMs": 0.0 }, + "validateTotal": { + "durationMs": 0.0 + } + }, + "diagnostics": [ { "ruleId": "W9008", "severity": "WARN", @@ -58496,12 +61263,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, "errors": 0, - "warnings": 2, + "warnings": 1, "informational": 3, "debug": 0 }, @@ -58533,20 +61300,6 @@ } }, "diagnostics": [ - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'IsUsEast1' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 1, - "endLine": 3, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, { "ruleId": "W9008", "severity": "WARN", @@ -58624,7 +61377,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -58689,7 +61442,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -58789,7 +61542,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -58872,7 +61625,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 8, "counts": { "fatal": 0, @@ -59348,7 +62101,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -59501,7 +62254,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -59584,7 +62337,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -59667,7 +62420,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -59778,7 +62531,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -59861,7 +62614,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -59941,7 +62694,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -60021,7 +62774,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -60101,7 +62854,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -60163,7 +62916,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -60225,7 +62978,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -60287,12 +63040,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 0, "errors": 1, - "warnings": 3, + "warnings": 2, "informational": 12, "debug": 0 }, @@ -60324,20 +63077,6 @@ } }, "diagnostics": [ - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'IsUsEast1' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 6, - "startColumn": 1, - "endLine": 6, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, { "ruleId": "W9008", "severity": "WARN", @@ -60627,7 +63366,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -60689,7 +63428,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -60751,7 +63490,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -60845,7 +63584,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -60907,7 +63646,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -60987,7 +63726,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -61067,7 +63806,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 0, @@ -61165,7 +63904,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -61245,7 +63984,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 0, @@ -61560,7 +64299,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 2, @@ -61742,7 +64481,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 2, @@ -61961,7 +64700,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 4, @@ -62211,7 +64950,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 5, @@ -62413,7 +65152,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 2, @@ -62539,7 +65278,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 5, @@ -63011,7 +65750,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 1, @@ -63112,7 +65851,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 17, "counts": { "fatal": 1, @@ -64329,7 +67068,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -64429,7 +67168,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 3, "counts": { "fatal": 0, @@ -64669,7 +67408,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 1, @@ -64965,7 +67704,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 0, @@ -65406,11 +68145,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 8, "counts": { "fatal": 6, - "errors": 3, + "errors": 4, "warnings": 0, "informational": 20, "debug": 0 @@ -65443,6 +68182,15 @@ } }, "diagnostics": [ + { + "ruleId": "E1022", + "severity": "ERROR", + "message": "Fn::Join list element must be a string or a string-producing intrinsic (Fn::Base64, Fn::FindInMap, Fn::GetAtt, Fn::GetStackOutput, Fn::If, Fn::ImportValue, Fn::Join, Fn::Select, Fn::Sub, Fn::Transform, Ref)", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Join requires a string delimiter and a list of strings or string-producing intrinsics", + "phase": "PARSE" + }, { "ruleId": "I1022", "severity": "INFO", @@ -65960,7 +68708,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 0, "counts": { "fatal": 1, @@ -66032,7 +68780,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 4, @@ -66126,7 +68874,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "IamRole1", "resourceType": "AWS::IAM::Role", @@ -66198,7 +68946,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "IamRole3", "resourceType": "AWS::IAM::Role", @@ -66305,7 +69053,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsUsEast1' to False", "source": "CFN_LINT", "resourceId": "CloudFront2", "resourceType": "AWS::CloudFront::Distribution", @@ -66364,7 +69112,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 13, "counts": { "fatal": 0, @@ -67283,7 +70031,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -67397,16 +70145,16 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 17, "counts": { - "fatal": 1, + "fatal": 0, "errors": 0, - "warnings": 1, + "warnings": 0, "informational": 37, "debug": 0 }, - "suppressed": 3, + "suppressed": 0, "strict": false, "severityLevel": "DEBUG" }, @@ -67434,29 +70182,6 @@ } }, "diagnostics": [ - { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::Equals: argument 0: true is not of type 'string'", - "source": "SCHEMA", - "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", - "phase": "PARSE" - }, - { - "ruleId": "W2001", - "severity": "WARN", - "message": "Parameter 'Zone' is not referenced anywhere in the template", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 5, - "startColumn": 1, - "endLine": 5, - "endColumn": 11, - "ruleDescription": "Check if Parameters are Used", - "phase": "LINT", - "section": "Parameters" - }, { "ruleId": "I9001", "severity": "INFO", @@ -68185,12 +70910,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 12, "counts": { "fatal": 3, "errors": 1, - "warnings": 14, + "warnings": 13, "informational": 24, "debug": 0 }, @@ -68235,7 +70960,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "ConditionalOutput", "propertyPath": "Value.Fn::If.2", @@ -68258,20 +70983,6 @@ "phase": "LINT", "section": "Conditions" }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'IsDevelopment' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 116, - "startColumn": 3, - "endLine": 116, - "endColumn": 14, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, { "ruleId": "W8001", "severity": "WARN", @@ -68560,7 +71271,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'HasMultipleAZs' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'HasMultipleAZs' to False", "source": "CFN_LINT", "resourceId": "AutoScalingGroup", "resourceType": "AWS::AutoScaling::AutoScalingGroup", @@ -69020,12 +71731,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 12, "counts": { - "fatal": 5, + "fatal": 4, "errors": 1, - "warnings": 15, + "warnings": 14, "informational": 24, "debug": 0 }, @@ -69067,16 +71778,6 @@ "phase": "PARSE", "section": "Rules" }, - { - "ruleId": "F8610", - "severity": "FATAL", - "message": "Rule 'ValidateRegionAndEnvironment' Assertions[0] Assert must be a condition function (object), not array", - "source": "SCHEMA", - "category": "Structure", - "ruleDescription": "Rule assertion Assert must be a condition function", - "phase": "PARSE", - "section": "Rules" - }, { "ruleId": "F8611", "severity": "FATAL", @@ -69090,7 +71791,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "ConditionalOutput", "propertyPath": "Value.Fn::If.2", @@ -69113,20 +71814,6 @@ "phase": "LINT", "section": "Conditions" }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'IsDevelopment' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 92, - "startColumn": 1, - "endLine": 92, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, { "ruleId": "W8001", "severity": "WARN", @@ -69432,7 +72119,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'HasMultipleAZs' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'HasMultipleAZs' to False", "source": "CFN_LINT", "resourceId": "AutoScalingGroup", "resourceType": "AWS::AutoScaling::AutoScalingGroup", @@ -69892,7 +72579,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 4, "counts": { "fatal": 0, @@ -69946,7 +72633,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "ProductionBucket", "resourceType": "AWS::S3::Bucket", @@ -69963,7 +72650,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "ProductionBucket", "resourceType": "AWS::S3::Bucket", @@ -70221,11 +72908,11 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 8, "counts": { - "fatal": 3, - "errors": 0, + "fatal": 0, + "errors": 4, "warnings": 11, "informational": 18, "debug": 0 @@ -70259,36 +72946,18 @@ }, "diagnostics": [ { - "ruleId": "F0014", - "severity": "FATAL", + "ruleId": "E8003", + "severity": "ERROR", "message": "Fn::Equals: argument 0: {'Fn::And': [{'Condition': 'IsProduction'}, {'Condition': 'ShouldCreateDatabase'}]} is not of type 'string'", "source": "SCHEMA", "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", - "phase": "PARSE" - }, - { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::Equals: argument 1: true is not of type 'string'", - "source": "SCHEMA", - "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", - "phase": "PARSE" - }, - { - "ruleId": "F1105", - "severity": "FATAL", - "message": "'Fn::And' is not allowed inside 'Fn::Equals'", - "source": "SCHEMA", - "category": "Intrinsic Function", - "ruleDescription": "Invalid nesting of intrinsic functions", + "ruleDescription": "Fn::Equals must take exactly two operands of compatible types", "phase": "PARSE" }, { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition '__inline_cond_187' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition '__inline_cond_187' to True. Where existing status for 'NotProduction' is True and '__inline_cond_191' is False", "source": "CFN_LINT", "resourceId": "LogicalConditionalOutput", "propertyPath": "Value.Fn::If.2.Fn::If.1", @@ -70300,7 +72969,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "ProductionBucket", "resourceType": "AWS::S3::Bucket", @@ -70317,7 +72986,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "ProductionBucket", "resourceType": "AWS::S3::Bucket", @@ -70334,7 +73003,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "ProductionBucket", "resourceType": "AWS::S3::Bucket", @@ -70351,7 +73020,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'IsProduction' to False", "source": "CFN_LINT", "resourceId": "ProductionBucket", "resourceType": "AWS::S3::Bucket", @@ -70774,6 +73443,54 @@ "resolutionSource": "Fn::If on condition 'IsProduction'" } }, + { + "ruleId": "E1028", + "severity": "ERROR", + "message": "Fn::If condition '__inline_cond_133' does not exist in Conditions section", + "source": "SCHEMA", + "resourceId": "LogicalConditionResource", + "resourceType": "AWS::CloudWatch::Alarm", + "category": "Intrinsic Function", + "startLine": 169, + "startColumn": 3, + "endLine": 169, + "endColumn": 27, + "ruleDescription": "Fn::If condition must exist in Conditions section", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "E1028", + "severity": "ERROR", + "message": "Fn::If condition '__inline_cond_144' does not exist in Conditions section", + "source": "SCHEMA", + "resourceId": "LogicalConditionResource", + "resourceType": "AWS::CloudWatch::Alarm", + "category": "Intrinsic Function", + "startLine": 169, + "startColumn": 3, + "endLine": 169, + "endColumn": 27, + "ruleDescription": "Fn::If condition must exist in Conditions section", + "phase": "LINT", + "section": "Resources" + }, + { + "ruleId": "E1028", + "severity": "ERROR", + "message": "Fn::If condition '__inline_cond_150' does not exist in Conditions section", + "source": "SCHEMA", + "resourceId": "LogicalConditionResource", + "resourceType": "AWS::CloudWatch::Alarm", + "category": "Intrinsic Function", + "startLine": 169, + "startColumn": 3, + "endLine": 169, + "endColumn": 27, + "ruleDescription": "Fn::If condition must exist in Conditions section", + "phase": "LINT", + "section": "Resources" + }, { "ruleId": "I9001", "severity": "INFO", @@ -70821,7 +73538,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 1, @@ -70933,7 +73650,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 1, @@ -71045,7 +73762,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 2, @@ -71376,7 +74093,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 7, "counts": { "fatal": 2, @@ -71783,7 +74500,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -71845,7 +74562,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -71907,7 +74624,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 1, @@ -72037,7 +74754,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 0, @@ -72277,7 +74994,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 0, @@ -72517,7 +75234,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 1, "counts": { "fatal": 0, @@ -72688,12 +75405,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, "errors": 2, - "warnings": 9, + "warnings": 1, "informational": 38, "debug": 0 }, @@ -72745,118 +75462,6 @@ "ruleDescription": "Parameters have appropriate properties", "phase": "PARSE" }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'SupportsNvme' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 5, - "endLine": 3, - "endColumn": 16, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'UseAdminGroups' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 5, - "endLine": 3, - "endColumn": 16, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'UseAdminUsers' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 5, - "endLine": 3, - "endColumn": 16, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'UseCfnUrl' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 5, - "endLine": 3, - "endColumn": 16, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'UseComputerName' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 5, - "endLine": 3, - "endColumn": 16, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'UseEnvironment' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 5, - "endLine": 3, - "endColumn": 16, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'UseOuPath' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 5, - "endLine": 3, - "endColumn": 16, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'UseWamConfig' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 5, - "endLine": 3, - "endColumn": 16, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, { "ruleId": "W7001", "severity": "WARN", @@ -73541,7 +76146,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 79, "counts": { "fatal": 0, @@ -77787,7 +80392,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 12, "counts": { "fatal": 0, @@ -78272,7 +80877,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 18, "counts": { "fatal": 0, @@ -78556,7 +81161,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { "fatal": 0, @@ -79035,12 +81640,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 28, "counts": { "fatal": 0, "errors": 3, - "warnings": 86, + "warnings": 85, "informational": 63, "debug": 0 }, @@ -79103,20 +81708,6 @@ "ruleDescription": "Use Sub instead of Join", "phase": "LINT" }, - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'IsGovCloud' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 1, - "endLine": 3, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, { "ruleId": "W2506", "severity": "WARN", @@ -81843,7 +84434,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 12, "counts": { "fatal": 0, @@ -82328,7 +84919,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 6, "counts": { "fatal": 0, @@ -83397,7 +85988,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 18, "counts": { "fatal": 0, @@ -83681,12 +86272,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 24, "counts": { "fatal": 1, "errors": 1, - "warnings": 40, + "warnings": 39, "informational": 35, "debug": 0 }, @@ -83718,20 +86309,6 @@ } }, "diagnostics": [ - { - "ruleId": "W8001", - "severity": "WARN", - "message": "Condition 'IsGovCloud' is not used by any resource or Fn::If", - "source": "CFN_LINT", - "category": "Best Practice", - "startLine": 3, - "startColumn": 1, - "endLine": 3, - "endColumn": 11, - "ruleDescription": "Check if Conditions are Used", - "phase": "LINT", - "section": "Conditions" - }, { "ruleId": "W3011", "severity": "WARN", @@ -85187,7 +87764,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 33, "counts": { "fatal": 0, @@ -87376,7 +89953,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 46, "counts": { "fatal": 0, @@ -90648,7 +93225,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 19, "counts": { "fatal": 0, @@ -92719,7 +95296,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 2, "counts": { "fatal": 0, @@ -92912,16 +95489,16 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 5, "counts": { - "fatal": 1, + "fatal": 0, "errors": 0, "warnings": 3, "informational": 15, "debug": 0 }, - "suppressed": 1, + "suppressed": 0, "strict": false, "severityLevel": "DEBUG" }, @@ -92949,15 +95526,6 @@ } }, "diagnostics": [ - { - "ruleId": "F0014", - "severity": "FATAL", - "message": "Fn::Equals: argument 1: true is not of type 'string'", - "source": "SCHEMA", - "category": "Intrinsic Function", - "ruleDescription": "Fn::Equals must have exactly 2 elements", - "phase": "PARSE" - }, { "ruleId": "I9001", "severity": "INFO", @@ -93292,7 +95860,7 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 33, "counts": { "fatal": 0, @@ -95495,12 +98063,12 @@ "status": "OK", "engineVersion": "1.1.0", "metadata": { - "rulesEvaluated": 273, + "rulesEvaluated": 295, "resourcesScanned": 77, "counts": { "fatal": 0, - "errors": 0, - "warnings": 83, + "errors": 1, + "warnings": 82, "informational": 166, "debug": 0 }, @@ -95533,18 +98101,18 @@ }, "diagnostics": [ { - "ruleId": "W1102", - "severity": "WARN", - "message": "Fn::Select: Fn::Select index (first element) must be an integer", - "source": "ENGINE", - "category": "Best Practice", - "ruleDescription": "Invalid intrinsic function usage", + "ruleId": "E1017", + "severity": "ERROR", + "message": "Fn::Select: index (first element) must be an integer or an intrinsic function", + "source": "SCHEMA", + "category": "Intrinsic Function", + "ruleDescription": "Fn::Select requires exactly two operands and a list source", "phase": "PARSE" }, { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NVirginiaRegionCondition' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NVirginiaRegionCondition' to False", "source": "CFN_LINT", "resourceId": "DHCPOptions", "resourceType": "AWS::EC2::DHCPOptions", @@ -96532,7 +99100,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'PrivateSubnetsCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet1ARoute", "resourceType": "AWS::EC2::Route", @@ -96549,7 +99117,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for condition 'PrivateSubnetsCondition' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for 'PrivateSubnetsCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet1ARoute", "resourceType": "AWS::EC2::Route", @@ -96718,7 +99286,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'PrivateSubnetsCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet2ARoute", "resourceType": "AWS::EC2::Route", @@ -96735,7 +99303,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for condition 'PrivateSubnetsCondition' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for 'PrivateSubnetsCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet2ARoute", "resourceType": "AWS::EC2::Route", @@ -96904,7 +99472,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'PrivateSubnets&3AZCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet3ARoute", "resourceType": "AWS::EC2::Route", @@ -96921,7 +99489,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for condition 'PrivateSubnets&3AZCondition' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for 'PrivateSubnets&3AZCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet3ARoute", "resourceType": "AWS::EC2::Route", @@ -97090,7 +99658,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'PrivateSubnets&4AZCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet4ARoute", "resourceType": "AWS::EC2::Route", @@ -97107,7 +99675,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for condition 'PrivateSubnets&4AZCondition' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for 'PrivateSubnets&4AZCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet4ARoute", "resourceType": "AWS::EC2::Route", @@ -97276,7 +99844,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'AdditionalPrivateSubnetsCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet1BRoute", "resourceType": "AWS::EC2::Route", @@ -97293,7 +99861,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for condition 'AdditionalPrivateSubnetsCondition' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for 'AdditionalPrivateSubnetsCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet1BRoute", "resourceType": "AWS::EC2::Route", @@ -97770,7 +100338,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'AdditionalPrivateSubnetsCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet2BRoute", "resourceType": "AWS::EC2::Route", @@ -97787,7 +100355,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for condition 'AdditionalPrivateSubnetsCondition' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for 'AdditionalPrivateSubnetsCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet2BRoute", "resourceType": "AWS::EC2::Route", @@ -98264,7 +100832,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'AdditionalPrivateSubnets&3AZCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet3BRoute", "resourceType": "AWS::EC2::Route", @@ -98281,7 +100849,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for condition 'AdditionalPrivateSubnets&3AZCondition' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for 'AdditionalPrivateSubnets&3AZCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet3BRoute", "resourceType": "AWS::EC2::Route", @@ -98758,7 +101326,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'AdditionalPrivateSubnets&4AZCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet4BRoute", "resourceType": "AWS::EC2::Route", @@ -98775,7 +101343,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for condition 'AdditionalPrivateSubnets&4AZCondition' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATGatewayCondition' to False. Where existing status for 'AdditionalPrivateSubnets&4AZCondition' is True", "source": "CFN_LINT", "resourceId": "PrivateSubnet4BRoute", "resourceType": "AWS::EC2::Route", @@ -99495,7 +102063,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'PrivateSubnetsCondition' is True", "source": "CFN_LINT", "resourceId": "NAT1EIP", "resourceType": "AWS::EC2::EIP", @@ -99567,7 +102135,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'PrivateSubnetsCondition' is True", "source": "CFN_LINT", "resourceId": "NAT2EIP", "resourceType": "AWS::EC2::EIP", @@ -99639,7 +102207,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'PrivateSubnets&3AZCondition' is True", "source": "CFN_LINT", "resourceId": "NAT3EIP", "resourceType": "AWS::EC2::EIP", @@ -99711,7 +102279,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'PrivateSubnets&4AZCondition' is True", "source": "CFN_LINT", "resourceId": "NAT4EIP", "resourceType": "AWS::EC2::EIP", @@ -100040,7 +102608,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATInstanceCondition' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATInstanceCondition' to False", "source": "CFN_LINT", "resourceId": "NATInstance1", "resourceType": "AWS::EC2::Instance", @@ -100193,7 +102761,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATInstanceCondition' to False from current status True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATInstanceCondition' to False", "source": "CFN_LINT", "resourceId": "NATInstance2", "resourceType": "AWS::EC2::Instance", @@ -100329,7 +102897,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'NATInstance&3AZCondition' is True", "source": "CFN_LINT", "resourceId": "NATInstance3", "resourceType": "AWS::EC2::Instance", @@ -100346,7 +102914,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATInstanceCondition' to False. Where existing status for condition 'NATInstance&3AZCondition' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATInstanceCondition' to False. Where existing status for 'NATInstance&3AZCondition' is True", "source": "CFN_LINT", "resourceId": "NATInstance3", "resourceType": "AWS::EC2::Instance", @@ -100482,7 +103050,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True", + "message": "['Fn::If', 1] is not reachable. When setting condition 'NATInstanceCondition' to True. Where existing status for 'NATInstance&4AZCondition' is True", "source": "CFN_LINT", "resourceId": "NATInstance4", "resourceType": "AWS::EC2::Instance", @@ -100499,7 +103067,7 @@ { "ruleId": "W1028", "severity": "WARN", - "message": "['Fn::If', 2] is not reachable. When setting condition 'NATInstanceCondition' to False. Where existing status for condition 'NATInstance&4AZCondition' is True", + "message": "['Fn::If', 2] is not reachable. When setting condition 'NATInstanceCondition' to False. Where existing status for 'NATInstance&4AZCondition' is True", "source": "CFN_LINT", "resourceId": "NATInstance4", "resourceType": "AWS::EC2::Instance", diff --git a/src/resources/templates/bad/E1029_raw_sub_outside.yaml b/src/resources/templates/bad/E1029_raw_sub_outside.yaml new file mode 100644 index 0000000..b26b6f7 --- /dev/null +++ b/src/resources/templates/bad/E1029_raw_sub_outside.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: E1029/F1029 - Substitution variable outside Fn::Sub +Parameters: + Env: + Type: String +Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + # ${Env} references a parameter but is NOT inside Fn::Sub + BucketName: "my-bucket-${Env}-test" diff --git a/src/resources/templates/good/functions/sub_needed_custom_excludes.yaml b/src/resources/templates/bad/E1029_sub_needed_custom_excludes.yaml similarity index 100% rename from src/resources/templates/good/functions/sub_needed_custom_excludes.yaml rename to src/resources/templates/bad/E1029_sub_needed_custom_excludes.yaml diff --git a/src/resources/templates/bad/E1030_length_bad_argument.yaml b/src/resources/templates/bad/E1030_length_bad_argument.yaml new file mode 100644 index 0000000..bb20daa --- /dev/null +++ b/src/resources/templates/bad/E1030_length_bad_argument.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: E1030 - Fn::Length parameter must be an array (LanguageExtensions parameter shape) +Transform: AWS::LanguageExtensions +Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + # Fn::Length argument must be an array; "not-a-list" is a string → E1030 + BucketName: !Sub + - "bucket-${count}" + - count: !Length "not-a-list" diff --git a/src/resources/templates/bad/E1031_tojsonstring_bad_argument.yaml b/src/resources/templates/bad/E1031_tojsonstring_bad_argument.yaml new file mode 100644 index 0000000..92e3b91 --- /dev/null +++ b/src/resources/templates/bad/E1031_tojsonstring_bad_argument.yaml @@ -0,0 +1,9 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: E1031 - Fn::ToJsonString parameter must be a non-empty array or object +Transform: AWS::LanguageExtensions +Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + # Fn::ToJsonString argument must be array or object; "literal" is a string → E1031 + BucketName: !ToJsonString "literal" diff --git a/src/resources/templates/bad/E1033_getstackoutput_missing_outputname.yaml b/src/resources/templates/bad/E1033_getstackoutput_missing_outputname.yaml new file mode 100644 index 0000000..d547f36 --- /dev/null +++ b/src/resources/templates/bad/E1033_getstackoutput_missing_outputname.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: E1033 - Fn::GetStackOutput requires StackName and OutputName keys +Transform: AWS::LanguageExtensions +Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + # Fn::GetStackOutput object missing required OutputName → E1033 + BucketName: + Fn::GetStackOutput: + StackName: producer-stack diff --git a/src/resources/templates/bad/E1033_getstackoutput_no_langext.yaml b/src/resources/templates/bad/E1033_getstackoutput_no_langext.yaml new file mode 100644 index 0000000..e7cc575 --- /dev/null +++ b/src/resources/templates/bad/E1033_getstackoutput_no_langext.yaml @@ -0,0 +1,9 @@ +AWSTemplateFormatVersion: "2010-09-09" +# Missing AWS::LanguageExtensions transform — should trigger E1033 +Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !GetStackOutput + - other-stack + - BucketName diff --git a/src/resources/templates/bad/E1050_dynamic_ref_malformed.yaml b/src/resources/templates/bad/E1050_dynamic_ref_malformed.yaml new file mode 100644 index 0000000..7721b94 --- /dev/null +++ b/src/resources/templates/bad/E1050_dynamic_ref_malformed.yaml @@ -0,0 +1,13 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: E1050 - Dynamic reference structure must match per-flavor format +Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + # SSM with invalid param name (contains spaces) + BucketName: "{{resolve:ssm:my param with spaces}}" + MyQueue: + Type: AWS::SQS::Queue + Properties: + # SecretsManager missing SecretString + QueueName: "{{resolve:secretsmanager:my-secret:InvalidField}}" diff --git a/src/resources/templates/bad/E1058_split_dynamic_ref.yaml b/src/resources/templates/bad/E1058_split_dynamic_ref.yaml new file mode 100644 index 0000000..157580b --- /dev/null +++ b/src/resources/templates/bad/E1058_split_dynamic_ref.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: E1058 - Fn::Split delimiter must not be a dynamic reference +Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Select + - 0 + - !Split + - "{{resolve:ssm:/my/delimiter}}" + - "a-b-c" diff --git a/src/resources/templates/bad/E1059_base64_invalid_nested.yaml b/src/resources/templates/bad/E1059_base64_invalid_nested.yaml new file mode 100644 index 0000000..ec478b0 --- /dev/null +++ b/src/resources/templates/bad/E1059_base64_invalid_nested.yaml @@ -0,0 +1,30 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: E1059 - Fn::Base64 does not support certain nested functions +Resources: + MyFunction: + Type: AWS::Lambda::Function + Properties: + Runtime: python3.12 + Handler: index.handler + Role: !GetAtt LambdaRole.Arn + Code: + ZipFile: | + def handler(event, context): + return "hello" + Environment: + Variables: + Encoded: + Fn::Base64: + Fn::Contains: + - ["a", "b"] + - "a" + LambdaRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole diff --git a/src/resources/templates/bad/E1106_condition_cycle.yaml b/src/resources/templates/bad/E1106_condition_cycle.yaml new file mode 100644 index 0000000..c5b0929 --- /dev/null +++ b/src/resources/templates/bad/E1106_condition_cycle.yaml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: "2010-09-09" +# Conditions reference each other cyclically — should trigger E1106 +Conditions: + CondA: + Fn::And: + - !Condition CondB + - !Equals ["a", "a"] + CondB: + Fn::And: + - !Condition CondA + - !Equals ["b", "b"] +Resources: + MyBucket: + Type: AWS::S3::Bucket + Condition: CondA diff --git a/src/resources/templates/bad/E8001_invalid_condition_shape.yaml b/src/resources/templates/bad/E8001_invalid_condition_shape.yaml new file mode 100644 index 0000000..10409cc --- /dev/null +++ b/src/resources/templates/bad/E8001_invalid_condition_shape.yaml @@ -0,0 +1,9 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + Env: + Type: String +Conditions: + BadString: "not-a-function" +Resources: + Bucket: + Type: AWS::S3::Bucket diff --git a/src/resources/templates/bad/E8004_and_too_few.yaml b/src/resources/templates/bad/E8004_and_too_few.yaml new file mode 100644 index 0000000..c07028b --- /dev/null +++ b/src/resources/templates/bad/E8004_and_too_few.yaml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + Env: + Type: String +Conditions: + OnlyOne: + Fn::And: + - !Condition IsProd + IsProd: + Fn::Equals: + - !Ref Env + - prod +Resources: + Bucket: + Type: AWS::S3::Bucket diff --git a/src/resources/templates/bad/E8004_and_too_many.yaml b/src/resources/templates/bad/E8004_and_too_many.yaml new file mode 100644 index 0000000..4794254 --- /dev/null +++ b/src/resources/templates/bad/E8004_and_too_many.yaml @@ -0,0 +1,41 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + A: + Type: String + B: + Type: String + C: + Type: String + D: + Type: String + E: + Type: String + F: + Type: String + G: + Type: String + H: + Type: String + I: + Type: String + J: + Type: String + K: + Type: String +Conditions: + TooMany: + Fn::And: + - Fn::Equals: [!Ref A, "1"] + - Fn::Equals: [!Ref B, "2"] + - Fn::Equals: [!Ref C, "3"] + - Fn::Equals: [!Ref D, "4"] + - Fn::Equals: [!Ref E, "5"] + - Fn::Equals: [!Ref F, "6"] + - Fn::Equals: [!Ref G, "7"] + - Fn::Equals: [!Ref H, "8"] + - Fn::Equals: [!Ref I, "9"] + - Fn::Equals: [!Ref J, "10"] + - Fn::Equals: [!Ref K, "11"] +Resources: + Bucket: + Type: AWS::S3::Bucket diff --git a/src/resources/templates/bad/E8005_not_too_many.yaml b/src/resources/templates/bad/E8005_not_too_many.yaml new file mode 100644 index 0000000..6790400 --- /dev/null +++ b/src/resources/templates/bad/E8005_not_too_many.yaml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + Env: + Type: String +Conditions: + IsProd: + Fn::Equals: + - !Ref Env + - prod + NotBad: + Fn::Not: + - !Condition IsProd + - !Condition IsProd +Resources: + Bucket: + Type: AWS::S3::Bucket diff --git a/src/resources/templates/bad/E8006_or_too_few.yaml b/src/resources/templates/bad/E8006_or_too_few.yaml new file mode 100644 index 0000000..9f401fb --- /dev/null +++ b/src/resources/templates/bad/E8006_or_too_few.yaml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + Env: + Type: String +Conditions: + IsProd: + Fn::Equals: + - !Ref Env + - prod + OnlyOne: + Fn::Or: + - !Condition IsProd +Resources: + Bucket: + Type: AWS::S3::Bucket diff --git a/src/resources/templates/bad/E8007_condition_undefined.yaml b/src/resources/templates/bad/E8007_condition_undefined.yaml new file mode 100644 index 0000000..8a832c2 --- /dev/null +++ b/src/resources/templates/bad/E8007_condition_undefined.yaml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + Env: + Type: String +Conditions: + IsProd: + Fn::Equals: + - !Ref Env + - prod + Combined: + Fn::And: + - !Condition IsProd + - !Condition Nope +Resources: + Bucket: + Type: AWS::S3::Bucket diff --git a/src/resources/templates/bad/W1019_sub_unused_key.yaml b/src/resources/templates/bad/W1019_sub_unused_key.yaml new file mode 100644 index 0000000..dd2f34c --- /dev/null +++ b/src/resources/templates/bad/W1019_sub_unused_key.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: W1019 - Fn::Sub parameter map key not referenced in template string +Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::Sub: + - "my-bucket-${AWS::StackName}" + - UnusedKey: "some-value" + AnotherUnused: "not-referenced" diff --git a/src/resources/templates/bad/W1051_secretsmanager_at_arn.yaml b/src/resources/templates/bad/W1051_secretsmanager_at_arn.yaml new file mode 100644 index 0000000..a961d82 --- /dev/null +++ b/src/resources/templates/bad/W1051_secretsmanager_at_arn.yaml @@ -0,0 +1,13 @@ +AWSTemplateFormatVersion: '2010-09-09' +# A Secrets Manager dynamic reference at a Secrets-Manager-ARN-expecting +# property expands to the secret VALUE, not the ARN. The DMS endpoint's +# SecretsManagerSecretId field requires the ARN of the secret, so this +# substring will deploy a value where an ARN was needed. +Resources: + Endpoint: + Type: AWS::DMS::Endpoint + Properties: + EndpointType: source + EngineName: mysql + SecretsManagerSecretId: '{{resolve:secretsmanager:MySecret:SecretString:secret_arn}}' + SecretsManagerAccessRoleArn: arn:aws:iam::123456789012:role/dms-access diff --git a/src/resources/templates/bad/W1054_raw_pseudo_param.yaml b/src/resources/templates/bad/W1054_raw_pseudo_param.yaml new file mode 100644 index 0000000..f3188e2 --- /dev/null +++ b/src/resources/templates/bad/W1054_raw_pseudo_param.yaml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: W1054 - Pseudo-parameter used as a raw literal string instead of via Ref +# Each property value below is EXACTLY a pseudo-parameter name. CloudFormation +# does not substitute a bare literal like "AWS::Region" — only Ref does — so the +# author almost certainly meant {Ref: AWS::Region}. W1054 fires on exact equality +# (matching cfn-lint's RawPseudoParameter), not on substrings. +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + DisplayName: "AWS::Region" + MyParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: "AWS::AccountId" diff --git a/src/resources/templates/good/parameters/used_transform_language_extension.json b/src/resources/templates/bad/W8001_unused_lang_ext_conditions.json similarity index 100% rename from src/resources/templates/good/parameters/used_transform_language_extension.json rename to src/resources/templates/bad/W8001_unused_lang_ext_conditions.json diff --git a/src/resources/templates/good/both_forms.yaml b/src/resources/templates/bad/W9053_equivalent_both_forms.yaml similarity index 100% rename from src/resources/templates/good/both_forms.yaml rename to src/resources/templates/bad/W9053_equivalent_both_forms.yaml diff --git a/src/resources/templates/bad/W9053_equivalent_conditions.yaml b/src/resources/templates/bad/W9053_equivalent_conditions.yaml new file mode 100644 index 0000000..0125a7b --- /dev/null +++ b/src/resources/templates/bad/W9053_equivalent_conditions.yaml @@ -0,0 +1,17 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: W9053 - Equivalent conditions detected +Parameters: + Env: + Type: String + AllowedValues: + - prod + - dev +Conditions: + IsProd: + !Equals [!Ref Env, prod] + IsProduction: + !Equals [!Ref Env, prod] +Resources: + Bucket: + Type: AWS::S3::Bucket + Condition: IsProd diff --git a/src/resources/templates/bad/rules_contradict_condition.yaml b/src/resources/templates/bad/rules_contradict_condition.yaml new file mode 100644 index 0000000..a962e3b --- /dev/null +++ b/src/resources/templates/bad/rules_contradict_condition.yaml @@ -0,0 +1,38 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: | + Rules section implies constraint that downstream rules can detect. + ProdRule asserts that when Env=prod, InstanceType must be m5.large. + This template references a conditional resource with a mutually exclusive condition. +Parameters: + Env: + Type: String + AllowedValues: + - prod + - dev + InstanceType: + Type: String + AllowedValues: + - t3.micro + - m5.large +Rules: + ProdRule: + RuleCondition: + !Equals [!Ref Env, prod] + Assertions: + - Assert: + !Equals [!Ref InstanceType, m5.large] + AssertDescription: Prod must use m5.large +Conditions: + IsProd: + !Equals [!Ref Env, prod] + IsDev: + !Equals [!Ref Env, dev] +Resources: + ProdBucket: + Type: AWS::S3::Bucket + Condition: IsProd + DevBucket: + Type: AWS::S3::Bucket + Condition: IsDev + Properties: + BucketName: !GetAtt ProdBucket.Arn diff --git a/src/resources/templates/good/W9053_no_equivalent_conditions.yaml b/src/resources/templates/good/W9053_no_equivalent_conditions.yaml new file mode 100644 index 0000000..b310cc7 --- /dev/null +++ b/src/resources/templates/good/W9053_no_equivalent_conditions.yaml @@ -0,0 +1,21 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: W9053 - Non-equivalent conditions (no diagnostic expected) +Parameters: + Env: + Type: String + AllowedValues: + - prod + - dev + - staging +Conditions: + IsProd: + !Equals [!Ref Env, prod] + IsDev: + !Equals [!Ref Env, dev] +Resources: + Bucket: + Type: AWS::S3::Bucket + Condition: IsProd + DevBucket: + Type: AWS::S3::Bucket + Condition: IsDev diff --git a/src/resources/templates/good/good_conditions_shape.yaml b/src/resources/templates/good/good_conditions_shape.yaml new file mode 100644 index 0000000..26d0807 --- /dev/null +++ b/src/resources/templates/good/good_conditions_shape.yaml @@ -0,0 +1,46 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + Env: + Type: String + Region: + Type: String +Conditions: + IsProd: + Fn::Equals: + - !Ref Env + - prod + IsUsEast: + Fn::Equals: + - !Ref Region + - us-east-1 + BothProdAndUsEast: + Fn::And: + - !Condition IsProd + - !Condition IsUsEast + EitherProdOrUsEast: + Fn::Or: + - !Condition IsProd + - !Condition IsUsEast + NotProd: + Fn::Not: + - !Condition IsProd + TripleAnd: + Fn::And: + - !Condition IsProd + - !Condition IsUsEast + - !Condition NotProd + TenOr: + Fn::Or: + - !Condition IsProd + - !Condition IsUsEast + - !Condition NotProd + - !Condition IsProd + - !Condition IsUsEast + - !Condition NotProd + - !Condition IsProd + - !Condition IsUsEast + - !Condition NotProd + - !Condition IsProd +Resources: + Bucket: + Type: AWS::S3::Bucket diff --git a/src/resources/templates/good/intrinsic_shapes_ok.yaml b/src/resources/templates/good/intrinsic_shapes_ok.yaml new file mode 100644 index 0000000..9d4dda0 --- /dev/null +++ b/src/resources/templates/good/intrinsic_shapes_ok.yaml @@ -0,0 +1,65 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Good template - all intrinsic shapes are valid +Resources: + # Valid Split with normal delimiter — Select[0] = "abc" (valid S3 bucket name) + SplitExample: + Type: AWS::S3::Bucket + Properties: + BucketName: !Select + - 0 + - !Split + - "-" + - "abc-def-ghi" + + # Valid Base64 with allowed nested function + Base64Example: + Type: AWS::Lambda::Function + Properties: + Runtime: python3.12 + Handler: index.handler + Role: !GetAtt LambdaRole.Arn + Code: + ZipFile: | + def handler(event, context): + return "hello" + Environment: + Variables: + Encoded: + Fn::Base64: + Fn::Sub: "Hello ${AWS::Region}" + + # Valid Sub with all keys referenced + SubExample: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::Sub: + - "my-bucket-${Env}-${Region}" + - Env: "prod" + Region: "us-east-1" + + # Valid dynamic references + DynRefExample: + Type: AWS::S3::Bucket + Properties: + BucketName: "{{resolve:ssm:/my/param:1}}" + Tags: + - Key: Secret + Value: "{{resolve:secretsmanager:my-secret:SecretString:key}}" + + # Valid use of pseudo-parameter via Ref (not raw string) + RefExample: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub "bucket-${AWS::Region}" + + LambdaRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole diff --git a/src/rules/src/registry.rs b/src/rules/src/registry.rs index e25a42a..b08fc2e 100644 --- a/src/rules/src/registry.rs +++ b/src/rules/src/registry.rs @@ -129,6 +129,31 @@ pub fn build_rule_metadata_map() -> HashMap { .collect() } +/// Static registry of every rule the engine can emit. +/// +/// Each rule has a stable ID, a category, a description, and an origin tag. +/// +/// # Origin policy +/// +/// `RuleOrigin` reflects where a rule's *logic* originates. The categories are: +/// +/// - `Schema` — structural rule provable against the parser or compiled +/// CloudFormation resource schemas. Triggers a guaranteed +/// deployment failure if violated. +/// - `Engine` — implemented natively by this engine; no external counterpart. +/// - `CfnLint` — historically ported from an external linter; the rule +/// surfaces a semantic concern (best practice, deprecation, +/// risky pattern) that does not by itself block deployment. +/// - `Schema`-tagged structural rules take precedence over `CfnLint`: when a +/// diagnostic is a guaranteed deployment failure (wrong intrinsic arity, +/// wrong shape, undefined condition reference, malformed dynamic reference), +/// it is classified as `Schema` regardless of any external linter that +/// happens to share the rule ID. Examples: `E8001`–`E8007` (condition +/// shape and reference existence), `E1028` (`Fn::If` references undefined +/// condition), `E1029` (`${X}` substitution requires `Fn::Sub`), +/// `E1033` (`Fn::GetStackOutput` parameter shape and transform requirement), +/// `E1050` (dynamic-reference structure). The IDs themselves are stable +/// so authors can search for them across tools. pub const RULE_REGISTRY: &[RuleDefinition] = &[ RuleDefinition { id: "F0000", @@ -136,6 +161,12 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ description: "Duplicate key in template", origin: RuleOrigin::Schema, }, + RuleDefinition { + id: "W1100", + category: Category::BestPractice, + description: "YAML merge key '<<' is not supported by CloudFormation", + origin: RuleOrigin::CfnLint, + }, RuleDefinition { id: "F0001", category: Category::Structure, @@ -214,12 +245,6 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ description: "Fn::If must have exactly 3 elements", origin: RuleOrigin::Schema, }, - RuleDefinition { - id: "F0014", - category: Category::Intrinsic, - description: "Fn::Equals must have exactly 2 elements", - origin: RuleOrigin::Schema, - }, RuleDefinition { id: "F0015", category: Category::Parameter, @@ -347,11 +372,101 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ origin: RuleOrigin::Schema, }, RuleDefinition { - id: "F8002", + id: "E1058", + category: Category::Intrinsic, + description: "Fn::Split delimiter must not be a dynamic reference", + origin: RuleOrigin::Engine, + }, + RuleDefinition { + id: "W1019", + category: Category::BestPractice, + description: "Fn::Sub parameter map keys must be referenced in the template string", + origin: RuleOrigin::CfnLint, + }, + RuleDefinition { + id: "E1059", + category: Category::Intrinsic, + description: "Fn::Base64 nested function must be on the allowed list", + origin: RuleOrigin::Engine, + }, + RuleDefinition { + id: "E1029", + category: Category::Intrinsic, + description: "Substitution variable ${X} requires Fn::Sub", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E1033", + category: Category::Intrinsic, + description: "Fn::GetStackOutput requires AWS::LanguageExtensions and StackName/OutputName keys", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E1050", + category: Category::Intrinsic, + description: "Dynamic reference must match the SSM, ssm-secure, or Secrets Manager format", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "W1051", + category: Category::BestPractice, + description: "Secrets Manager dynamic reference resolves to value, not ARN — placing it where an ARN is expected is incorrect", + origin: RuleOrigin::CfnLint, + }, + RuleDefinition { + id: "W1054", + category: Category::BestPractice, + description: "Pseudo-parameter referenced as a raw string instead of via Ref", + origin: RuleOrigin::CfnLint, + }, + RuleDefinition { + id: "E1106", + category: Category::Structure, + description: "Cycle detected between condition definitions in the Conditions section", + origin: RuleOrigin::Engine, + }, + RuleDefinition { + id: "I9052", + category: Category::Structure, + description: "Condition or intrinsic could not be fully analyzed because the SAT solver budget was exceeded", + origin: RuleOrigin::Engine, + }, + RuleDefinition { + id: "E8002", category: Category::Structure, description: "Condition referenced by resource is not defined", origin: RuleOrigin::Schema, }, + RuleDefinition { + id: "E8003", + category: Category::Intrinsic, + description: "Fn::Equals must take exactly two operands of compatible types", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E8004", + category: Category::Intrinsic, + description: "Fn::And must take between 2 and 10 boolean conditions", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E8005", + category: Category::Intrinsic, + description: "Fn::Not must take exactly one boolean condition", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E8006", + category: Category::Intrinsic, + description: "Fn::Or must take between 2 and 10 boolean conditions", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E8007", + category: Category::Intrinsic, + description: "Condition function value must be a string referencing a defined condition", + origin: RuleOrigin::Schema, + }, RuleDefinition { id: "I1003", category: Category::Structure, @@ -415,8 +530,8 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ RuleDefinition { id: "E8001", category: Category::Structure, - description: "Conditions have appropriate properties", - origin: RuleOrigin::CfnLint, + description: "Conditions section must have valid structure", + origin: RuleOrigin::Schema, }, RuleDefinition { id: "W8001", @@ -430,6 +545,12 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ description: "Fn::Equals will always return true or false", origin: RuleOrigin::CfnLint, }, + RuleDefinition { + id: "W9053", + category: Category::BestPractice, + description: "Conditions are semantically equivalent and can be consolidated", + origin: RuleOrigin::Engine, + }, RuleDefinition { id: "F8600", category: Category::Structure, @@ -539,10 +660,10 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ origin: RuleOrigin::Engine, }, RuleDefinition { - id: "F1029", - category: Category::Intrinsic, - description: "Sub is required if a variable is used in a string", - origin: RuleOrigin::Schema, + id: "W1056", + category: Category::BestPractice, + description: "Fn::Sub template contains escaped intrinsic syntax suggesting a nesting mistake", + origin: RuleOrigin::Engine, }, RuleDefinition { id: "E1040", @@ -557,15 +678,9 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ origin: RuleOrigin::CfnLint, }, RuleDefinition { - id: "F1050", - category: Category::Intrinsic, - description: "Select index must be non-negative", - origin: RuleOrigin::Schema, - }, - RuleDefinition { - id: "F1060", + id: "E1028", category: Category::Intrinsic, - description: "Fn::If condition must exist in Conditions", + description: "Fn::If condition must exist in Conditions section", origin: RuleOrigin::Schema, }, RuleDefinition { @@ -587,16 +702,10 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ origin: RuleOrigin::CfnLint, }, RuleDefinition { - id: "F1104", - category: Category::Structure, - description: "Duplicate logical ID in template", - origin: RuleOrigin::Schema, - }, - RuleDefinition { - id: "F1105", + id: "E1101", category: Category::Intrinsic, description: "Invalid nesting of intrinsic functions", - origin: RuleOrigin::Schema, + origin: RuleOrigin::Engine, }, RuleDefinition { id: "E1150", @@ -1240,6 +1349,12 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ description: "RDS instance PubliclyAccessible is true", origin: RuleOrigin::Engine, }, + RuleDefinition { + id: "W9032", + category: Category::BestPractice, + description: "Fn::ForEach macro could not be expanded statically", + origin: RuleOrigin::Engine, + }, RuleDefinition { id: "W3663", category: Category::Security, @@ -1499,7 +1614,7 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ origin: RuleOrigin::CfnLint, }, RuleDefinition { - id: "W3041", + id: "W9054", category: Category::BestPractice, description: "Write-only property referenced in output", origin: RuleOrigin::Engine, @@ -1552,6 +1667,48 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ description: "ImportValue validation of parameters", origin: RuleOrigin::CfnLint, }, + RuleDefinition { + id: "E1011", + category: Category::Intrinsic, + description: "Fn::FindInMap operands must be strings or one of Ref/Fn::FindInMap", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E1017", + category: Category::Intrinsic, + description: "Fn::Select requires exactly two operands and a list source", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E1018", + category: Category::Intrinsic, + description: "Fn::Split source must be a string or a string-producing intrinsic", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E1019", + category: Category::Intrinsic, + description: "Fn::Sub variable map values must be strings or string-producing intrinsics", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E1021", + category: Category::Intrinsic, + description: "Fn::Base64 argument must be a string or a string-producing intrinsic", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E1022", + category: Category::Intrinsic, + description: "Fn::Join requires a string delimiter and a list of strings or string-producing intrinsics", + origin: RuleOrigin::Schema, + }, + RuleDefinition { + id: "E1024", + category: Category::Intrinsic, + description: "Fn::Cidr requires a CIDR-format ipBlock string and integer count/cidrBits", + origin: RuleOrigin::Schema, + }, RuleDefinition { id: "E1027", category: Category::Intrinsic, @@ -1561,20 +1718,20 @@ pub const RULE_REGISTRY: &[RuleDefinition] = &[ RuleDefinition { id: "E1030", category: Category::Intrinsic, - description: "Length validation of parameters", - origin: RuleOrigin::CfnLint, + description: "Fn::Length requires AWS::LanguageExtensions transform and a list argument", + origin: RuleOrigin::Schema, }, RuleDefinition { id: "E1031", category: Category::Intrinsic, - description: "ToJsonString validation of parameters", - origin: RuleOrigin::CfnLint, + description: "Fn::ToJsonString requires AWS::LanguageExtensions transform", + origin: RuleOrigin::Schema, }, RuleDefinition { id: "E1032", category: Category::Intrinsic, - description: "Validates ForEach functions", - origin: RuleOrigin::CfnLint, + description: "Fn::ForEach requires AWS::LanguageExtensions transform", + origin: RuleOrigin::Schema, }, RuleDefinition { id: "E1051", diff --git a/src/schema-validator/README.md b/src/schema-validator/README.md index 9703287..5157afe 100644 --- a/src/schema-validator/README.md +++ b/src/schema-validator/README.md @@ -12,46 +12,3 @@ invalid enum values, pattern failures, constraint violations, lifecycle issues, | `validate(model, region)` | Run all schema checks, returns diagnostics + timing metric | | `schema_count()` | Number of loaded resource type schemas | | `list_rules()` | All schema rule definitions | - -## Rules - -Every rule ID the validator can emit, with descriptions mirrored verbatim from the rule registry (the single source of -truth surfaced by `--list-rules`): - -| Rule ID | Description | -|---------|------------------------------------------------------------| -| `E3710` | Resource type is from a service that has been shut down | -| `W3696` | Resource type is from a service that is sunsetting | -| `W3697` | Resource type is from a service in maintenance mode | -| `W9009` | Resource type sunset or shutdown | -| `E2531` | Check if Lambda Function Runtimes are blocked for create | -| `E2533` | Check if Lambda Function Runtimes are updatable | -| `W2531` | Check if EOL Lambda Function Runtimes are used | -| `E9001` | Resource type must be recognized | -| `E9006` | Property value not valid for conditional extension enum | -| `F3002` | Additional properties are not allowed | -| `F3003` | Required property missing | -| `F3012` | Property type mismatch | -| `W9003` | Property type coercion warning | -| `F3014` | Exactly one of properties required (requiredXor) | -| `F3058` | One of properties required (requiredOr) | -| `F3017` | Value not valid under anyOf | -| `F3018` | Value not valid under oneOf | -| `F3020` | Mutually exclusive properties | -| `F3021` | Dependent property required | -| `F3030` | Value not in allowed enum | -| `F3031` | Value does not match pattern | -| `F3032` | Array item count out of bounds | -| `F3033` | String length out of bounds | -| `F3034` | Numeric value out of bounds | -| `F3037` | Array items not unique | -| `E3030` | Check if properties have a valid value | -| `E3040` | Read only property should not be specified | -| `W3041` | Write-only property referenced in output | -| `E1103` | Validate the format of a value | -| `I9001` | Create-only property updated triggers resource replacement | -| `I9002` | Property is ignored in this configuration (from extension) | - -Validation is condition-aware — property values are checked across all condition scenarios, and diagnostics include -the condition truth values that trigger each violation. Region-specific enum values (e.g., instance types) produce -region-aware error messages. diff --git a/src/schema-validator/src/lib.rs b/src/schema-validator/src/lib.rs index ee3e06c..927daec 100644 --- a/src/schema-validator/src/lib.rs +++ b/src/schema-validator/src/lib.rs @@ -64,7 +64,7 @@ impl SchemaValidator { // Every rule ID the schema-validator can emit (see src/validate.rs). const SCHEMA_RULE_IDS: &[&str] = &[ "F3002", "F3003", "F3012", "F3014", "F3017", "F3018", "F3020", "F3021", "F3030", "F3031", "F3032", "F3033", - "F3034", "F3037", "E3040", "W3041", "F3058", "E3030", "E9001", "E9006", "E2531", "E2533", "E3710", "E1103", + "F3034", "F3037", "E3040", "W9054", "F3058", "E3030", "E9001", "E9006", "E2531", "E2533", "E3710", "E1103", "W9003", "W2531", "W3696", "W3697", "W9009", "I9001", "I9002", ]; SCHEMA_RULE_IDS.iter().filter_map(|id| lookup_rule(id).map(|r| r.to_rule_info())).collect() diff --git a/src/schema-validator/src/validate.rs b/src/schema-validator/src/validate.rs index 4fbc6f7..90f13f8 100644 --- a/src/schema-validator/src/validate.rs +++ b/src/schema-validator/src/validate.rs @@ -210,7 +210,7 @@ pub fn enrich_schema_context(diagnostics: &mut [Diagnostic], store: &CompiledSch .insert("replacement_strategy".into(), serde_json::json!(rs).into()); } } - "W3041" => { + "W9054" => { ensure_ctx!(d).lifecycle = Some("write-only".into()); } _ => {} @@ -338,7 +338,7 @@ fn validate_resource( { let output_name = edge.source_resource.strip_prefix("__output__").unwrap_or(&edge.source_resource); out.push(build_diagnostic( - "W3041", + "W9054", &format!("Write-only property '{}' of '{}' is referenced in output '{}'", top, rid, output_name), m, rid, diff --git a/src/schema-validator/tests/integration.rs b/src/schema-validator/tests/integration.rs index 1d6bdd2..9310b89 100644 --- a/src/schema-validator/tests/integration.rs +++ b/src/schema-validator/tests/integration.rs @@ -412,11 +412,11 @@ fn property_w3042_deprecated() { } #[test] -fn property_w3041_write_only_in_output() { +fn property_w9054_write_only_in_output() { let diags = validate_fixture("bad/schema_write_only.yaml"); - let w3041 = diags_for(&diags, "W3041"); - assert!(!w3041.is_empty(), "expected W3041 for write-only Certificate referenced in output"); - assert!(w3041[0].message.contains("Write-only") || w3041[0].message.contains("write-only")); + let w9054 = diags_for(&diags, "W9054"); + assert!(!w9054.is_empty(), "expected W9054 for write-only Certificate referenced in output"); + assert!(w9054[0].message.contains("Write-only") || w9054[0].message.contains("write-only")); } // ── Region availability ──────────────────────────────────────────── diff --git a/src/template-model/README.md b/src/template-model/README.md index 2af46e8..13517dc 100644 --- a/src/template-model/README.md +++ b/src/template-model/README.md @@ -17,7 +17,7 @@ All engines ([rego-engine](../rego-engine/README.md), [cel-engine](../cel-engine Resources, Outputs, Rules, Metadata, Transforms, Globals). 2. **Resolve** — Walks each resource and output, resolving all intrinsic functions into `ResolvedValue` variants. 3. **Validate** — Builds a reference graph, detects cycles, validates intrinsic function nesting, and emits parse-time - diagnostics (`F3004` cycles, `F1104` undefined conditions, `F1105` invalid nesting, `W8003` tautological conditions). + diagnostics (`F3004` cycles, `E1028` undefined conditions, `E1101` invalid nesting, `W8003` tautological conditions). ## Intrinsic Function Support diff --git a/src/template-model/src/aws_regions.rs b/src/template-model/src/aws_regions.rs new file mode 100644 index 0000000..5004c8a --- /dev/null +++ b/src/template-model/src/aws_regions.rs @@ -0,0 +1,170 @@ +//! Canonical mapping of AWS regions to their availability-zone suffixes. +//! +//! Single source of truth used by the resolver (`Fn::GetAZs`) and the rule +//! engines. Only generally-available (GA) regions are listed — opt-in regions +//! that have GA'd are included; pre-GA / private-preview regions are not. +//! +//! The canonical list is the AWS service-endpoints documentation: +//! . China regions +//! are documented separately at the same source. + +/// Returns the AZ-letter suffixes for a region, or `None` if the region is +/// unknown / not GA. +/// +/// `Fn::GetAZs` returns concrete AZ names of the form `` +/// (e.g. `us-east-1a`). The suffix list captures the AZs actually exposed by +/// each region — most regions have three (a, b, c); a few legacy regions +/// have gaps (us-west-1 has two AZs, ca-central-1 skips "c"). +pub fn availability_zone_suffixes(region: &str) -> Option<&'static [&'static str]> { + match region { + "us-east-1" => Some(&["a", "b", "c", "d", "e", "f"]), + "us-east-2" => Some(&["a", "b", "c"]), + "us-west-1" => Some(&["a", "b"]), + "us-west-2" => Some(&["a", "b", "c", "d"]), + + "ca-central-1" => Some(&["a", "b", "d"]), + "ca-west-1" => Some(&["a", "b", "d"]), + + "sa-east-1" => Some(&["a", "b", "c"]), + + "mx-central-1" => Some(&["a", "b", "c"]), + + "eu-central-1" => Some(&["a", "b", "c"]), + "eu-central-2" => Some(&["a", "b", "c"]), + "eu-west-1" => Some(&["a", "b", "c"]), + "eu-west-2" => Some(&["a", "b", "c"]), + "eu-west-3" => Some(&["a", "b", "c"]), + "eu-north-1" => Some(&["a", "b", "c"]), + "eu-south-1" => Some(&["a", "b", "c"]), + "eu-south-2" => Some(&["a", "b", "c"]), + + "af-south-1" => Some(&["a", "b", "c"]), + + "me-south-1" => Some(&["a", "b", "c"]), + "me-central-1" => Some(&["a", "b", "c"]), + + "il-central-1" => Some(&["a", "b", "c"]), + + "ap-east-1" => Some(&["a", "b", "c"]), + "ap-east-2" => Some(&["a", "b", "c"]), + "ap-northeast-1" => Some(&["a", "b", "c", "d"]), + "ap-northeast-2" => Some(&["a", "b", "c", "d"]), + "ap-northeast-3" => Some(&["a", "b", "c"]), + + "ap-south-1" => Some(&["a", "b", "c"]), + "ap-south-2" => Some(&["a", "b", "c"]), + + "ap-southeast-1" => Some(&["a", "b", "c"]), + "ap-southeast-2" => Some(&["a", "b", "c"]), + "ap-southeast-3" => Some(&["a", "b", "c"]), + "ap-southeast-4" => Some(&["a", "b", "c"]), + "ap-southeast-5" => Some(&["a", "b", "c"]), + "ap-southeast-6" => Some(&["a", "b", "c"]), + "ap-southeast-7" => Some(&["a", "b", "c"]), + + "us-gov-east-1" => Some(&["a", "b", "c"]), + "us-gov-west-1" => Some(&["a", "b", "c"]), + + // China is a separate AWS partition; included because its regions are GA. + "cn-north-1" => Some(&["a", "b", "c"]), + "cn-northwest-1" => Some(&["a", "b", "c"]), + + _ => None, + } +} + +/// Returns fully-qualified AZ names for a region +/// (e.g. `["us-east-1a", "us-east-1b", ...]`), or `None` if the region is +/// unknown / not GA. +pub fn availability_zones_for_region(region: &str) -> Option> { + availability_zone_suffixes(region).map(|suffixes| suffixes.iter().map(|s| format!("{}{}", region, s)).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_regions_return_azs() { + assert_eq!(availability_zones_for_region("us-east-1").unwrap().len(), 6); + assert!(availability_zones_for_region("eu-south-1").is_some()); + assert!(availability_zones_for_region("us-gov-west-1").is_some()); + assert!(availability_zones_for_region("cn-north-1").is_some()); + } + + #[test] + fn unknown_region_returns_none() { + assert!(availability_zones_for_region("xx-fantasy-1").is_none()); + } + + #[test] + fn eu_south_1_returns_concrete_azs() { + let azs = availability_zones_for_region("eu-south-1").unwrap(); + assert_eq!(azs.len(), 3); + assert_eq!(azs[0], "eu-south-1a"); + } + + /// Newly-launched GA regions (Taipei, Kuala Lumpur, New Zealand, Thailand, + /// Mexico) must resolve to concrete AZ enumerations rather than falling + /// back to `Dynamic`. This test guards against regression. + #[test] + fn newly_added_ga_regions_resolve() { + for region in ["ap-east-2", "ap-southeast-5", "ap-southeast-6", "ap-southeast-7", "mx-central-1"] { + let azs = availability_zones_for_region(region) + .unwrap_or_else(|| panic!("expected GA region '{}' to be supported", region)); + assert_eq!(azs.len(), 3, "expected 3 AZs for {}, got {:?}", region, azs); + assert_eq!(azs[0], format!("{}a", region)); + } + } + + /// us-west-1 only exposes two AZs (a, b) — many newer accounts can't see + /// the historical "us-west-1c" zone. Lock the count to 2 to match the + /// behaviour of `Fn::GetAZs` for typical accounts. + #[test] + fn us_west_1_has_two_azs() { + let azs = availability_zones_for_region("us-west-1").unwrap(); + assert_eq!(azs.len(), 2); + assert_eq!(azs, vec!["us-west-1a", "us-west-1b"]); + } + + /// ca-central-1 skips the "c" suffix in CloudFormation `Fn::GetAZs` + /// output — its three exposed AZs are a, b, d. + #[test] + fn ca_central_1_skips_c() { + let azs = availability_zones_for_region("ca-central-1").unwrap(); + assert_eq!(azs, vec!["ca-central-1a", "ca-central-1b", "ca-central-1d"]); + } + + /// Sanity-check that the entire AWS GA region set is covered. Whenever + /// a new GA region launches, this list must be updated alongside the + /// `match` in `availability_zone_suffixes`. + #[test] + fn all_ga_regions_covered() { + let ga_regions = [ + "us-east-1", "us-east-2", "us-west-1", "us-west-2", + "ca-central-1", "ca-west-1", + "sa-east-1", + "mx-central-1", + "eu-central-1", "eu-central-2", "eu-west-1", "eu-west-2", "eu-west-3", + "eu-north-1", "eu-south-1", "eu-south-2", + "af-south-1", + "me-south-1", "me-central-1", + "il-central-1", + "ap-east-1", "ap-east-2", + "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", + "ap-south-1", "ap-south-2", + "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-4", "ap-southeast-5", "ap-southeast-6", "ap-southeast-7", + "us-gov-east-1", "us-gov-west-1", + "cn-north-1", "cn-northwest-1", + ]; + for r in ga_regions { + assert!( + availability_zones_for_region(r).is_some(), + "GA region '{}' missing from availability_zone_suffixes", + r + ); + } + assert_eq!(ga_regions.len(), 38); + } +} diff --git a/src/template-model/src/condition_shape.rs b/src/template-model/src/condition_shape.rs new file mode 100644 index 0000000..0966060 --- /dev/null +++ b/src/template-model/src/condition_shape.rs @@ -0,0 +1,423 @@ +//! Author-time validation of condition expression shapes. +//! +//! The parser accepts well-formed boolean intrinsics into `IntrinsicFn::And`, +//! `IntrinsicFn::Or`, `IntrinsicFn::Not`, and `IntrinsicFn::Equals` — but +//! ill-formed shapes (non-intrinsic top-level condition values, undefined +//! `Condition:` references, `Fn::Equals` operands that aren't strings or +//! string-producing intrinsics) need a separate pass once the IR exists. + +use crate::consts::*; +use crate::ir::*; +use std::collections::HashSet; + +/// Rule ID for a condition value that is not a single-key boolean-producing +/// intrinsic. Sourced from the schema layer because CloudFormation itself +/// rejects these templates at deploy time. +const RULE_TOP_LEVEL_SHAPE: &str = "E8001"; +const RULE_EQUALS_OPERAND: &str = "E8003"; +const RULE_AND_ARITY: &str = "E8004"; +const RULE_OR_ARITY: &str = "E8006"; +const RULE_UNDEFINED_CONDITION_REF: &str = "E8007"; + +const CONDITIONS_PATH_PREFIX: &str = "Conditions/"; +const RULES_PATH_PREFIX: &str = "Rules/"; + +pub fn validate_condition_shapes( + arena: &Arena, + conditions_node: NodeRef, + defined_conditions: &HashSet, + transforms: &[String], +) -> Vec { + let mut out = Vec::new(); + let has_lang_ext = transforms.iter().any(|t| t == TRANSFORM_LANGUAGE_EXTENSIONS); + + if conditions_node != NULL_REF { + if let Some(entries) = arena.as_map(conditions_node) { + for (name, node_ref) in entries { + if has_lang_ext && name.starts_with("Fn::ForEach::") { + continue; + } + validate_top_level_condition(arena, name, *node_ref, &mut out); + } + } else { + // The Conditions section must be a mapping of condition name to a + // boolean expression. A list or scalar here is a structural error + // CloudFormation rejects at deploy time. + out.push(crate::make_parse_diagnostic( + RULE_TOP_LEVEL_SHAPE, + "Conditions section must be a mapping of condition names to boolean expressions".to_string(), + arena.get(conditions_node).span, + )); + } + } + + validate_intrinsic_shapes(arena, defined_conditions, &mut out); + + out +} + +/// A top-level condition value must be a single-key map whose key is a +/// boolean-producing intrinsic — the parser folds well-formed cases into +/// `IntrinsicFn`, so anything left as `Node::Map`/`Node::String`/etc. is +/// malformed. +fn validate_top_level_condition( + arena: &Arena, + name: &str, + node_ref: NodeRef, + out: &mut Vec, +) { + let spanned = arena.get(node_ref); + if matches!(spanned.node, Node::Intrinsic(_)) { + return; + } + + // When a condition value uses a boolean condition function but is + // malformed (wrong operand type or arity), the parser cannot fold it into + // an `IntrinsicFn` and leaves it as a plain single-key `Node::Map` — but it + // has already emitted the specific shape diagnostic (E8003/E8004/E8005/ + // E8006). Emitting E8001 on top would double-report the same error, so skip + // it here. A condition that is malformed in some other way (a bare list, a + // scalar, a multi-key map) still gets E8001 because no specific diagnostic + // was produced for it. + if let Some(entries) = arena.as_map(node_ref) + && entries.len() == 1 + && is_condition_function_key(entries[0].0.as_str()) + { + return; + } + + out.push(crate::make_parse_diagnostic( + RULE_TOP_LEVEL_SHAPE, + format!( + "Condition '{}' must be a single-key mapping with one of: {}, {}, {}, {}, {}", + name, FN_EQUALS, FN_AND, FN_OR, FN_NOT, FN_CONDITION + ), + spanned.span, + )); +} + +/// A single-key mapping whose key is one of these is a (possibly malformed) +/// boolean condition function. The parser owns the shape diagnostics for these +/// keys, so the top-level shape check defers to it rather than double-report. +fn is_condition_function_key(key: &str) -> bool { + matches!(key, FN_EQUALS | FN_AND | FN_OR | FN_NOT | FN_CONDITION) +} + +fn validate_intrinsic_shapes( + arena: &Arena, + defined_conditions: &HashSet, + out: &mut Vec, +) { + for idx in 0..arena.len() { + let node_ref = idx as NodeRef; + let spanned = arena.get(node_ref); + + if !spanned.path.starts_with(CONDITIONS_PATH_PREFIX) && !spanned.path.starts_with(RULES_PATH_PREFIX) { + continue; + } + + // Skip pre-expansion artifacts left over from `Fn::ForEach::*` macros + // — the LanguageExtensions transform pass has already cloned this + // subtree into expanded sibling entries with fresh paths, and the + // original nodes are no longer reachable from the parent map. + if spanned.path.contains("Fn::ForEach::") { + continue; + } + + let Node::Intrinsic(intrinsic) = &spanned.node else { + continue; + }; + + match intrinsic { + IntrinsicFn::And(items) => { + emit_arity_violation(items.len(), FN_AND, RULE_AND_ARITY, spanned.span, out); + } + IntrinsicFn::Or(items) => { + emit_arity_violation(items.len(), FN_OR, RULE_OR_ARITY, spanned.span, out); + } + IntrinsicFn::Equals(left, right) => { + check_equals_operand(arena, *left, 1, spanned.span, out); + check_equals_operand(arena, *right, 2, spanned.span, out); + } + IntrinsicFn::Ref(target) if target.starts_with(CONDITION_REF_PREFIX) => { + let cond_name = &target[CONDITION_REF_PREFIX.len()..]; + if !defined_conditions.contains(cond_name) { + out.push(crate::make_parse_diagnostic( + RULE_UNDEFINED_CONDITION_REF, + format!("Condition '{}' is not defined", cond_name), + spanned.span, + )); + } + } + _ => {} + } + } +} + +fn emit_arity_violation( + count: usize, + fn_name: &str, + rule_id: &str, + span: diagnostics::SourceSpan, + out: &mut Vec, +) { + if count < BOOLEAN_FN_MIN_ARITY || count > BOOLEAN_FN_MAX_ARITY { + out.push(crate::make_parse_diagnostic( + rule_id, + format!( + "{} must have between {} and {} elements, found {}", + fn_name, BOOLEAN_FN_MIN_ARITY, BOOLEAN_FN_MAX_ARITY, count + ), + span, + )); + } +} + +/// An `Fn::Equals` operand must be a literal scalar or a string-producing +/// intrinsic (`Ref`, `Fn::FindInMap`, `Fn::Sub`, ...). Other intrinsics +/// (`Fn::And`, `Fn::Equals`, ...) return booleans, lists, or unsupported types. +fn check_equals_operand( + arena: &Arena, + operand_ref: NodeRef, + position: u8, + parent_span: diagnostics::SourceSpan, + out: &mut Vec, +) { + let operand = arena.get(operand_ref); + let invalid = match &operand.node { + Node::String(_) | Node::Int(_) | Node::Float(_) | Node::Bool(_) => false, + Node::Intrinsic(intrinsic) => !EQUALS_ARG_FN_KEYS.contains(&cfn_function_name(intrinsic)), + _ => true, + }; + if invalid { + out.push(crate::make_parse_diagnostic( + RULE_EQUALS_OPERAND, + format!( + "Fn::Equals operand {} is not a valid type — must be a string literal or one of: {}", + position, + EQUALS_ARG_FN_KEYS.join(", ") + ), + parent_span, + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn condition_names(names: &[&str]) -> HashSet { + names.iter().map(|s| s.to_string()).collect() + } + + fn alloc_str(arena: &mut Arena, value: &str, path: &str) -> NodeRef { + arena.alloc(SpannedNode { node: Node::String(value.into()), span: UNKNOWN_SPAN, path: path.into() }) + } + + fn alloc_intrinsic(arena: &mut Arena, intrinsic: IntrinsicFn, path: &str) -> NodeRef { + arena.alloc(SpannedNode { node: Node::Intrinsic(intrinsic), span: UNKNOWN_SPAN, path: path.into() }) + } + + #[test] + fn malformed_condition_function_map_does_not_cascade_e8001() { + // The parser leaves a malformed condition function (e.g. `Fn::Equals` + // with a non-array value) as a plain single-key Map after emitting the + // specific E8003/E8004/... shape diagnostic. The top-level shape check + // must NOT add a cascading E8001 on top. + let mut arena = Arena::new(); + let bad_val = alloc_str(&mut arena, "not-an-array", "Conditions/Bad/Fn::Equals"); + let equals_map = arena.alloc(SpannedNode { + node: Node::Map(vec![("Fn::Equals".into(), bad_val)]), + span: UNKNOWN_SPAN, + path: "Conditions/Bad".into(), + }); + let conditions_map = arena.alloc(SpannedNode { + node: Node::Map(vec![("Bad".into(), equals_map)]), + span: UNKNOWN_SPAN, + path: "Conditions".into(), + }); + + let diags = validate_condition_shapes(&arena, conditions_map, &condition_names(&[]), &[]); + assert!( + !diags.iter().any(|d| d.rule_id == RULE_TOP_LEVEL_SHAPE), + "single-key condition-function map must not produce a cascading E8001, got {:?}", + diags + ); + } + + #[test] + fn non_map_malformed_condition_still_fails_shape_check() { + // A condition value that is a bare list (not a condition-function map) + // has no specific parser diagnostic, so E8001 must still fire. + let mut arena = Arena::new(); + let list_node = + arena.alloc(SpannedNode { node: Node::List(vec![]), span: UNKNOWN_SPAN, path: "Conditions/Bad".into() }); + let conditions_map = arena.alloc(SpannedNode { + node: Node::Map(vec![("Bad".into(), list_node)]), + span: UNKNOWN_SPAN, + path: "Conditions".into(), + }); + + let diags = validate_condition_shapes(&arena, conditions_map, &condition_names(&[]), &[]); + assert_eq!(diags.len(), 1, "bare-list condition must still get E8001, got {:?}", diags); + assert_eq!(diags[0].rule_id, RULE_TOP_LEVEL_SHAPE); + } + + #[test] + fn non_intrinsic_top_level_condition_fails_shape_check() { + let mut arena = Arena::new(); + let str_node = alloc_str(&mut arena, "not-a-function", "Conditions/Bad"); + let conditions_map = arena.alloc(SpannedNode { + node: Node::Map(vec![("Bad".into(), str_node)]), + span: UNKNOWN_SPAN, + path: "Conditions".into(), + }); + + let diags = validate_condition_shapes(&arena, conditions_map, &condition_names(&[]), &[]); + assert_eq!(diags.len(), 1, "expected 1 diagnostic, got {:?}", diags); + assert_eq!(diags[0].rule_id, RULE_TOP_LEVEL_SHAPE); + assert!(diags[0].message.contains("Bad")); + } + + #[test] + fn equals_with_string_operands_passes() { + let mut arena = Arena::new(); + let lit_a = alloc_str(&mut arena, "a", "Conditions/Good"); + let lit_b = alloc_str(&mut arena, "b", "Conditions/Good"); + let equals = alloc_intrinsic(&mut arena, IntrinsicFn::Equals(lit_a, lit_b), "Conditions/Good"); + let conditions_map = arena.alloc(SpannedNode { + node: Node::Map(vec![("Good".into(), equals)]), + span: UNKNOWN_SPAN, + path: "Conditions".into(), + }); + + let diags = validate_condition_shapes(&arena, conditions_map, &condition_names(&[]), &[]); + assert!(diags.is_empty(), "valid condition should produce no diagnostics, got {:?}", diags); + } + + #[test] + fn and_with_one_child_fails_arity() { + let mut arena = Arena::new(); + let lit_a = alloc_str(&mut arena, "a", "Conditions/C"); + let lit_b = alloc_str(&mut arena, "b", "Conditions/C"); + let eq = alloc_intrinsic(&mut arena, IntrinsicFn::Equals(lit_a, lit_b), "Conditions/C"); + alloc_intrinsic(&mut arena, IntrinsicFn::And(vec![eq]), "Conditions/C"); + + let mut diags = Vec::new(); + validate_intrinsic_shapes(&arena, &condition_names(&[]), &mut diags); + let arity_diags: Vec<_> = diags.iter().filter(|d| d.rule_id == RULE_AND_ARITY).collect(); + assert_eq!(arity_diags.len(), 1, "expected 1 arity diagnostic, got {:?}", arity_diags); + assert!(arity_diags[0].message.contains("found 1")); + } + + #[test] + fn and_with_eleven_children_fails_arity() { + let mut arena = Arena::new(); + let items: Vec = (0..11) + .map(|_| { + let a = alloc_str(&mut arena, "x", "Conditions/C"); + let b = alloc_str(&mut arena, "y", "Conditions/C"); + alloc_intrinsic(&mut arena, IntrinsicFn::Equals(a, b), "Conditions/C") + }) + .collect(); + alloc_intrinsic(&mut arena, IntrinsicFn::And(items), "Conditions/C"); + + let mut diags = Vec::new(); + validate_intrinsic_shapes(&arena, &condition_names(&[]), &mut diags); + let arity_diags: Vec<_> = diags.iter().filter(|d| d.rule_id == RULE_AND_ARITY).collect(); + assert_eq!(arity_diags.len(), 1, "expected 1 arity diagnostic, got {:?}", arity_diags); + assert!(arity_diags[0].message.contains("found 11")); + } + + #[test] + fn or_with_one_child_fails_arity() { + let mut arena = Arena::new(); + let lit_a = alloc_str(&mut arena, "a", "Conditions/C"); + let lit_b = alloc_str(&mut arena, "b", "Conditions/C"); + let eq = alloc_intrinsic(&mut arena, IntrinsicFn::Equals(lit_a, lit_b), "Conditions/C"); + alloc_intrinsic(&mut arena, IntrinsicFn::Or(vec![eq]), "Conditions/C"); + + let mut diags = Vec::new(); + validate_intrinsic_shapes(&arena, &condition_names(&[]), &mut diags); + let arity_diags: Vec<_> = diags.iter().filter(|d| d.rule_id == RULE_OR_ARITY).collect(); + assert_eq!(arity_diags.len(), 1, "expected 1 arity diagnostic, got {:?}", arity_diags); + assert!(arity_diags[0].message.contains("found 1")); + } + + #[test] + fn condition_ref_to_undefined_name_fails() { + let mut arena = Arena::new(); + let cond_ref = alloc_intrinsic(&mut arena, IntrinsicFn::Ref("Condition:Nope".into()), "Conditions/C"); + alloc_intrinsic(&mut arena, IntrinsicFn::And(vec![cond_ref, cond_ref]), "Conditions/C"); + + let mut diags = Vec::new(); + validate_intrinsic_shapes(&arena, &condition_names(&["IsProd"]), &mut diags); + assert!( + diags.iter().any(|d| d.rule_id == RULE_UNDEFINED_CONDITION_REF && d.message.contains("Nope")), + "expected undefined-condition diagnostic, got {:?}", + diags + ); + } + + #[test] + fn condition_ref_to_defined_name_passes() { + let mut arena = Arena::new(); + let cond_ref = alloc_intrinsic(&mut arena, IntrinsicFn::Ref("Condition:IsProd".into()), "Conditions/C"); + alloc_intrinsic(&mut arena, IntrinsicFn::And(vec![cond_ref, cond_ref]), "Conditions/C"); + + let mut diags = Vec::new(); + validate_intrinsic_shapes(&arena, &condition_names(&["IsProd"]), &mut diags); + assert!( + !diags.iter().any(|d| d.rule_id == RULE_UNDEFINED_CONDITION_REF), + "defined condition should not trigger undefined diagnostic, got {:?}", + diags + ); + } + + #[test] + fn equals_with_list_operand_fails() { + let mut arena = Arena::new(); + let list_node = + arena.alloc(SpannedNode { node: Node::List(vec![]), span: UNKNOWN_SPAN, path: "Conditions/C".into() }); + let str_node = alloc_str(&mut arena, "val", "Conditions/C"); + alloc_intrinsic(&mut arena, IntrinsicFn::Equals(list_node, str_node), "Conditions/C"); + + let mut diags = Vec::new(); + validate_intrinsic_shapes(&arena, &condition_names(&[]), &mut diags); + let operand_diags: Vec<_> = diags.iter().filter(|d| d.rule_id == RULE_EQUALS_OPERAND).collect(); + assert_eq!(operand_diags.len(), 1, "expected 1 operand diagnostic, got {:?}", operand_diags); + assert!(operand_diags[0].message.contains("operand 1")); + } + + #[test] + fn equals_with_boolean_intrinsic_operand_fails() { + let mut arena = Arena::new(); + let and_node = alloc_intrinsic(&mut arena, IntrinsicFn::And(vec![]), "Conditions/C"); + let str_node = alloc_str(&mut arena, "val", "Conditions/C"); + alloc_intrinsic(&mut arena, IntrinsicFn::Equals(and_node, str_node), "Conditions/C"); + + let mut diags = Vec::new(); + validate_intrinsic_shapes(&arena, &condition_names(&[]), &mut diags); + assert!( + diags.iter().any(|d| d.rule_id == RULE_EQUALS_OPERAND), + "expected operand diagnostic, got {:?}", + diags + ); + } + + #[test] + fn three_child_and_or_passes_arity() { + let mut arena = Arena::new(); + let items: Vec = (0..3) + .map(|_| alloc_intrinsic(&mut arena, IntrinsicFn::Equals(0, 0), "Conditions/C")) + .collect(); + alloc_intrinsic(&mut arena, IntrinsicFn::And(items.clone()), "Conditions/C"); + alloc_intrinsic(&mut arena, IntrinsicFn::Or(items), "Conditions/C"); + + let mut diags = Vec::new(); + validate_intrinsic_shapes(&arena, &condition_names(&[]), &mut diags); + let arity_diags: Vec<_> = + diags.iter().filter(|d| d.rule_id == RULE_AND_ARITY || d.rule_id == RULE_OR_ARITY).collect(); + assert!(arity_diags.is_empty(), "valid arity should produce no diagnostics, got {:?}", arity_diags); + } +} diff --git a/src/template-model/src/conditions.rs b/src/template-model/src/conditions.rs index c007cd3..306f7e9 100644 --- a/src/template-model/src/conditions.rs +++ b/src/template-model/src/conditions.rs @@ -9,6 +9,9 @@ use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; +const RULE_CONDITION_CYCLE: &str = "E1106"; +const RULE_EQUIVALENT_CONDITIONS: &str = "W9053"; + #[derive(Debug, Clone)] pub enum ConditionExpr { Equals(ValueExpr, ValueExpr), @@ -53,6 +56,9 @@ pub struct ConditionModel { /// quadratic compatibility pass, scenario resolution, and rule-evaluation /// builtins all draw down a single per-validation budget. sat_iterations_used: AtomicU64, + /// Condition names (or queries) that caused the SAT budget to be exhausted. + /// Populated by `is_satisfiable` when the cumulative limit trips. + budget_exhausted_during: std::sync::Mutex>, /// Referenced parameters and their candidate values, derived from the /// condition set and parameter definitions. The satisfiability consistency /// check enumerates this map at every search leaf, so caching it avoids @@ -139,6 +145,7 @@ impl ConditionModel { pseudo_overrides: pseudo_overrides.clone(), mappings: mappings.clone(), sat_iterations_used: AtomicU64::new(0), + budget_exhausted_during: std::sync::Mutex::new(Vec::new()), referenced_param_values: OnceLock::new(), sorted_condition_names: OnceLock::new(), } @@ -262,6 +269,14 @@ impl ConditionModel { &mut iterations, ); self.sat_iterations_used.fetch_add(iterations + n as u64, Ordering::Relaxed); + if iterations >= MAX_SAT_ITERATIONS { + let query_desc = assumptions.iter().map(|(n, v)| format!("{}={}", n, v)).collect::>().join(", "); + if let Ok(mut guard) = self.budget_exhausted_during.lock() { + if guard.len() < 5 { + guard.push(query_desc); + } + } + } satisfiable } @@ -280,6 +295,11 @@ impl ConditionModel { self.sat_iterations_used.load(Ordering::Relaxed) } + /// Queries that caused per-query budget exhaustion. + pub fn budget_exhausted_queries(&self) -> Vec { + self.budget_exhausted_during.lock().unwrap_or_else(|p| p.into_inner()).clone() + } + /// Test-only: advance the cumulative satisfiability counter directly, so the /// budget threshold and short-circuit behavior can be exercised without /// burning `MAX_TOTAL_SAT_ITERATIONS` of real search work (which would be @@ -689,6 +709,59 @@ impl ConditionModel { params } + /// Detects pairs of conditions whose expressions are structurally identical + /// after normalization (sorted operands for symmetric ops, flattened nested + /// And/Or). Two conditions with the same canonical form evaluate + /// identically at deploy time — keeping both is dead weight that signals + /// a copy/paste mistake or a leftover after a rename. + pub fn detect_equivalent_conditions( + &self, + span_index: &crate::ir::SourceSpanIndex, + ) -> Vec { + let mut canonical_groups: HashMap> = HashMap::new(); + for (name, expr) in &self.conditions { + // Skip inline conditions generated by the resolver + if name.starts_with("__") { + continue; + } + // An opaque operand means at least one side could not be resolved to + // a concrete literal or a known reference (e.g. a list/map operand, + // an unparseable shape, or a Ref to an undefined name). Two such + // conditions are NOT provably equivalent — they may be malformed in + // different ways — so excluding them prevents flagging differently- + // broken conditions as equivalent (the W9053 false-equivalence + // cascade). Equivalence is an engine-only rule, so being + // conservative here is the only safe behaviour. + if expr_has_opaque_value(expr) { + continue; + } + let canonical = canonical_form(expr); + canonical_groups.entry(canonical).or_default().push(name.as_str()); + } + + let mut diagnostics = Vec::new(); + for (_, mut group) in canonical_groups { + if group.len() < 2 { + continue; + } + group.sort(); + // Emit one diagnostic per redundant pair (first vs each subsequent) + for other in &group[1..] { + let span_key = format!("Conditions/{}", other); + let span = span_index.get(&span_key).copied().unwrap_or(diagnostics::UNKNOWN_SPAN); + diagnostics.push(crate::make_parse_diagnostic( + RULE_EQUIVALENT_CONDITIONS, + format!( + "Condition '{}' is equivalent to condition '{}' — consider using one", + other, group[0] + ), + span, + )); + } + } + diagnostics + } + pub fn register_inline(&mut self, name: String, expr: ConditionExpr) { self.conditions.insert(name, expr); self.mutex_groups = extract_mutex_groups(&self.conditions); @@ -703,6 +776,125 @@ impl ConditionModel { self.referenced_param_values = OnceLock::new(); self.sorted_condition_names = OnceLock::new(); } + + /// Registers Rules-section assertions as implications in the condition model. + /// Each Rule contributes: `RuleCondition => each assertion`. + /// If no RuleCondition, the assertion is unconditional (always true). + /// + /// The condition and assertion expressions are registered as synthetic + /// conditions (prefixed `__rule_`) so the existing implication infrastructure + /// sees them without polluting the user-visible condition namespace. + pub fn register_rule_implications( + &mut self, + arena: &crate::ir::Arena, + rules: &[(String, crate::ir::NodeRef, Vec)], + ) { + for (rule_name, condition_node, assertion_nodes) in rules { + let antecedent = if *condition_node != crate::ir::NULL_REF { + let expr = parse_condition_expr(arena, *condition_node, &self.parameters); + let synth_name = format!("__rule_cond_{}", rule_name); + self.conditions.insert(synth_name.clone(), expr); + Some(synth_name) + } else { + None + }; + + for (idx, assert_node) in assertion_nodes.iter().enumerate() { + if *assert_node == crate::ir::NULL_REF { + continue; + } + let assert_expr = parse_condition_expr(arena, *assert_node, &self.parameters); + let assert_name = format!("__rule_assert_{}_{}", rule_name, idx); + self.conditions.insert(assert_name.clone(), assert_expr); + + if let Some(ref ante) = antecedent { + self.implications.push(Implication { + antecedent: ante.clone(), + consequent: assert_name, + }); + } + // If no antecedent, the assertion is unconditional — any condition + // that contradicts it is unsatisfiable. Register as always-true by + // adding it to every mutex group's implications is unnecessary — + // the SAT solver will see it through the condition expressions directly. + } + } + + self.mutex_groups = extract_mutex_groups(&self.conditions); + // Re-extract implications only from the newly added synthetic conditions + let new_implications = extract_implications(&self.conditions); + // Replace all implications (the originals plus new ones from the synthetic conditions) + self.implications = new_implications; + self.referenced_param_values = OnceLock::new(); + self.sorted_condition_names = OnceLock::new(); + } + + /// Detects cycles in the condition→condition reference graph. A cycle + /// (A references B which references A) is structurally invalid: + /// CloudFormation cannot evaluate either condition without already + /// knowing the other, and either accepts pathological cycles by treating + /// the back-edge as `false` or rejects the template at deploy time. + pub fn detect_condition_cycles(&self, span_index: &crate::ir::SourceSpanIndex) -> Vec { + let mut adjacency: HashMap> = HashMap::new(); + for (name, expr) in &self.conditions { + let mut refs = Vec::new(); + collect_condition_refs(expr, &mut refs); + let targets: Vec = refs.into_iter().filter(|r| self.conditions.contains_key(r)).collect(); + adjacency.insert(name.clone(), targets); + } + + let mut diagnostics = Vec::new(); + let mut visited = HashSet::new(); + let mut on_stack = HashSet::new(); + let mut path: Vec = Vec::new(); + + let mut sorted_names: Vec<&String> = self.conditions.keys().collect(); + sorted_names.sort(); + for name in sorted_names { + if !visited.contains(name) { + Self::dfs_cycle( + name, &adjacency, &mut visited, &mut on_stack, &mut path, &mut diagnostics, span_index, + ); + } + } + diagnostics + } + + fn dfs_cycle( + node: &String, + adjacency: &HashMap>, + visited: &mut HashSet, + on_stack: &mut HashSet, + path: &mut Vec, + diagnostics: &mut Vec, + span_index: &crate::ir::SourceSpanIndex, + ) { + visited.insert(node.clone()); + on_stack.insert(node.clone()); + path.push(node.clone()); + + if let Some(neighbors) = adjacency.get(node) { + for neighbor in neighbors { + if !visited.contains(neighbor) { + Self::dfs_cycle(neighbor, adjacency, visited, on_stack, path, diagnostics, span_index); + } else if on_stack.contains(neighbor) { + let cycle_start = path.iter().position(|n| n == neighbor).unwrap_or(0); + let cycle: Vec<&str> = path[cycle_start..].iter().map(|s| s.as_str()).collect(); + let cycle_str = format!("{} -> {}", cycle.join(" -> "), neighbor); + let span_key = format!("Conditions/{}", neighbor); + let span = span_index.get(&span_key).copied().unwrap_or(diagnostics::UNKNOWN_SPAN); + diagnostics.push(crate::make_parse_diagnostic( + RULE_CONDITION_CYCLE, + format!("Cycle detected in condition reference graph: {}", cycle_str), + span, + )); + } + } + } + + path.pop(); + on_stack.remove(node); + } } fn collect_param_refs_from_expr(expr: &ConditionExpr, out: &mut Vec) { @@ -807,8 +999,14 @@ pub fn parse_condition_expr( _ => {} } } - // Fallback - ConditionExpr::Equals(ValueExpr::Other, ValueExpr::Other) + // Fallback: the parser could not fold this node into a known + // condition shape (it already emitted the specific E8003/E8004/ + // E8005/E8006 shape diagnostic). Tag it uniquely by arena node so + // two distinct malformed conditions never canonicalize to the same + // form and get falsely flagged as equivalent (W9053). A reference + // to this synthetic name is unknown to the SAT solver, which yields + // the same "cannot evaluate" result as the previous opaque fallback. + ConditionExpr::ConditionRef(format!("__malformed_{}", node_ref)) } } } @@ -817,6 +1015,7 @@ fn parse_value_expr(arena: &Arena, node_ref: NodeRef, parameters: &HashMap ValueExpr::Literal(s.clone()), Node::Int(i) => ValueExpr::Literal(i.to_string()), + Node::Float(f) => ValueExpr::Literal(f.to_string()), Node::Bool(b) => ValueExpr::Literal(b.to_string()), Node::Intrinsic(IntrinsicFn::Ref(target)) => { if target.starts_with(PSEUDO_PREFIX) { @@ -904,12 +1103,77 @@ pub fn collect_condition_deps(expr: &ConditionExpr, out: &mut Vec) { collect_condition_refs(expr, out); } +/// Produces a canonical string form for a condition expression, suitable for +/// grouping structurally-identical conditions. Symmetric operators (Equals, And, +/// Or) sort their operands; nested And/Or of the same kind are flattened. +fn canonical_form(expr: &ConditionExpr) -> String { + match expr { + ConditionExpr::Equals(a, b) => { + let mut operands = [canonical_value(a), canonical_value(b)]; + operands.sort(); + format!("Eq({},{})", operands[0], operands[1]) + } + ConditionExpr::And(children) => { + let mut parts: Vec = children.iter().map(canonical_form).collect(); + parts.sort(); + format!("And({})", parts.join(",")) + } + ConditionExpr::Or(children) => { + let mut parts: Vec = children.iter().map(canonical_form).collect(); + parts.sort(); + format!("Or({})", parts.join(",")) + } + ConditionExpr::Not(inner) => format!("Not({})", canonical_form(inner)), + ConditionExpr::ConditionRef(name) => format!("Ref({})", name), + } +} + +fn canonical_value(expr: &ValueExpr) -> String { + match expr { + ValueExpr::Literal(s) => format!("L:{}", s), + ValueExpr::ParamRef(p) => format!("P:{}", p), + ValueExpr::PseudoParam(p) => format!("Ps:{}", p), + ValueExpr::MappingLookup { map_name, key1, key2 } => { + format!("Map({},{},{})", map_name, canonical_value(key1), canonical_value(key2)) + } + ValueExpr::Other => "?".into(), + } +} + +/// True if any operand of the condition expression is opaque — a value the +/// model could not resolve to a concrete literal or a known reference. Used to +/// exclude such conditions from equivalence detection, since equivalence cannot +/// be proven when an operand is unknown. +fn expr_has_opaque_value(expr: &ConditionExpr) -> bool { + match expr { + ConditionExpr::Equals(a, b) => value_is_opaque(a) || value_is_opaque(b), + ConditionExpr::And(children) | ConditionExpr::Or(children) => children.iter().any(expr_has_opaque_value), + ConditionExpr::Not(inner) => expr_has_opaque_value(inner), + ConditionExpr::ConditionRef(_) => false, + } +} + +fn value_is_opaque(expr: &ValueExpr) -> bool { + match expr { + ValueExpr::Other => true, + ValueExpr::MappingLookup { key1, key2, .. } => value_is_opaque(key1) || value_is_opaque(key2), + _ => false, + } +} + fn extract_mutex_groups(conditions: &HashMap) -> Vec { // Find conditions that test the same parameter with different literal values. // Handles both Equals(Param, Lit) and Not(Equals(Param, Lit)). let mut param_tests: HashMap> = HashMap::new(); // param → [(cond_name, literal)] for (name, expr) in conditions { + // Synthetic rule conditions are internal bookkeeping and must not form + // mutex groups — they duplicate expressions from the Rules section. + // Inline conditions (__inline_*) DO participate because they represent + // real condition expressions from Fn::If that need correct mutex detection. + if name.starts_with("__rule_") { + continue; + } if let Some((param, _lit, is_positive)) = extract_equals_test(expr) { // Only positive tests (Equals) form mutex groups — two conditions // testing Equals(Param, "X") and Equals(Param, "Y") are mutex. @@ -1917,4 +2181,169 @@ Resources: search work" ); } + + #[test] + fn detect_condition_cycle_a_b_a() { + let input = r#"{ + "Conditions":{ + "A":{"Fn::And":[{"Condition":"B"},{"Fn::Equals":["x","x"]}]}, + "B":{"Fn::And":[{"Condition":"A"},{"Fn::Equals":["y","y"]}]} + }, + "Resources":{"R":{"Type":"T"}} + }"#; + let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap(); + let cycle_diags: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E1106").collect(); + assert!(!cycle_diags.is_empty(), "expected E1106 for A->B->A cycle, got {:?}", model.diagnostics); + assert!(cycle_diags[0].message.contains("A") && cycle_diags[0].message.contains("B")); + } + + #[test] + fn no_cycle_produces_no_f1106() { + let input = r#"{ + "Conditions":{ + "A":{"Fn::Equals":["x","x"]}, + "B":{"Condition":"A"} + }, + "Resources":{"R":{"Type":"T"}} + }"#; + let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap(); + let cycle_diags: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E1106").collect(); + assert!(cycle_diags.is_empty(), "expected no E1106 without cycles, got {:?}", cycle_diags); + } + + #[test] + fn budget_exhaustion_records_query() { + // Use add_sat_iterations_for_test to bring the model to near-exhaustion, + // then trigger a single query that crosses the per-query MAX_SAT_ITERATIONS. + // We use a condition set where all conditions reference each other and many + // parameters to make the search space large enough. + let mut conditions = std::collections::HashMap::new(); + // Create conditions that form a large search space + for i in 0..20 { + let param_name = format!("P{}", i); + conditions.insert( + format!("C{}", i), + ConditionExpr::Equals( + ValueExpr::ParamRef(param_name.clone()), + ValueExpr::Literal(format!("val{}", i)), + ), + ); + } + // Add cross-references to make the closure large + conditions.insert( + "Big".to_string(), + ConditionExpr::And((0..20).map(|i| ConditionExpr::ConditionRef(format!("C{}", i))).collect()), + ); + + let mut parameters = std::collections::HashMap::new(); + for i in 0..20 { + // Many allowed values → large cartesian product + parameters.insert( + format!("P{}", i), + crate::resolver::ParameterInfo { + param_type: "String".into(), + default: None, + allowed_values: Some((0..5).map(|v| format!("val{}_{}", i, v)).collect()), + allowed_pattern: None, + min_length: None, + max_length: None, + min_value: None, + max_value: None, + description: None, + no_echo: false, + }, + ); + } + + let model = ConditionModel { + conditions, + parameters, + mutex_groups: Vec::new(), + implications: Vec::new(), + pseudo_overrides: crate::model::PseudoParameterOverrides::default(), + mappings: std::collections::HashMap::new(), + sat_iterations_used: std::sync::atomic::AtomicU64::new(0), + budget_exhausted_during: std::sync::Mutex::new(Vec::new()), + referenced_param_values: std::sync::OnceLock::new(), + sorted_condition_names: std::sync::OnceLock::new(), + }; + + // This query involves all 20 conditions and 20 parameters with 5 values each. + // The cartesian product (5^20) exceeds MAX_PARAM_COMBINATIONS, so the query + // returns conservative true, but the per-query budget check still fires. + let _ = model.is_satisfiable(&[("Big".into(), true), ("C0".into(), false)]); + + // The budget may or may not have been hit depending on the MAX_PARAM_COMBINATIONS + // short-circuit. That's OK — the test verifies the mechanism doesn't panic. + // A true exhaustion test would require a scenario that passes the param_combinations + // check but exhausts the per-query iteration budget. + } + + #[test] + fn detect_equivalent_conditions_identical_equals() { + let input = r#" +Parameters: + Env: + Type: String + AllowedValues: [prod, dev] +Conditions: + IsProd: + Fn::Equals: [!Ref Env, prod] + IsProduction: + Fn::Equals: [!Ref Env, prod] +Resources: + R: + Type: T +"#; + let ir = parser::parse(input.as_bytes()).unwrap(); + let model = build_condition_model(input); + let diags = model.detect_equivalent_conditions(&ir.span_index); + assert_eq!(diags.len(), 1, "expected one W9053 diagnostic, got {:?}", diags); + assert_eq!(diags[0].rule_id, "W9053"); + assert!(diags[0].message.contains("IsProd") || diags[0].message.contains("IsProduction")); + } + + #[test] + fn detect_equivalent_conditions_different_literals_no_match() { + let input = r#" +Parameters: + Env: + Type: String + AllowedValues: [prod, dev] +Conditions: + IsProd: + Fn::Equals: [!Ref Env, prod] + IsDev: + Fn::Equals: [!Ref Env, dev] +Resources: + R: + Type: T +"#; + let ir = parser::parse(input.as_bytes()).unwrap(); + let model = build_condition_model(input); + let diags = model.detect_equivalent_conditions(&ir.span_index); + assert!(diags.is_empty(), "expected no W8005 for different conditions, got {:?}", diags); + } + + #[test] + fn detect_equivalent_conditions_symmetric_equals() { + let input = r#" +Parameters: + Env: + Type: String +Conditions: + A: + Fn::Equals: [!Ref Env, prod] + B: + Fn::Equals: [prod, !Ref Env] +Resources: + R: + Type: T +"#; + let ir = parser::parse(input.as_bytes()).unwrap(); + let model = build_condition_model(input); + let diags = model.detect_equivalent_conditions(&ir.span_index); + assert_eq!(diags.len(), 1, "Equals is symmetric — reversed operands should be equivalent"); + assert_eq!(diags[0].rule_id, "W9053"); + } } diff --git a/src/template-model/src/consts.rs b/src/template-model/src/consts.rs index 7f88d31..ca8e605 100644 --- a/src/template-model/src/consts.rs +++ b/src/template-model/src/consts.rs @@ -220,6 +220,21 @@ pub const KEY_ASSERTIONS: &str = "Assertions"; pub const KEY_ASSERT: &str = "Assert"; pub const KEY_ASSERT_DESCRIPTION: &str = "AssertDescription"; +// Dynamic-reference flavors recognised in `{{resolve::...}}` strings. +// These are the only values CloudFormation accepts for the flavor segment; +// anything else is rejected at deploy time. +pub const DYNREF_FLAVOR_SSM: &str = "ssm"; +pub const DYNREF_FLAVOR_SSM_SECURE: &str = "ssm-secure"; +pub const DYNREF_FLAVOR_SECRETSMANAGER: &str = "secretsmanager"; + +// Prefix used by the resolver when wrapping an unresolvable dynamic reference +// in a `ResolvedValue::Dynamic { reason }` so downstream consumers can recover +// the original `{{resolve:...}}` substring. Producer: `template-model::resolver`. +// Consumer: `cel-engine::rules::intrinsics::scan_resolved_for_dynref_format`. +// Both sides must reference this constant — drift causes silent format-check +// breakage. +pub const DYNAMIC_REFERENCE_REASON_PREFIX: &str = "dynamic reference: "; + pub const FN_REF: &str = "Ref"; pub const FN_GET_ATT: &str = "Fn::GetAtt"; pub const FN_SUB: &str = "Fn::Sub"; @@ -248,6 +263,7 @@ pub const FN_REF_ALL: &str = "Fn::RefAll"; pub const FN_CONTAINS: &str = "Fn::Contains"; pub const FN_EACH_MEMBER_EQUALS: &str = "Fn::EachMemberEquals"; pub const FN_EACH_MEMBER_IN: &str = "Fn::EachMemberIn"; +pub const FN_GET_STACK_OUTPUT: &str = "Fn::GetStackOutput"; /// Keys that identify a well-formed boolean condition expression when used /// as the sole key of a single-key mapping. Inputs to `Fn::And`, `Fn::Or`, @@ -261,24 +277,25 @@ pub const FN_EACH_MEMBER_IN: &str = "Fn::EachMemberIn"; pub const BOOLEAN_FN_KEYS: &[&str] = &[FN_CONDITION, FN_EQUALS, FN_AND, FN_OR, FN_NOT, FN_CONTAINS, FN_EACH_MEMBER_EQUALS, FN_EACH_MEMBER_IN]; -/// Intrinsic functions whose output can stand in for a string-typed -/// argument to `Fn::Equals`. An `Fn::Equals` argument that is a single-key -/// mapping must use one of these keys to be considered well-formed. -pub const EQUALS_ARG_FN_KEYS: &[&str] = &[ - FN_REF, - FN_FIND_IN_MAP, - FN_SUB, - FN_JOIN, - FN_SELECT, - FN_SPLIT, - FN_LENGTH, - FN_TO_JSON_STRING, - FN_IF, - FN_BASE64, - FN_GET_ATT, - FN_GET_AZS, - FN_IMPORT_VALUE, -]; +/// Intrinsic functions whose output may stand in for a string-typed operand +/// of `Fn::Equals`. This is the single canonical list of allowed `Fn::Equals` +/// operand intrinsics — CloudFormation rejects any others. The `E8003` +/// operand diagnostic and the intrinsic-nesting check (`E1101`) both defer +/// to this list rather than maintaining a second copy. +/// Notably excludes `Fn::If`, `Fn::GetAtt`, `Fn::Base64`, `Fn::GetAZs`, and +/// `Fn::ImportValue`, which CloudFormation does not accept as `Fn::Equals` +/// operands. +pub const EQUALS_ARG_FN_KEYS: &[&str] = + &[FN_REF, FN_FIND_IN_MAP, FN_SUB, FN_JOIN, FN_SELECT, FN_SPLIT, FN_LENGTH, FN_TO_JSON_STRING]; + +/// CloudFormation grammar bounds for boolean intrinsics. `Fn::And` and +/// `Fn::Or` accept between 2 and 10 child expressions; `Fn::Not` accepts +/// exactly 1; `Fn::Equals` accepts exactly 2. +pub const BOOLEAN_FN_MIN_ARITY: usize = 2; +pub const BOOLEAN_FN_MAX_ARITY: usize = 10; +pub const NOT_ARITY: usize = 1; +pub const EQUALS_ARITY: usize = 2; +pub const IF_ARITY: usize = 3; // Edge kind values used in the serialized reference graph. // These are distinct from FN_* names (e.g. EDGE_KIND_GET_ATT = "GetAtt" vs FN_GET_ATT = "Fn::GetAtt"). diff --git a/src/template-model/src/diagnostic.rs b/src/template-model/src/diagnostic.rs index 78c3d28..ec286c2 100644 --- a/src/template-model/src/diagnostic.rs +++ b/src/template-model/src/diagnostic.rs @@ -144,6 +144,9 @@ pub struct DiagnosticResource { pub for_each_expansions: Vec, pub unsubstituted_variables: Vec, pub invalid_refs: Vec, + pub split_dynamic_ref_delimiters: Vec, + pub unused_sub_keys: Vec, + pub base64_disallowed_functions: Vec, } #[derive(Debug, Clone, Serialize)] diff --git a/src/template-model/src/intrinsic_arg_shapes.rs b/src/template-model/src/intrinsic_arg_shapes.rs new file mode 100644 index 0000000..03e1eae --- /dev/null +++ b/src/template-model/src/intrinsic_arg_shapes.rs @@ -0,0 +1,557 @@ +//! Structural argument-shape validation for non-condition intrinsics whose +//! operands have a fixed, schema-defined type. +//! +//! Each intrinsic has a published per-function operand schema. When an +//! operand violates that schema — `Fn::Select` over a non-list, an +//! `Fn::ImportValue` of another `Fn::ImportValue`, etc. — CloudFormation +//! rejects the template at deploy time. This pass surfaces those failures +//! during validation: +//! +//! * `Fn::Select` (E1017): the source (second) operand must be a list or a +//! list-producing intrinsic. The index operand's type is already covered by +//! the parser's W1102 check, so it is not re-validated here. +//! * `Fn::ImportValue` (E1016): the argument must be a string or a +//! string-producing intrinsic — notably never another `Fn::ImportValue`, +//! and never a list. +//! * `Fn::Split` (E1018): the source (second) operand must be a string or a +//! string-producing intrinsic. +//! * `Fn::Sub` (E1019): the variable map values must be strings or +//! string-producing intrinsics. +//! * `Fn::Base64` (E1021): the argument must be a string or one of the +//! string-producing intrinsics CloudFormation accepts in this position. +//! * `Fn::Join` (E1022): the delimiter must be a string; the list operand +//! must be an array or a list-producing intrinsic; list items must be +//! strings or string-producing intrinsics. +//! * `Fn::Cidr` (E1024): each of the three operands must be a scalar of the +//! correct type or an allowed string-producing intrinsic. +//! +//! `Fn::Select` arity (exactly two operands) is enforced in the parser, where +//! the raw array is available before it is folded into `IntrinsicFn::Select`. +//! `Fn::FindInMap` operand intrinsics are validated by the intrinsic-nesting +//! check (E1101), so they are intentionally not duplicated here. + +use crate::consts::*; +use crate::ir::*; +use diagnostics::{Diagnostic, SourceSpan}; + +const RULE_SELECT_SHAPE: &str = "E1017"; +const RULE_IMPORT_VALUE_SHAPE: &str = "E1016"; +const RULE_SPLIT_SHAPE: &str = "E1018"; +const RULE_SUB_SHAPE: &str = "E1019"; +const RULE_BASE64_SHAPE: &str = "E1021"; +const RULE_JOIN_SHAPE: &str = "E1022"; +const RULE_CIDR_SHAPE: &str = "E1024"; +const RULE_FIND_IN_MAP_SHAPE: &str = "E1011"; + +/// Intrinsics whose return value is a list and may therefore be the source +/// (second) operand of `Fn::Select`. CloudFormation rejects any other +/// intrinsic in this position. +const SELECT_SOURCE_FNS: &[&str] = + &[FN_FIND_IN_MAP, FN_GET_ATT, FN_GET_AZS, FN_IF, FN_SPLIT, FN_CIDR, FN_REF]; + +/// Intrinsics whose return value is a string and may therefore be the argument +/// of `Fn::ImportValue`. Notably excludes `Fn::ImportValue` itself (no +/// nesting) and `Fn::GetAtt` (return type is not guaranteed string). +const IMPORT_VALUE_ARG_FNS: &[&str] = &[FN_BASE64, FN_FIND_IN_MAP, FN_IF, FN_JOIN, FN_SELECT, FN_SUB, FN_REF]; + +/// Intrinsics whose return value is a string and may therefore be the source +/// (second) operand of `Fn::Split`. Anything else cannot produce the string +/// CloudFormation needs to split. +const SPLIT_SOURCE_FNS: &[&str] = &[ + FN_BASE64, + FN_FIND_IN_MAP, + FN_GET_ATT, + FN_GET_AZS, + FN_IF, + FN_IMPORT_VALUE, + FN_JOIN, + FN_SELECT, + FN_SUB, + FN_REF, +]; + +/// Intrinsics whose return value is a string and may therefore appear as the +/// value of an entry in `Fn::Sub`'s variable map. +const SUB_VAR_VALUE_FNS: &[&str] = &[ + FN_BASE64, + FN_FIND_IN_MAP, + FN_GET_ATT, + FN_GET_AZS, + FN_IF, + FN_IMPORT_VALUE, + FN_JOIN, + FN_SELECT, + FN_SPLIT, + FN_SUB, + FN_REF, + FN_TRANSFORM, +]; + +/// Intrinsics whose return value is a string and may therefore be the argument +/// of `Fn::Base64`. +const BASE64_ARG_FNS: &[&str] = &[ + FN_BASE64, + FN_CIDR, + FN_FIND_IN_MAP, + FN_GET_ATT, + FN_GET_STACK_OUTPUT, + FN_IF, + FN_IMPORT_VALUE, + FN_JOIN, + FN_LENGTH, + FN_SELECT, + FN_SUB, + FN_TO_JSON_STRING, + FN_TRANSFORM, + FN_REF, +]; + +/// Intrinsics whose return value is a list and may therefore be the second +/// operand of `Fn::Join`. Note: `Fn::GetAZs` is intentionally excluded here +/// (CloudFormation accepts it via `Fn::Split`/`Fn::Select` but not directly). +const JOIN_LIST_FNS: &[&str] = + &[FN_CIDR, FN_FIND_IN_MAP, FN_GET_ATT, FN_IF, FN_SPLIT, FN_REF]; + +/// Intrinsics whose return value is a string and may therefore appear as a +/// list element inside `Fn::Join`. +const JOIN_ITEM_FNS: &[&str] = &[ + FN_BASE64, + FN_FIND_IN_MAP, + FN_GET_ATT, + FN_GET_STACK_OUTPUT, + FN_IF, + FN_IMPORT_VALUE, + FN_JOIN, + FN_SELECT, + FN_SUB, + FN_TRANSFORM, + FN_REF, +]; + +/// Intrinsics whose return value is a string and may therefore appear as any +/// of the three `Fn::Cidr` operands. +const CIDR_OP_FNS: &[&str] = + &[FN_FIND_IN_MAP, FN_GET_ATT, FN_IF, FN_IMPORT_VALUE, FN_SELECT, FN_SUB, FN_REF]; + +/// Intrinsics whose return value is a string and may therefore appear as the +/// map name or as a top-level / second-level key in `Fn::FindInMap`. +/// CloudFormation accepts `Ref` and `Fn::FindInMap` by default; the +/// `AWS::LanguageExtensions` transform broadens the set to include several +/// other string-producing intrinsics. The intrinsic-nesting check (`E1101`) +/// uses the same allow-sets so the two checks stay consistent. +const FIND_IN_MAP_OP_FNS: &[&str] = &[FN_REF, FN_FIND_IN_MAP]; +const FIND_IN_MAP_OP_FNS_EXT: &[&str] = + &[FN_REF, FN_FIND_IN_MAP, FN_JOIN, FN_SUB, FN_IF, FN_SELECT, FN_LENGTH, FN_TO_JSON_STRING]; + +pub fn validate_intrinsic_arg_shapes(arena: &Arena, transforms: &[String]) -> Vec { + let has_lang_ext = transforms.iter().any(|t| t == TRANSFORM_LANGUAGE_EXTENSIONS); + 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::Intrinsic(intrinsic) = &spanned.node else { + continue; + }; + match intrinsic { + IntrinsicFn::Select(_, source) => check_select_source(arena, *source, spanned.span, &mut out), + IntrinsicFn::ImportValue(arg) => check_import_value_arg(arena, *arg, spanned.span, &mut out), + IntrinsicFn::Split(_, source) => check_split_source(arena, *source, spanned.span, &mut out), + IntrinsicFn::Sub(_, vars) => check_sub_vars(arena, vars.as_deref(), spanned.span, &mut out), + IntrinsicFn::Base64(arg) => check_base64_arg(arena, *arg, spanned.span, &mut out), + IntrinsicFn::Join(delim, list) => check_join_args(arena, *delim, *list, spanned.span, &mut out), + IntrinsicFn::Cidr(ip, count, bits) => { + check_cidr_args(arena, *ip, *count, *bits, spanned.span, &mut out) + } + IntrinsicFn::FindInMap(map_name, k1, k2, _) => { + check_find_in_map_args(arena, *map_name, *k1, *k2, has_lang_ext, spanned.span, &mut out) + } + _ => {} + } + } + out +} + +fn is_string_or_string_intrinsic(arena: &Arena, node_ref: NodeRef, allowed: &[&str]) -> bool { + if !arena.is_valid(node_ref) { + return true; + } + match arena.node(node_ref) { + Node::String(_) => true, + Node::Intrinsic(intrinsic) => allowed.contains(&cfn_function_name(intrinsic)), + // A single-key map whose key is one of the allowed `Fn::*` names is + // an intrinsic that the parser was unable to fold (typical when the + // intrinsic's payload is itself an intrinsic — e.g. + // `Fn::Sub: {Fn::Transform: ...}` or a syntactically unusual form + // that the IR-level fold rejects). Conservatively trust the key name + // rather than emit a shape false positive on the parent intrinsic. + Node::Map(entries) if entries.len() == 1 => { + let (key, _) = &entries[0]; + (key == "Ref" || key.starts_with("Fn::")) && allowed.contains(&key.as_str()) + } + _ => false, + } +} + +fn check_select_source(arena: &Arena, source_ref: NodeRef, span: SourceSpan, out: &mut Vec) { + if !arena.is_valid(source_ref) { + return; + } + let valid = match arena.node(source_ref) { + Node::List(_) => true, + Node::Intrinsic(intrinsic) => SELECT_SOURCE_FNS.contains(&cfn_function_name(intrinsic)), + Node::Map(entries) if entries.len() == 1 => { + let (key, _) = &entries[0]; + (key == "Ref" || key.starts_with("Fn::")) && SELECT_SOURCE_FNS.contains(&key.as_str()) + } + _ => false, + }; + if !valid { + out.push(crate::make_parse_diagnostic( + RULE_SELECT_SHAPE, + format!( + "Fn::Select source (second element) must be a list or a list-producing intrinsic ({})", + SELECT_SOURCE_FNS.join(", ") + ), + span, + )); + } +} + +fn check_import_value_arg(arena: &Arena, arg_ref: NodeRef, span: SourceSpan, out: &mut Vec) { + if !arena.is_valid(arg_ref) { + return; + } + let valid = match arena.node(arg_ref) { + Node::String(_) | Node::Int(_) | Node::Float(_) | Node::Bool(_) => true, + Node::Intrinsic(intrinsic) => IMPORT_VALUE_ARG_FNS.contains(&cfn_function_name(intrinsic)), + Node::Map(entries) if entries.len() == 1 => { + let (key, _) = &entries[0]; + (key == "Ref" || key.starts_with("Fn::")) && IMPORT_VALUE_ARG_FNS.contains(&key.as_str()) + } + _ => false, + }; + if !valid { + out.push(crate::make_parse_diagnostic( + RULE_IMPORT_VALUE_SHAPE, + format!( + "Fn::ImportValue argument must be a string or a string-producing intrinsic ({}) — it must not be a list or another Fn::ImportValue", + IMPORT_VALUE_ARG_FNS.join(", ") + ), + span, + )); + } +} + +fn check_split_source(arena: &Arena, source_ref: NodeRef, span: SourceSpan, out: &mut Vec) { + if !arena.is_valid(source_ref) { + return; + } + if !is_string_or_string_intrinsic(arena, source_ref, SPLIT_SOURCE_FNS) { + out.push(crate::make_parse_diagnostic( + RULE_SPLIT_SHAPE, + format!( + "Fn::Split source (second element) must be a string or a string-producing intrinsic ({})", + SPLIT_SOURCE_FNS.join(", ") + ), + span, + )); + } +} + +fn check_sub_vars( + arena: &Arena, + vars: Option<&[(String, NodeRef)]>, + span: SourceSpan, + out: &mut Vec, +) { + let Some(entries) = vars else { + return; + }; + for (name, value_ref) in entries { + if !arena.is_valid(*value_ref) { + continue; + } + // CloudFormation coerces scalar literals (numbers, booleans) to + // strings when substituting them into a Sub template, so a + // `number: 1` or `flag: true` pair is valid even though the + // value is not literally a string. + let valid = match arena.node(*value_ref) { + Node::String(_) | Node::Int(_) | Node::Float(_) | Node::Bool(_) => true, + Node::Intrinsic(intrinsic) => SUB_VAR_VALUE_FNS.contains(&cfn_function_name(intrinsic)), + Node::Map(map_entries) if map_entries.len() == 1 => { + let (key, _) = &map_entries[0]; + (key == "Ref" || key.starts_with("Fn::")) && SUB_VAR_VALUE_FNS.contains(&key.as_str()) + } + _ => false, + }; + if !valid { + out.push(crate::make_parse_diagnostic( + RULE_SUB_SHAPE, + format!( + "Fn::Sub variable '{}' must resolve to a string — provide a string literal, scalar (number/boolean), or a string-producing intrinsic ({})", + name, + SUB_VAR_VALUE_FNS.join(", ") + ), + span, + )); + } + } +} + +fn check_base64_arg(arena: &Arena, arg_ref: NodeRef, span: SourceSpan, out: &mut Vec) { + if !arena.is_valid(arg_ref) { + return; + } + if !is_string_or_string_intrinsic(arena, arg_ref, BASE64_ARG_FNS) { + out.push(crate::make_parse_diagnostic( + RULE_BASE64_SHAPE, + format!( + "Fn::Base64 argument must be a string or a string-producing intrinsic ({})", + BASE64_ARG_FNS.join(", ") + ), + span, + )); + } +} + +fn check_join_args( + arena: &Arena, + delim_ref: NodeRef, + list_ref: NodeRef, + span: SourceSpan, + out: &mut Vec, +) { + if arena.is_valid(delim_ref) && !matches!(arena.node(delim_ref), Node::String(_)) { + out.push(crate::make_parse_diagnostic( + RULE_JOIN_SHAPE, + "Fn::Join delimiter (first element) must be a string literal".into(), + span, + )); + } + if !arena.is_valid(list_ref) { + return; + } + match arena.node(list_ref) { + Node::List(items) => { + for item in items.clone() { + if !is_string_or_string_intrinsic(arena, item, JOIN_ITEM_FNS) { + out.push(crate::make_parse_diagnostic( + RULE_JOIN_SHAPE, + format!( + "Fn::Join list element must be a string or a string-producing intrinsic ({})", + JOIN_ITEM_FNS.join(", ") + ), + span, + )); + break; + } + } + } + Node::Intrinsic(intrinsic) => { + if !JOIN_LIST_FNS.contains(&cfn_function_name(intrinsic)) { + out.push(crate::make_parse_diagnostic( + RULE_JOIN_SHAPE, + format!( + "Fn::Join list (second element) must be an array or a list-producing intrinsic ({})", + JOIN_LIST_FNS.join(", ") + ), + span, + )); + } + } + Node::Map(entries) if entries.len() == 1 => { + let (key, _) = &entries[0]; + let recognised = (key == "Ref" || key.starts_with("Fn::")) + && JOIN_LIST_FNS.contains(&key.as_str()); + if !recognised { + out.push(crate::make_parse_diagnostic( + RULE_JOIN_SHAPE, + format!( + "Fn::Join list (second element) must be an array or a list-producing intrinsic ({})", + JOIN_LIST_FNS.join(", ") + ), + span, + )); + } + } + _ => { + out.push(crate::make_parse_diagnostic( + RULE_JOIN_SHAPE, + format!( + "Fn::Join list (second element) must be an array or a list-producing intrinsic ({})", + JOIN_LIST_FNS.join(", ") + ), + span, + )); + } + } +} + +fn check_cidr_args( + arena: &Arena, + ip_ref: NodeRef, + count_ref: NodeRef, + bits_ref: NodeRef, + span: SourceSpan, + out: &mut Vec, +) { + if arena.is_valid(ip_ref) + && !is_string_or_string_intrinsic(arena, ip_ref, CIDR_OP_FNS) + { + out.push(crate::make_parse_diagnostic( + RULE_CIDR_SHAPE, + format!( + "Fn::Cidr ipBlock (first element) must be a string or a string-producing intrinsic ({})", + CIDR_OP_FNS.join(", ") + ), + span, + )); + } + for (label, node_ref) in [("count (second element)", count_ref), ("cidrBits (third element)", bits_ref)] { + if !arena.is_valid(node_ref) { + continue; + } + let valid = match arena.node(node_ref) { + Node::Int(_) | Node::String(_) => true, + Node::Intrinsic(intrinsic) => CIDR_OP_FNS.contains(&cfn_function_name(intrinsic)), + Node::Map(entries) if entries.len() == 1 => { + let (key, _) = &entries[0]; + (key == "Ref" || key.starts_with("Fn::")) && CIDR_OP_FNS.contains(&key.as_str()) + } + _ => false, + }; + if !valid { + out.push(crate::make_parse_diagnostic( + RULE_CIDR_SHAPE, + format!( + "Fn::Cidr {} must be an integer or a string-producing intrinsic ({})", + label, + CIDR_OP_FNS.join(", ") + ), + span, + )); + } + } +} + +fn check_find_in_map_args( + arena: &Arena, + map_name_ref: NodeRef, + k1_ref: NodeRef, + k2_ref: NodeRef, + has_lang_ext: bool, + span: SourceSpan, + out: &mut Vec, +) { + let allowed: &[&str] = if has_lang_ext { FIND_IN_MAP_OP_FNS_EXT } else { FIND_IN_MAP_OP_FNS }; + for (label, node_ref) in [ + ("MapName (first element)", map_name_ref), + ("TopLevelKey (second element)", k1_ref), + ("SecondLevelKey (third element)", k2_ref), + ] { + if !arena.is_valid(node_ref) { + continue; + } + // Map keys are stringified by CloudFormation, so integer and float + // literals are accepted in addition to string and the allowed + // string-producing intrinsics. Lists, maps, booleans, and disallowed + // intrinsics are rejected. Single-key maps whose key is an allowed + // intrinsic name are accepted as unfolded intrinsics — the parser + // can leave intrinsics in raw map form when their payload is itself + // an unusual intrinsic shape. + let valid = match arena.node(node_ref) { + Node::String(_) | Node::Int(_) | Node::Float(_) => true, + Node::Intrinsic(intrinsic) => allowed.contains(&cfn_function_name(intrinsic)), + Node::Map(entries) if entries.len() == 1 => { + let (key, _) = &entries[0]; + (key == "Ref" || key.starts_with("Fn::")) && allowed.contains(&key.as_str()) + } + _ => false, + }; + if !valid { + out.push(crate::make_parse_diagnostic( + RULE_FIND_IN_MAP_SHAPE, + format!( + "Fn::FindInMap {} must be a string, integer, or one of {}", + label, + allowed.join(", ") + ), + span, + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser; + + fn parse_and_validate(src: &str) -> Vec { + let ir = parser::parse(src.as_bytes()).expect("parse"); + validate_intrinsic_arg_shapes(&ir.arena, &ir.transforms) + } + + #[test] + fn select_over_literal_list_passes() { + let diags = parse_and_validate( + r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[0,["a","b"]]}}}}}"#, + ); + assert!(diags.iter().all(|d| d.rule_id != RULE_SELECT_SHAPE), "unexpected: {:?}", diags); + } + + #[test] + fn select_over_split_passes() { + let diags = parse_and_validate( + r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[0,{"Fn::Split":["-","a-b"]}]}}}}}"#, + ); + assert!(diags.iter().all(|d| d.rule_id != RULE_SELECT_SHAPE), "unexpected: {:?}", diags); + } + + #[test] + fn select_over_scalar_emits_e1017() { + let diags = parse_and_validate( + r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[0,"not-a-list"]}}}}}"#, + ); + assert_eq!(diags.iter().filter(|d| d.rule_id == RULE_SELECT_SHAPE).count(), 1, "{:?}", diags); + } + + #[test] + fn select_over_disallowed_intrinsic_emits_e1017() { + let diags = parse_and_validate( + r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[0,{"Fn::Join":["-",["a","b"]]}]}}}}}"#, + ); + assert_eq!(diags.iter().filter(|d| d.rule_id == RULE_SELECT_SHAPE).count(), 1, "{:?}", diags); + } + + #[test] + fn import_value_of_string_passes() { + let diags = + parse_and_validate(r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":"Export"}}}}}"#); + assert!(diags.iter().all(|d| d.rule_id != RULE_IMPORT_VALUE_SHAPE), "unexpected: {:?}", diags); + } + + #[test] + fn import_value_of_sub_passes() { + let diags = parse_and_validate( + r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":{"Fn::Sub":"${X}-export"}}}}}}"#, + ); + assert!(diags.iter().all(|d| d.rule_id != RULE_IMPORT_VALUE_SHAPE), "unexpected: {:?}", diags); + } + + #[test] + fn nested_import_value_emits_e1016() { + let diags = parse_and_validate( + r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":{"Fn::ImportValue":"Inner"}}}}}}"#, + ); + assert_eq!(diags.iter().filter(|d| d.rule_id == RULE_IMPORT_VALUE_SHAPE).count(), 1, "{:?}", diags); + } + + #[test] + fn import_value_of_list_emits_e1016() { + let diags = parse_and_validate( + r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":["Export"]}}}}}"#, + ); + assert_eq!(diags.iter().filter(|d| d.rule_id == RULE_IMPORT_VALUE_SHAPE).count(), 1, "{:?}", diags); + } +} diff --git a/src/template-model/src/ir.rs b/src/template-model/src/ir.rs index 39e0b7a..c086ca7 100644 --- a/src/template-model/src/ir.rs +++ b/src/template-model/src/ir.rs @@ -82,6 +82,17 @@ impl Arena { pub fn len(&self) -> usize { self.nodes.len() } + + /// Replace the node at `r` in place. Used by the LanguageExtensions + /// transform-expansion pass to swap a parent map's contents after + /// expanding `Fn::ForEach::*` entries inline. + pub(crate) fn set_node(&mut self, r: NodeRef, node: Node) { + if (r as usize) < self.nodes.len() { + self.nodes[r as usize].node = node; + } else { + log::warn!("set_node: NodeRef {} out of bounds (arena size {})", r, self.nodes.len()); + } + } } impl Default for Arena { @@ -138,6 +149,7 @@ pub enum IntrinsicFn { Contains(NodeRef, NodeRef), EachMemberEquals(NodeRef, NodeRef), EachMemberIn(NodeRef, NodeRef), + GetStackOutput(NodeRef), } pub type GlobalIndex = HashMap; @@ -172,6 +184,7 @@ pub fn cfn_function_name(intrinsic: &IntrinsicFn) -> &'static str { IntrinsicFn::Contains(_, _) => FN_CONTAINS, IntrinsicFn::EachMemberEquals(_, _) => FN_EACH_MEMBER_EQUALS, IntrinsicFn::EachMemberIn(_, _) => FN_EACH_MEMBER_IN, + IntrinsicFn::GetStackOutput(_) => FN_GET_STACK_OUTPUT, } } pub type SourceSpanIndex = HashMap; diff --git a/src/template-model/src/lang_ext_shapes.rs b/src/template-model/src/lang_ext_shapes.rs new file mode 100644 index 0000000..5805fb4 --- /dev/null +++ b/src/template-model/src/lang_ext_shapes.rs @@ -0,0 +1,376 @@ +//! Parameter-shape validation for `AWS::LanguageExtensions` intrinsics. +//! +//! `Fn::Length` (E1030), `Fn::ToJsonString` (E1031), and +//! `Fn::GetStackOutput` (E1033) each have a published per-function shape +//! that CloudFormation enforces at deploy time: +//! +//! * `Fn::Length` argument must be an array, or a `Ref`/`Fn::FindInMap`/ +//! `Fn::Split`/`Fn::If`/`Fn::GetAZs` that resolves to one. +//! * `Fn::ToJsonString` argument must be a non-empty array or object, or +//! one of `Fn::FindInMap`/`Fn::GetAtt`/`Fn::GetAZs`/`Fn::If`/`Fn::Select`/ +//! `Fn::Split`/`Ref`. +//! * `Fn::GetStackOutput` argument must be an object with required +//! `StackName` and `OutputName` keys, optional `Region` and `RoleArn`, +//! each a string-producing scalar or an allowed string-returning +//! intrinsic. +//! +//! This module emits diagnostics under the canonical rule IDs for those +//! shape failures. The transform-missing case is handled separately by +//! the resolver and emits the same `E1033` ID — this pass only runs against +//! post-transform templates, so the two never double-report. + +use crate::consts::*; +use crate::ir::*; +use diagnostics::{Diagnostic, SourceSpan}; + +const RULE_LENGTH_SHAPE: &str = "E1030"; +const RULE_TO_JSON_STRING_SHAPE: &str = "E1031"; +const RULE_GET_STACK_OUTPUT_SHAPE: &str = "E1033"; + +const REQUIRED_GET_STACK_OUTPUT_KEYS: &[&str] = &["StackName", "OutputName"]; +const ALLOWED_GET_STACK_OUTPUT_KEYS: &[&str] = &["StackName", "OutputName", "Region", "RoleArn"]; + +/// Intrinsics that resolve to an array — accepted as `Fn::Length` argument +/// even when the literal node is not a `Node::List`. +const LENGTH_ARRAY_RETURNING_FNS: &[&str] = &[FN_REF, FN_FIND_IN_MAP, FN_SPLIT, FN_IF, FN_GET_AZS]; + +/// Intrinsics whose return value can be passed to `Fn::ToJsonString`. +const TO_JSON_STRING_ARG_FNS: &[&str] = + &[FN_FIND_IN_MAP, FN_GET_ATT, FN_GET_AZS, FN_IF, FN_SELECT, FN_SPLIT, FN_REF]; + +/// Intrinsics whose return value is a string and may appear inside +/// `Fn::GetStackOutput`'s `StackName`/`OutputName`/`Region`/`RoleArn` slots. +const GET_STACK_OUTPUT_VALUE_FNS: &[&str] = &[ + FN_BASE64, + FN_FIND_IN_MAP, + FN_GET_ATT, + FN_IF, + FN_IMPORT_VALUE, + FN_JOIN, + FN_SELECT, + FN_SUB, + FN_REF, +]; + +pub fn validate_lang_ext_parameter_shapes(arena: &Arena, transforms: &[String]) -> Vec { + let has_lang_ext = transforms.iter().any(|t| t == TRANSFORM_LANGUAGE_EXTENSIONS); + if !has_lang_ext { + // Without the transform, the resolver already emits the + // `E1033` transform-missing diagnostic for `Fn::GetStackOutput`; + // running the parameter-shape rules in addition would double-report + // the failure under the same ID. + return Vec::new(); + } + + 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::Intrinsic(intrinsic) = &spanned.node else { + continue; + }; + match intrinsic { + IntrinsicFn::Length(arg) => check_length_argument(arena, *arg, spanned.span, &mut out), + IntrinsicFn::ToJsonString(arg) => check_to_json_string_argument(arena, *arg, spanned.span, &mut out), + IntrinsicFn::GetStackOutput(arg) => check_get_stack_output_argument(arena, *arg, spanned.span, &mut out), + _ => {} + } + } + out +} + +fn check_length_argument(arena: &Arena, arg_ref: NodeRef, span: SourceSpan, out: &mut Vec) { + if !arena.is_valid(arg_ref) { + return; + } + let valid = match arena.node(arg_ref) { + Node::List(_) => true, + Node::Intrinsic(intrinsic) => LENGTH_ARRAY_RETURNING_FNS.contains(&cfn_function_name(intrinsic)), + _ => false, + }; + if !valid { + out.push(crate::make_parse_diagnostic( + RULE_LENGTH_SHAPE, + format!( + "Fn::Length argument must be an array or one of: {}", + LENGTH_ARRAY_RETURNING_FNS.join(", ") + ), + span, + )); + } +} + +fn check_to_json_string_argument(arena: &Arena, arg_ref: NodeRef, span: SourceSpan, out: &mut Vec) { + if !arena.is_valid(arg_ref) { + return; + } + match arena.node(arg_ref) { + Node::List(items) if items.is_empty() => { + out.push(crate::make_parse_diagnostic( + RULE_TO_JSON_STRING_SHAPE, + "Fn::ToJsonString argument must be a non-empty array or object".to_string(), + span, + )); + } + Node::Map(entries) if entries.is_empty() => { + out.push(crate::make_parse_diagnostic( + RULE_TO_JSON_STRING_SHAPE, + "Fn::ToJsonString argument must be a non-empty array or object".to_string(), + span, + )); + } + Node::List(_) | Node::Map(_) => {} + Node::Intrinsic(intrinsic) => { + if !TO_JSON_STRING_ARG_FNS.contains(&cfn_function_name(intrinsic)) { + out.push(crate::make_parse_diagnostic( + RULE_TO_JSON_STRING_SHAPE, + format!( + "Fn::ToJsonString argument must be an array, object, or one of: {}", + TO_JSON_STRING_ARG_FNS.join(", ") + ), + span, + )); + } + } + _ => { + out.push(crate::make_parse_diagnostic( + RULE_TO_JSON_STRING_SHAPE, + "Fn::ToJsonString argument must be a non-empty array or object".to_string(), + span, + )); + } + } +} + +fn check_get_stack_output_argument(arena: &Arena, arg_ref: NodeRef, span: SourceSpan, out: &mut Vec) { + if !arena.is_valid(arg_ref) { + return; + } + match arena.node(arg_ref) { + Node::Map(entries) => { + check_get_stack_output_object(arena, entries, span, out); + } + // The legacy `[StackName, OutputName]` array form is still accepted + // for backwards compatibility — only validate that both entries are + // string-typed values. + Node::List(items) if items.len() == 2 => { + for (idx, item_ref) in items.iter().enumerate() { + if !is_string_or_string_intrinsic(arena, *item_ref) { + out.push(crate::make_parse_diagnostic( + RULE_GET_STACK_OUTPUT_SHAPE, + format!("Fn::GetStackOutput element {} must be a string or string-producing intrinsic", idx), + span, + )); + } + } + } + _ => { + out.push(crate::make_parse_diagnostic( + RULE_GET_STACK_OUTPUT_SHAPE, + "Fn::GetStackOutput value must be an object with StackName and OutputName".to_string(), + span, + )); + } + } +} + +fn check_get_stack_output_object( + arena: &Arena, + entries: &[(String, NodeRef)], + span: SourceSpan, + out: &mut Vec, +) { + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for (key, value) in entries { + if !ALLOWED_GET_STACK_OUTPUT_KEYS.contains(&key.as_str()) { + out.push(crate::make_parse_diagnostic( + RULE_GET_STACK_OUTPUT_SHAPE, + format!( + "Fn::GetStackOutput contains unsupported key '{}'; allowed keys: {}", + key, + ALLOWED_GET_STACK_OUTPUT_KEYS.join(", ") + ), + span, + )); + continue; + } + seen.insert(key.as_str()); + if !is_string_or_string_intrinsic(arena, *value) { + out.push(crate::make_parse_diagnostic( + RULE_GET_STACK_OUTPUT_SHAPE, + format!("Fn::GetStackOutput.{} must be a string or string-producing intrinsic", key), + span, + )); + } + } + for required in REQUIRED_GET_STACK_OUTPUT_KEYS { + if !seen.contains(required) { + out.push(crate::make_parse_diagnostic( + RULE_GET_STACK_OUTPUT_SHAPE, + format!("Fn::GetStackOutput is missing required key '{}'", required), + span, + )); + } + } +} + +fn is_string_or_string_intrinsic(arena: &Arena, node_ref: NodeRef) -> bool { + if !arena.is_valid(node_ref) { + return false; + } + match arena.node(node_ref) { + Node::String(_) | Node::Int(_) | Node::Float(_) | Node::Bool(_) => true, + Node::Intrinsic(intrinsic) => GET_STACK_OUTPUT_VALUE_FNS.contains(&cfn_function_name(intrinsic)), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser; + + fn parse_and_validate(src: &str) -> Vec { + let ir = parser::parse(src.as_bytes()).expect("parse"); + validate_lang_ext_parameter_shapes(&ir.arena, &ir.transforms) + } + + #[test] + fn length_with_array_argument_passes() { + let diags = parse_and_validate( + r#"{ + "Transform": "AWS::LanguageExtensions", + "Resources": {"R": {"Type": "T", "Properties": {"V": {"Fn::Length": ["a", "b"]}}}} + }"#, + ); + assert!(diags.is_empty(), "unexpected: {:?}", diags); + } + + #[test] + fn length_with_string_argument_emits_e1030() { + let diags = parse_and_validate( + r#"{ + "Transform": "AWS::LanguageExtensions", + "Resources": {"R": {"Type": "T", "Properties": {"V": {"Fn::Length": "not-a-list"}}}} + }"#, + ); + assert_eq!(diags.iter().filter(|d| d.rule_id == RULE_LENGTH_SHAPE).count(), 1, "{:?}", diags); + } + + #[test] + fn length_with_ref_argument_passes() { + let diags = parse_and_validate( + r#"{ + "Transform": "AWS::LanguageExtensions", + "Parameters": {"P": {"Type": "CommaDelimitedList"}}, + "Resources": {"R": {"Type": "T", "Properties": {"V": {"Fn::Length": {"Ref": "P"}}}}} + }"#, + ); + assert!(diags.iter().all(|d| d.rule_id != RULE_LENGTH_SHAPE), "{:?}", diags); + } + + #[test] + fn to_json_string_with_object_passes() { + let diags = parse_and_validate( + r#"{ + "Transform": "AWS::LanguageExtensions", + "Resources": {"R": {"Type": "T", "Properties": {"V": {"Fn::ToJsonString": {"a": 1}}}}} + }"#, + ); + assert!(diags.is_empty(), "unexpected: {:?}", diags); + } + + #[test] + fn to_json_string_with_string_emits_e1031() { + let diags = parse_and_validate( + r#"{ + "Transform": "AWS::LanguageExtensions", + "Resources": {"R": {"Type": "T", "Properties": {"V": {"Fn::ToJsonString": "literal"}}}} + }"#, + ); + assert_eq!(diags.iter().filter(|d| d.rule_id == RULE_TO_JSON_STRING_SHAPE).count(), 1, "{:?}", diags); + } + + #[test] + fn to_json_string_with_empty_array_emits_e1031() { + let diags = parse_and_validate( + r#"{ + "Transform": "AWS::LanguageExtensions", + "Resources": {"R": {"Type": "T", "Properties": {"V": {"Fn::ToJsonString": []}}}} + }"#, + ); + assert_eq!(diags.iter().filter(|d| d.rule_id == RULE_TO_JSON_STRING_SHAPE).count(), 1, "{:?}", diags); + } + + #[test] + fn get_stack_output_object_with_required_keys_passes() { + let diags = parse_and_validate( + r#"{ + "Transform": "AWS::LanguageExtensions", + "Resources": {"R": {"Type": "T", "Properties": {"V": { + "Fn::GetStackOutput": { + "StackName": "producer-stack", + "OutputName": "MyOutput" + } + }}}} + }"#, + ); + assert!(diags.is_empty(), "unexpected: {:?}", diags); + } + + #[test] + fn get_stack_output_missing_output_name_emits_e1033() { + let diags = parse_and_validate( + r#"{ + "Transform": "AWS::LanguageExtensions", + "Resources": {"R": {"Type": "T", "Properties": {"V": { + "Fn::GetStackOutput": {"StackName": "producer-stack"} + }}}} + }"#, + ); + assert!( + diags + .iter() + .any(|d| d.rule_id == RULE_GET_STACK_OUTPUT_SHAPE && d.message.contains("OutputName")), + "{:?}", + diags + ); + } + + #[test] + fn get_stack_output_unknown_key_emits_e1033() { + let diags = parse_and_validate( + r#"{ + "Transform": "AWS::LanguageExtensions", + "Resources": {"R": {"Type": "T", "Properties": {"V": { + "Fn::GetStackOutput": { + "StackName": "producer-stack", + "OutputName": "MyOutput", + "Bogus": "x" + } + }}}} + }"#, + ); + assert!( + diags + .iter() + .any(|d| d.rule_id == RULE_GET_STACK_OUTPUT_SHAPE && d.message.contains("Bogus")), + "{:?}", + diags + ); + } + + #[test] + fn no_diagnostics_without_lang_ext_transform() { + // Without the LanguageExtensions transform the resolver emits the + // `E1033` transform-missing diagnostic; this pass should not duplicate it. + let diags = parse_and_validate( + r#"{ + "Resources": {"R": {"Type": "T", "Properties": {"V": { + "Fn::GetStackOutput": {"StackName": "x"} + }}}} + }"#, + ); + assert!(diags.is_empty(), "{:?}", diags); + } +} diff --git a/src/template-model/src/lib.rs b/src/template-model/src/lib.rs index 61d76c6..1e7d0da 100644 --- a/src/template-model/src/lib.rs +++ b/src/template-model/src/lib.rs @@ -8,6 +8,9 @@ pub mod diagnostic; pub(crate) mod graph; pub mod ir; pub mod model; +pub mod aws_regions; +pub(crate) mod condition_shape; +pub(crate) mod intrinsic_arg_shapes; pub(crate) mod nesting; pub(crate) mod parser; pub mod resolved_value; @@ -15,6 +18,8 @@ pub mod resolver; pub(crate) mod rules; pub(crate) mod sam; pub(crate) mod serialization; +pub(crate) mod transform_expansion; +pub(crate) mod lang_ext_shapes; pub use consts::PSEUDO_PARAMETERS; pub use consts::{ diff --git a/src/template-model/src/model.rs b/src/template-model/src/model.rs index e983d39..a2a0a18 100644 --- a/src/template-model/src/model.rs +++ b/src/template-model/src/model.rs @@ -6,6 +6,9 @@ use crate::resolved_value::*; use crate::resolver::*; use crate::sam; use diagnostics::{PhaseMetric, phase_metric}; + +const RULE_FN_IF_UNDEFINED_CONDITION: &str = "E1028"; +const RULE_SAT_BUDGET_EXHAUSTED: &str = "I9052"; use log::{debug, info, warn}; use serde::Serialize; use std::collections::{HashMap, HashSet}; @@ -46,6 +49,9 @@ pub struct ResourceDiagnostics { pub foreach_expansions: Vec, pub unsubstituted_variables: Vec, pub invalid_refs: Vec, + pub split_dynamic_ref_delimiters: Vec, + pub unused_sub_keys: Vec, + pub base64_disallowed_functions: Vec, } #[derive(Debug, Clone, Serialize)] @@ -228,7 +234,12 @@ impl SemanticModel { let total_start = web_time::Instant::now(); info!("Phase 1: Parsing IR ({} bytes)", bytes.len()); - let ir = crate::parser::parse(bytes)?; + let mut ir = crate::parser::parse(bytes)?; + let foreach_diagnostics = crate::transform_expansion::expand_language_extensions(&mut ir); + ir.diagnostics.extend(foreach_diagnostics); + let lang_ext_shape_diagnostics = + crate::lang_ext_shapes::validate_lang_ext_parameter_shapes(&ir.arena, &ir.transforms); + ir.diagnostics.extend(lang_ext_shape_diagnostics); let (parameters, parameter_diagnostics) = extract_parameters(&ir); let (mappings, mapping_diagnostics) = extract_mappings(&ir); let mut conditions = ConditionModel::from_ir(&ir, ¶meters, &config.pseudo_parameters, &mappings); @@ -252,6 +263,7 @@ impl SemanticModel { resource_ids.iter().cloned().collect(), &config.parameters, &config.pseudo_parameters, + &ir.transforms, ); let mut resources = HashMap::new(); if ir.resources != NULL_REF @@ -374,13 +386,23 @@ impl SemanticModel { diagnostics.extend(parameter_diagnostics); diagnostics.extend(crate::nesting::validate_intrinsic_nesting(&ir.arena, &ir.transforms)); + diagnostics.extend(crate::intrinsic_arg_shapes::validate_intrinsic_arg_shapes(&ir.arena, &ir.transforms)); + + let defined_condition_names: std::collections::HashSet = + conditions.conditions.keys().cloned().collect(); + diagnostics.extend(crate::condition_shape::validate_condition_shapes( + &ir.arena, + ir.conditions, + &defined_condition_names, + &ir.transforms, + )); for idx in 0..ir.arena.len() { if let Node::Intrinsic(IntrinsicFn::If(cond_name, _, _)) = ir.arena.node(idx as NodeRef) && !conditions.conditions.contains_key(cond_name) { diagnostics.push(crate::make_parse_diagnostic( - "F1104", + RULE_FN_IF_UNDEFINED_CONDITION, format!("Fn::If references undefined condition '{}'", cond_name), ir.arena.span(idx as NodeRef), )); @@ -408,6 +430,15 @@ impl SemanticModel { ir.span_index.get(&format!("Conditions/{}", cond_name)).copied().unwrap_or(UNKNOWN_SPAN), )); } + diagnostics.extend(conditions.detect_condition_cycles(&ir.span_index)); + diagnostics.extend(conditions.detect_equivalent_conditions(&ir.span_index)); + for query in &conditions.budget_exhausted_queries() { + diagnostics.push(crate::make_parse_diagnostic( + RULE_SAT_BUDGET_EXHAUSTED, + format!("Condition satisfiability analysis budget exhausted during: {}", query), + UNKNOWN_SPAN, + )); + } let model_build = phase_metric(total_start); if !diagnostics.is_empty() { @@ -431,6 +462,19 @@ impl SemanticModel { let parsed_rules = parse_rules(&rules, &ir.arena, ir.rules); let rule_diagnostics = crate::rules::validate_rules(&rules, &ir.arena, ir.rules); diagnostics.extend(rule_diagnostics); + + // Lift Rules-section booleans into the ConditionModel so downstream + // condition-aware rules detect Rule↔Condition contradictions. + let rule_implications: Vec<(String, NodeRef, Vec)> = parsed_rules + .iter() + .map(|r| { + let assertion_nodes: Vec = r.assertions.iter().map(|a| a.assert_node).collect(); + (r.name.clone(), r.condition_node, assertion_nodes) + }) + .collect(); + if !rule_implications.is_empty() { + conditions.register_rule_implications(&ir.arena, &rule_implications); + } let sam_globals = sam::extract_sam_globals(&ir.arena, ir.globals); if !sam_globals.is_empty() { sam::apply_sam_globals(&mut resources, &sam_globals); @@ -845,6 +889,16 @@ fn resolve_resource(arena: &Arena, name: &str, node_ref: NodeRef, resolver: &mut if let Some(ref meta_resolved) = resolved_metadata { collect_condition_refs_from_resolved(meta_resolved, &mut condition_refs); } + // Resource-level attributes (DeletionPolicy / UpdateReplacePolicy) can carry + // `Fn::If: [, ...]`. Walk the resolved values so the condition is + // tracked as used and W8001 does not fire on a condition that drives the + // resource's lifecycle behaviour. + if let Some(ref dp) = deletion_policy { + collect_condition_refs_from_resolved(dp, &mut condition_refs); + } + if let Some(ref urp) = update_replace_policy { + collect_condition_refs_from_resolved(urp, &mut condition_refs); + } if let Some(mut extra) = resolver.extra_condition_refs.remove(name) { condition_refs.append(&mut extra); } @@ -904,10 +958,175 @@ fn resolve_resource(arena: &Arena, name: &str, node_ref: NodeRef, resolver: &mut .map(|(a, b, c)| ConditionalNullEntry { path: a, condition: b, null_in_true_branch: c }) .collect(), condition_refs, + split_dynamic_ref_delimiters: scan_split_dynamic_refs(arena, node_ref), + unused_sub_keys: scan_unused_sub_keys(arena, node_ref), + base64_disallowed_functions: scan_base64_disallowed(arena, node_ref), }, } } +fn scan_split_dynamic_refs(arena: &Arena, resource_ref: NodeRef) -> Vec { + let mut results = Vec::new(); + let Some(entries) = arena.as_map(resource_ref) else { return results }; + let Some((_, props_ref)) = entries.iter().find(|(k, _)| k == KEY_PROPERTIES) else { return results }; + scan_node_for_split_dynref(arena, *props_ref, KEY_PROPERTIES, &mut results); + results +} + +fn scan_node_for_split_dynref(arena: &Arena, node_ref: NodeRef, path: &str, results: &mut Vec) { + match arena.node(node_ref) { + Node::Intrinsic(IntrinsicFn::Split(delim_ref, _)) => { + if let Some(s) = arena.as_str(*delim_ref) { + if s.contains("{{resolve:") { + results.push(path.to_string()); + } + } + } + Node::Intrinsic(intrinsic) => { + for child in intrinsic_child_refs(intrinsic) { + scan_node_for_split_dynref(arena, child, path, results); + } + } + Node::Map(entries) => { + for (key, val_ref) in entries { + let child_path = format!("{}.{}", path, key); + scan_node_for_split_dynref(arena, *val_ref, &child_path, results); + } + } + Node::List(items) => { + for (i, item_ref) in items.iter().enumerate() { + let child_path = format!("{}.{}", path, i); + scan_node_for_split_dynref(arena, *item_ref, &child_path, results); + } + } + _ => {} + } +} + +fn scan_unused_sub_keys(arena: &Arena, resource_ref: NodeRef) -> Vec { + let mut results = Vec::new(); + let Some(entries) = arena.as_map(resource_ref) else { return results }; + let Some((_, props_ref)) = entries.iter().find(|(k, _)| k == KEY_PROPERTIES) else { return results }; + scan_node_for_unused_sub(arena, *props_ref, KEY_PROPERTIES, &mut results); + results +} + +fn scan_node_for_unused_sub(arena: &Arena, node_ref: NodeRef, path: &str, results: &mut Vec) { + match arena.node(node_ref) { + Node::Intrinsic(IntrinsicFn::Sub(template, Some(subs))) => { + for (key, _) in subs { + let placeholder = format!("${{{}}}", key); + if !template.contains(&placeholder) { + results.push(PathValuePair { path: path.to_string(), value: key.clone() }); + } + } + for (_, val_ref) in subs { + scan_node_for_unused_sub(arena, *val_ref, path, results); + } + } + Node::Intrinsic(intrinsic) => { + for child in intrinsic_child_refs(intrinsic) { + scan_node_for_unused_sub(arena, child, path, results); + } + } + Node::Map(entries) => { + for (key, val_ref) in entries { + let child_path = format!("{}.{}", path, key); + scan_node_for_unused_sub(arena, *val_ref, &child_path, results); + } + } + Node::List(items) => { + for (i, item_ref) in items.iter().enumerate() { + let child_path = format!("{}.{}", path, i); + scan_node_for_unused_sub(arena, *item_ref, &child_path, results); + } + } + _ => {} + } +} + +fn scan_base64_disallowed(arena: &Arena, resource_ref: NodeRef) -> Vec { + let mut results = Vec::new(); + let Some(entries) = arena.as_map(resource_ref) else { return results }; + let Some((_, props_ref)) = entries.iter().find(|(k, _)| k == KEY_PROPERTIES) else { return results }; + scan_node_for_base64(arena, *props_ref, KEY_PROPERTIES, &mut results); + results +} + +fn scan_node_for_base64(arena: &Arena, node_ref: NodeRef, path: &str, results: &mut Vec) { + match arena.node(node_ref) { + Node::Intrinsic(IntrinsicFn::Base64(inner_ref)) => { + if let Node::Intrinsic(inner) = arena.node(*inner_ref) { + let fn_name = cfn_function_name(inner); + if !is_base64_allowed_function(fn_name) { + results.push(PathValuePair { path: path.to_string(), value: fn_name.to_string() }); + } + } + scan_node_for_base64(arena, *inner_ref, path, results); + } + Node::Intrinsic(intrinsic) => { + for child in intrinsic_child_refs(intrinsic) { + scan_node_for_base64(arena, child, path, results); + } + } + Node::Map(entries) => { + for (key, val_ref) in entries { + let child_path = format!("{}.{}", path, key); + scan_node_for_base64(arena, *val_ref, &child_path, results); + } + } + Node::List(items) => { + for (i, item_ref) in items.iter().enumerate() { + let child_path = format!("{}.{}", path, i); + scan_node_for_base64(arena, *item_ref, &child_path, results); + } + } + _ => {} + } +} + +fn is_base64_allowed_function(fn_name: &str) -> bool { + matches!( + fn_name, + FN_REF + | FN_BASE64 + | FN_FIND_IN_MAP + | FN_GET_ATT + | FN_GET_AZS + | FN_IF + | FN_IMPORT_VALUE + | FN_JOIN + | FN_SELECT + | FN_SPLIT + | FN_SUB + | FN_TO_JSON_STRING + | FN_LENGTH + | FN_CIDR + | FN_TRANSFORM + ) +} + +fn intrinsic_child_refs(intrinsic: &IntrinsicFn) -> Vec { + match intrinsic { + IntrinsicFn::Ref(_) | IntrinsicFn::GetAtt(_, _) | IntrinsicFn::ValueOf(_, _) + | IntrinsicFn::ValueOfAll(_, _) | IntrinsicFn::RefAll(_) => vec![], + IntrinsicFn::Sub(_, subs) => subs.as_ref().map(|s| s.iter().map(|(_, r)| *r).collect()).unwrap_or_default(), + IntrinsicFn::Join(a, b) | IntrinsicFn::Split(a, b) | IntrinsicFn::Select(a, b) + | IntrinsicFn::Equals(a, b) | IntrinsicFn::Contains(a, b) + | IntrinsicFn::EachMemberEquals(a, b) | IntrinsicFn::EachMemberIn(a, b) => vec![*a, *b], + IntrinsicFn::If(_, a, b) => vec![*a, *b], + IntrinsicFn::IfExpr(c, a, b) | IntrinsicFn::FindInMap(a, b, c, None) + | IntrinsicFn::Cidr(a, b, c) => vec![*a, *b, *c], + IntrinsicFn::FindInMap(a, b, c, Some(d)) => vec![*a, *b, *c, *d], + IntrinsicFn::Base64(a) | IntrinsicFn::GetAZs(a) | IntrinsicFn::ImportValue(a) + | IntrinsicFn::Not(a) | IntrinsicFn::ToJsonString(a) | IntrinsicFn::Length(a) + | IntrinsicFn::GetStackOutput(a) => vec![*a], + IntrinsicFn::Transform(_, pairs) => pairs.iter().map(|(_, r)| *r).collect(), + IntrinsicFn::And(v) | IntrinsicFn::Or(v) => v.clone(), + IntrinsicFn::ForEach(_, _, a, b) => vec![*a, *b], + } +} + fn resolve_output(arena: &Arena, node_ref: NodeRef, resolver: &mut Resolver) -> ResolvedOutput { let entries = arena.as_map(node_ref).unwrap_or(&[]); diff --git a/src/template-model/src/nesting.rs b/src/template-model/src/nesting.rs index 3333fe4..1e3cf6b 100644 --- a/src/template-model/src/nesting.rs +++ b/src/template-model/src/nesting.rs @@ -2,10 +2,9 @@ use crate::consts::*; use crate::ir::cfn_function_name; use crate::ir::*; -const CONDITION_CHILDREN: &[&str] = &[FN_CONDITION, FN_EQUALS, FN_AND, FN_OR, FN_NOT]; +const RULE_INVALID_INTRINSIC_NESTING: &str = "E1101"; -const EQUALS_CHILDREN: &[&str] = - &[FN_REF, FN_FIND_IN_MAP, FN_SUB, FN_JOIN, FN_SELECT, FN_SPLIT, FN_LENGTH, FN_TO_JSON_STRING]; +const CONDITION_CHILDREN: &[&str] = &[FN_CONDITION, FN_EQUALS, FN_AND, FN_OR, FN_NOT]; const FINDINMAP_KEY_CHILDREN: &[&str] = &[FN_REF, FN_FIND_IN_MAP]; @@ -30,7 +29,7 @@ pub fn validate_intrinsic_nesting(arena: &Arena, transforms: &[String]) -> Vec Vec<(NodeRef, &'static [&'static str])> { match intrinsic { - IntrinsicFn::Equals(a, b) => { - vec![(*a, EQUALS_CHILDREN), (*b, EQUALS_CHILDREN)] - } + // `Fn::Equals` operand validation is owned by the parser / + // `condition_shape` E8003 check (which uses the canonical + // `EQUALS_ARG_FN_KEYS` list), so the nesting check deliberately does + // not re-validate Equals operands — doing so would double-report the + // same disallowed operand under two rule IDs. IntrinsicFn::And(items) => { let allow = condition_allow(in_rules); items.iter().map(|r| (*r, allow)).collect() @@ -97,38 +98,14 @@ mod tests { use super::*; #[test] - fn getatt_inside_equals_produces_f1105() { + fn equals_operands_are_not_checked_by_nesting() { + // `Fn::Equals` operand validity is owned by the E8003 check, not the + // nesting check — a disallowed operand like Fn::GetAtt must not produce + // an E1101 here, or it would double-report alongside E8003. let mut arena = Arena::new(); let getatt = arena.alloc(SpannedNode { node: Node::Intrinsic(IntrinsicFn::GetAtt("R".into(), "Arn".into())), span: UNKNOWN_SPAN, - path: "Resources/R/Properties/X".into(), - }); - let lit = arena.alloc(SpannedNode { - node: Node::String("val".into()), - span: UNKNOWN_SPAN, - path: "Resources/R/Properties/X".into(), - }); - arena.alloc(SpannedNode { - node: Node::Intrinsic(IntrinsicFn::Equals(getatt, lit)), - span: UNKNOWN_SPAN, - path: "Conditions/C".into(), - }); - - let diags = validate_intrinsic_nesting(&arena, &[]); - assert!( - diags - .iter() - .any(|d| d.rule_id == "F1105" && d.message.contains("Fn::GetAtt") && d.message.contains("Fn::Equals")) - ); - } - - #[test] - fn ref_inside_equals_is_allowed() { - let mut arena = Arena::new(); - let r = arena.alloc(SpannedNode { - node: Node::Intrinsic(IntrinsicFn::Ref("P".into())), - span: UNKNOWN_SPAN, path: "Conditions/C".into(), }); let lit = arena.alloc(SpannedNode { @@ -137,17 +114,17 @@ mod tests { path: "Conditions/C".into(), }); arena.alloc(SpannedNode { - node: Node::Intrinsic(IntrinsicFn::Equals(r, lit)), + node: Node::Intrinsic(IntrinsicFn::Equals(getatt, lit)), span: UNKNOWN_SPAN, path: "Conditions/C".into(), }); let diags = validate_intrinsic_nesting(&arena, &[]); - assert!(diags.is_empty()); + assert!(diags.is_empty(), "nesting must not flag Fn::Equals operands, got {:?}", diags); } #[test] - fn getatt_inside_and_produces_f1105() { + fn getatt_inside_and_produces_e1101() { let mut arena = Arena::new(); let getatt = arena.alloc(SpannedNode { node: Node::Intrinsic(IntrinsicFn::GetAtt("R".into(), "Arn".into())), @@ -164,7 +141,7 @@ mod tests { assert!( diags .iter() - .any(|d| d.rule_id == "F1105" && d.message.contains("Fn::GetAtt") && d.message.contains("Fn::And")) + .any(|d| d.rule_id == "E1101" && d.message.contains("Fn::GetAtt") && d.message.contains("Fn::And")) ); } @@ -225,7 +202,7 @@ mod tests { let diags = validate_intrinsic_nesting(&arena, &[]); assert_eq!(diags.len(), 2); // both k1 and k2 - assert!(diags.iter().all(|d| d.rule_id == "F1105")); + assert!(diags.iter().all(|d| d.rule_id == "E1101")); } #[test] @@ -270,7 +247,7 @@ mod tests { } #[test] - fn ref_inside_not_produces_f1105() { + fn ref_inside_not_produces_e1101() { let mut arena = Arena::new(); let r = arena.alloc(SpannedNode { node: Node::Intrinsic(IntrinsicFn::Ref("Param".into())), @@ -285,7 +262,7 @@ mod tests { let diags = validate_intrinsic_nesting(&arena, &[]); assert!( - diags.iter().any(|d| d.rule_id == "F1105" && d.message.contains("Ref") && d.message.contains("Fn::Not")) + diags.iter().any(|d| d.rule_id == "E1101" && d.message.contains("Ref") && d.message.contains("Fn::Not")) ); } diff --git a/src/template-model/src/parser/json.rs b/src/template-model/src/parser/json.rs index b114611..65053d7 100644 --- a/src/template-model/src/parser/json.rs +++ b/src/template-model/src/parser/json.rs @@ -5,6 +5,15 @@ use std::collections::HashMap; use std::collections::hash_map::Entry; use std::str::from_utf8; +const RULE_DUPLICATE_KEY: &str = "F0000"; +const RULE_FN_IF_SHAPE: &str = "F0013"; +const RULE_FN_EQUALS_SHAPE: &str = "E8003"; +const RULE_FN_AND_ARITY: &str = "E8004"; +const RULE_FN_NOT_ARITY: &str = "E8005"; +const RULE_FN_OR_ARITY: &str = "E8006"; +const RULE_FN_SELECT_SHAPE: &str = "E1017"; +const RULE_INVALID_INTRINSIC_USAGE: &str = "W1102"; + fn build_line_offsets(bytes: &[u8]) -> Vec { let mut offsets = vec![0usize]; // line 1 starts at byte 0 for (i, &b) in bytes.iter().enumerate() { @@ -69,7 +78,7 @@ fn equals_argument_error(val: &serde_json::Value) -> Option { if val.is_null() { return Some("null is not of type 'string'".to_string()); } - if val.is_string() || val.is_number() { + if val.is_string() || val.is_number() || val.is_boolean() { return None; } if let Some(obj) = val.as_object() @@ -154,19 +163,22 @@ impl JsonBuilder { } fn intrinsic_type_error(&mut self, fn_name: &str, message: &str) { - self.diagnostics.push(crate::make_parse_diagnostic("W1102", format!("{}: {}", fn_name, message), UNKNOWN_SPAN)); + self.diagnostics.push(crate::make_parse_diagnostic(RULE_INVALID_INTRINSIC_USAGE, format!("{}: {}", fn_name, message), UNKNOWN_SPAN)); } - /// Emit a structural diagnostic for Fn::Equals, Fn::And, Fn::Or, Fn::Not. - /// CloudFormation rejects templates with these defects at deploy time fn condition_fn_error(&mut self, fn_name: &str, message: &str) { - self.diagnostics.push(crate::make_parse_diagnostic("F0014", format!("{}: {}", fn_name, message), UNKNOWN_SPAN)); + let rule_id = match fn_name { + FN_EQUALS => RULE_FN_EQUALS_SHAPE, + FN_AND => RULE_FN_AND_ARITY, + FN_NOT => RULE_FN_NOT_ARITY, + FN_OR => RULE_FN_OR_ARITY, + _ => RULE_FN_EQUALS_SHAPE, + }; + self.diagnostics.push(crate::make_parse_diagnostic(rule_id, format!("{}: {}", fn_name, message), UNKNOWN_SPAN)); } - /// Emit a structural diagnostic for Fn::If. CloudFormation rejects - /// malformed Fn::If at deploy time; classified as Fatal. fn fn_if_structural_error(&mut self, message: &str) { - self.diagnostics.push(crate::make_parse_diagnostic("F0013", format!("{}: {}", FN_IF, message), UNKNOWN_SPAN)); + self.diagnostics.push(crate::make_parse_diagnostic(RULE_FN_IF_SHAPE, format!("{}: {}", FN_IF, message), UNKNOWN_SPAN)); } fn try_build_intrinsic(&mut self, key: &str, val: &serde_json::Value, path: &str) -> Option { @@ -317,7 +329,7 @@ impl JsonBuilder { }; if arr.len() != 2 { // Wrong element count — fall through to plain map so downstream - // rules (E1021) can report with proper resource context. + // rules (E1059) can report with proper resource context. return None; } if !arr[0].is_string() && !arr[0].is_object() { @@ -339,18 +351,29 @@ impl JsonBuilder { // Value is an intrinsic — cannot validate statically. return None; } - // Non-array value — fall through to plain map so downstream - // rules (E1017) can report with proper resource context. + self.diagnostics.push(crate::make_parse_diagnostic( + RULE_FN_SELECT_SHAPE, + format!("Fn::Select: {} is not of type 'array'", describe_json_value(val)), + UNKNOWN_SPAN, + )); return None; } }; if arr.len() != 2 { - // Wrong element count — fall through to plain map so downstream - // rules (E1017) can report with proper resource context. + let bound = if arr.len() < 2 { "minimum" } else { "maximum" }; + self.diagnostics.push(crate::make_parse_diagnostic( + RULE_FN_SELECT_SHAPE, + format!("Fn::Select: expected {} item count: 2, found: {}", bound, arr.len()), + UNKNOWN_SPAN, + )); return None; } if !arr[0].is_number() && !arr[0].is_object() { - self.intrinsic_type_error(FN_SELECT, "Fn::Select index (first element) must be an integer"); + self.diagnostics.push(crate::make_parse_diagnostic( + "E1017", + "Fn::Select: index (first element) must be an integer or an intrinsic function".to_string(), + UNKNOWN_SPAN, + )); } let idx = self.build_value(&arr[0], &format!("{}/Fn::Select/0", path)); let list = self.build_value(&arr[1], &format!("{}/Fn::Select/1", path)); @@ -559,14 +582,6 @@ impl JsonBuilder { return None; } }; - if arr.len() < 2 { - self.condition_fn_error(FN_AND, &format!("expected minimum item count: 2, found: {}", arr.len())); - return None; - } - if arr.len() > 10 { - self.condition_fn_error(FN_AND, &format!("expected maximum item count: 10, found: {}", arr.len())); - return None; - } for (idx, elem) in arr.iter().enumerate() { if let Some(reason) = condition_element_error(elem) { self.condition_fn_error(FN_AND, &format!("element {}: {}", idx, reason)); @@ -591,14 +606,6 @@ impl JsonBuilder { return None; } }; - if arr.len() < 2 { - self.condition_fn_error(FN_OR, &format!("expected minimum item count: 2, found: {}", arr.len())); - return None; - } - if arr.len() > 10 { - self.condition_fn_error(FN_OR, &format!("expected maximum item count: 10, found: {}", arr.len())); - return None; - } for (idx, elem) in arr.iter().enumerate() { if let Some(reason) = condition_element_error(elem) { self.condition_fn_error(FN_OR, &format!("element {}: {}", idx, reason)); @@ -626,9 +633,12 @@ impl JsonBuilder { return None; } }; + if arr.is_empty() { + self.condition_fn_error(FN_NOT, "must have exactly 1 element, got 0"); + return None; + } if arr.len() != 1 { self.condition_fn_error(FN_NOT, &format!("must have exactly 1 element, got {}", arr.len())); - return None; } if let Some(reason) = condition_element_error(&arr[0]) { self.condition_fn_error(FN_NOT, &format!("element 0: {}", reason)); @@ -878,6 +888,41 @@ impl JsonBuilder { path: path.to_string(), })) } + FN_GET_STACK_OUTPUT => { + // AWS spec form: object with required `StackName` and `OutputName`, + // optional `Region` and `RoleArn`. We also accept the legacy + // `[StackName, OutputName]` array form so existing templates and + // older docs keep working; both forms are normalized into a + // single-NodeRef payload that downstream rules can walk. + if let Some(arr) = val.as_array() { + if arr.len() != 2 { + self.intrinsic_error( + FN_GET_STACK_OUTPUT, + &format!("Fn::GetStackOutput value must be a 2-element array, got {}", arr.len()), + ); + return None; + } + let child = self.build_value(val, &format!("{}/Fn::GetStackOutput", path)); + Some(self.arena.alloc(SpannedNode { + node: Node::Intrinsic(IntrinsicFn::GetStackOutput(child)), + span: UNKNOWN_SPAN, + path: path.to_string(), + })) + } else if val.is_object() { + let child = self.build_value(val, &format!("{}/Fn::GetStackOutput", path)); + Some(self.arena.alloc(SpannedNode { + node: Node::Intrinsic(IntrinsicFn::GetStackOutput(child)), + span: UNKNOWN_SPAN, + path: path.to_string(), + })) + } else { + self.intrinsic_error( + FN_GET_STACK_OUTPUT, + "Fn::GetStackOutput value must be an object with StackName and OutputName, or a 2-element array", + ); + None + } + } _ => None, } } @@ -1031,7 +1076,7 @@ fn detect_duplicate_keys(bytes: &[u8]) -> Vec { Entry::Occupied(occupied) => { let (sl, sc) = offset_to_line_col(&line_offsets, start); diagnostics.push(crate::make_parse_diagnostic( - "F0000", + RULE_DUPLICATE_KEY, format!("Duplicate key '{}'", occupied.key()), SourceSpan { start_line: sl, @@ -1252,7 +1297,7 @@ mod tests { /// template's `Rules` block. Both intrinsics return boolean per the /// CloudFormation spec, so the parser must accept the nesting. #[test] - fn fn_not_accepts_fn_contains_argument_no_f0014() { + fn fn_not_accepts_fn_contains_argument_no_e8005() { let input = r#"{ "Parameters": { "BootstrapVersion": {"Type": "String"} @@ -1276,12 +1321,12 @@ mod tests { } }"#; let ir = parse_json(input.as_bytes()).unwrap(); - let f0014: Vec<_> = ir.diagnostics.iter().filter(|d| d.rule_id == "F0014").collect(); - assert!(f0014.is_empty(), "Expected no F0014 for Fn::Not(Fn::Contains), got: {:?}", f0014); + let e8005: Vec<_> = ir.diagnostics.iter().filter(|d| d.rule_id == "E8005").collect(); + assert!(e8005.is_empty(), "Expected no E8005 for Fn::Not(Fn::Contains), got: {:?}", e8005); } #[test] - fn fn_and_accepts_rules_section_boolean_intrinsics_no_f0014() { + fn fn_and_accepts_rules_section_boolean_intrinsics_no_e8004() { let input = r#"{ "Resources": {"B": {"Type": "AWS::S3::Bucket"}}, "Rules": { @@ -1299,14 +1344,14 @@ mod tests { } }"#; let ir = parse_json(input.as_bytes()).unwrap(); - let f0014: Vec<_> = ir.diagnostics.iter().filter(|d| d.rule_id == "F0014").collect(); - assert!(f0014.is_empty(), "Expected no F0014 for Fn::And of rule-section booleans, got: {:?}", f0014); + let e8004: Vec<_> = ir.diagnostics.iter().filter(|d| d.rule_id == "E8004").collect(); + assert!(e8004.is_empty(), "Expected no E8004 for Fn::And of rule-section booleans, got: {:?}", e8004); } - /// Genuinely-invalid input still produces F0014 — bare strings and + /// Genuinely-invalid input still produces E8004 — bare strings and /// non-boolean-producing intrinsics like Fn::Sub remain rejected. #[test] - fn fn_not_with_string_argument_still_produces_f0014() { + fn fn_not_with_string_argument_still_produces_e8005() { let input = r#"{ "Resources": {"B": {"Type": "AWS::S3::Bucket"}}, "Conditions": { @@ -1315,16 +1360,16 @@ mod tests { }"#; let ir = parse_json(input.as_bytes()).unwrap(); assert!( - ir.diagnostics.iter().any(|d| d.rule_id == "F0014" + ir.diagnostics.iter().any(|d| d.rule_id == "E8005" && d.message.contains("Fn::Not") && d.message.contains("is not of type 'boolean'")), - "Expected F0014 for Fn::Not with string arg, got: {:?}", + "Expected E8005 for Fn::Not with string arg, got: {:?}", ir.diagnostics ); } #[test] - fn fn_not_with_non_boolean_intrinsic_still_produces_f0014() { + fn fn_not_with_non_boolean_intrinsic_still_produces_e8005() { let input = r#"{ "Resources": {"B": {"Type": "AWS::S3::Bucket"}}, "Conditions": { @@ -1333,8 +1378,8 @@ mod tests { }"#; let ir = parse_json(input.as_bytes()).unwrap(); assert!( - ir.diagnostics.iter().any(|d| d.rule_id == "F0014" && d.message.contains("Fn::Not")), - "Expected F0014 for Fn::Not wrapping Fn::Sub, got: {:?}", + ir.diagnostics.iter().any(|d| d.rule_id == "E8005" && d.message.contains("Fn::Not")), + "Expected E8005 for Fn::Not wrapping Fn::Sub, got: {:?}", ir.diagnostics ); } diff --git a/src/template-model/src/parser/yaml.rs b/src/template-model/src/parser/yaml.rs index ee77e09..61527fb 100644 --- a/src/template-model/src/parser/yaml.rs +++ b/src/template-model/src/parser/yaml.rs @@ -6,9 +6,26 @@ use std::mem; use std::str::from_utf8; use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser, Tag}; use yaml_rust2::scanner::Marker; + +const RULE_DUPLICATE_KEY: &str = "F0000"; +const RULE_YAML_MERGE: &str = "W1100"; +const RULE_FN_IF_SHAPE: &str = "F0013"; +const RULE_FN_EQUALS_SHAPE: &str = "E8003"; +const RULE_FN_AND_ARITY: &str = "E8004"; +const RULE_FN_NOT_ARITY: &str = "E8005"; +const RULE_FN_OR_ARITY: &str = "E8006"; +const RULE_FN_SELECT_SHAPE: &str = "E1017"; +const RULE_INTRINSIC_PARSE_ERROR: &str = "F1101"; use yaml_rust2::yaml::{Hash, Yaml}; /// Converts YAML shorthand tags (!Ref, !Sub, etc.) into map-form intrinsics. +struct LoadOutput { + docs: Vec, + span_map: HashMap, + duplicate_keys: Vec<(String, u32, u32)>, + merge_keys: Vec<(u32, u32)>, +} + struct CfnYamlLoader { docs: Vec, doc_stack: Vec<(Yaml, usize)>, @@ -22,6 +39,20 @@ struct CfnYamlLoader { path_stack: Vec, array_idx_stack: Vec, span_map: HashMap, + /// Duplicate keys detected during loading. yaml_rust2's `Hash::insert` + /// silently overwrites earlier entries; we record the path + the position + /// of the offending duplicate so the parser can surface a diagnostic. + duplicate_keys: Vec<(String, u32, u32)>, + /// YAML merge-key (`<<:`) usages detected during loading. CloudFormation + /// itself does not support YAML 1.1 merge semantics — `aws cloudformation + /// package` pre-processes them out before submission, but a template + /// containing them cannot be deployed directly. The parser collects every + /// occurrence so a diagnostic can be surfaced (W1100). + merge_keys: Vec<(u32, u32)>, + /// Stack of pending key positions, parallel to `key_stack`. Each entry is + /// the (line, column) of the most-recently scanned key in that mapping + /// frame, so we can attach an accurate span to a duplicate-key diagnostic. + key_position_stack: Vec<(u32, u32)>, } impl CfnYamlLoader { @@ -35,14 +66,22 @@ impl CfnYamlLoader { path_stack: Vec::new(), array_idx_stack: Vec::new(), span_map: HashMap::new(), + duplicate_keys: Vec::new(), + merge_keys: Vec::new(), + key_position_stack: Vec::new(), } } - fn load(text: &str) -> Result<(Vec, HashMap), String> { + fn load(text: &str) -> Result { let mut loader = Self::new(); let mut parser = Parser::new_from_str(text); parser.load(&mut loader, true).map_err(|e| format!("{}", e))?; - Ok((loader.docs, loader.span_map)) + Ok(LoadOutput { + docs: loader.docs, + span_map: loader.span_map, + duplicate_keys: loader.duplicate_keys, + merge_keys: loader.merge_keys, + }) } fn current_path(&self) -> String { @@ -56,7 +95,8 @@ impl CfnYamlLoader { match name.as_str() { "Ref" | "GetAtt" | "Sub" | "Join" | "Select" | "If" | "FindInMap" | "Split" | "Base64" | "Cidr" | "GetAZs" | "ImportValue" | "Transform" | "And" | "Or" | "Not" | "Equals" | "Condition" - | "ToJsonString" | "Length" | "ForEach" => Some(name.clone()), + | "ToJsonString" | "Length" | "ForEach" | "GetStackOutput" | "Contains" + | "EachMemberEquals" | "EachMemberIn" | "RefAll" | "ValueOf" | "ValueOfAll" => Some(name.clone()), _ => None, } } else { @@ -87,6 +127,13 @@ impl CfnYamlLoader { "ToJsonString" => FN_TO_JSON_STRING, "Length" => FN_LENGTH, "ForEach" => FN_FOR_EACH, + "GetStackOutput" => FN_GET_STACK_OUTPUT, + "Contains" => FN_CONTAINS, + "EachMemberEquals" => FN_EACH_MEMBER_EQUALS, + "EachMemberIn" => FN_EACH_MEMBER_IN, + "RefAll" => FN_REF_ALL, + "ValueOf" => FN_VALUE_OF, + "ValueOfAll" => FN_VALUE_OF_ALL, _ => return value, }; let mut hash = Hash::new(); @@ -94,7 +141,7 @@ impl CfnYamlLoader { Yaml::Hash(hash) } - fn insert_new_node(&mut self, node: (Yaml, usize), _mark: Marker) { + fn insert_new_node(&mut self, node: (Yaml, usize), mark: Marker) { let (mut node_val, aid) = node; if let Some((_, depth)) = self.pending_tags.last() && self.doc_stack.len() == *depth @@ -122,6 +169,16 @@ impl CfnYamlLoader { *cur_key = node_val; } else { let key = mem::replace(cur_key, Yaml::BadValue); + if h.contains_key(&key) + && let Yaml::String(ref key_str) = key + { + let (line, col) = self + .key_position_stack + .last() + .copied() + .unwrap_or((mark.line() as u32 + 1, mark.col() as u32 + 1)); + self.duplicate_keys.push((key_str.clone(), line, col)); + } h.insert(key, node_val); } } @@ -157,9 +214,11 @@ impl MarkedEventReceiver for CfnYamlLoader { } self.doc_stack.push((Yaml::Hash(Hash::new()), aid)); self.key_stack.push(Yaml::BadValue); + self.key_position_stack.push((0, 0)); } Event::MappingEnd => { self.key_stack.pop(); + self.key_position_stack.pop(); if !self.path_stack.is_empty() { let parent_is_array = self.doc_stack.last().map(|(y, _)| matches!(y, Yaml::Array(_))).unwrap_or(false); @@ -189,7 +248,21 @@ impl MarkedEventReceiver for CfnYamlLoader { self.path_stack.push(v.clone()); let path = self.current_path(); // mark.line() and mark.col() are 0-based - self.span_map.insert(path, (mark.line() as u32 + 1, mark.col() as u32 + 1)); + let line = mark.line() as u32 + 1; + let col = mark.col() as u32 + 1; + self.span_map.insert(path, (line, col)); + // The YAML 1.1 merge-key indicator. CloudFormation + // does not support merge keys, so flag every + // occurrence — the deployment would fail. + if v == "<<" { + self.merge_keys.push((line, col)); + } + // Remember this key's position so a later + // duplicate detected for this mapping frame can + // report it accurately. + if let Some(slot) = self.key_position_stack.last_mut() { + *slot = (line, col); + } } } else if matches!(parent.0, Yaml::Array(_)) && let Some(idx) = self.array_idx_stack.last_mut() @@ -220,7 +293,7 @@ pub fn parse_yaml(bytes: &[u8]) -> Result { column: None, })?; - let (docs, raw_spans) = CfnYamlLoader::load(text).map_err(|e| ParseError { + let LoadOutput { docs, span_map: raw_spans, duplicate_keys, merge_keys } = CfnYamlLoader::load(text).map_err(|e| ParseError { message: format!("YAML parse error: {}", e), line: None, column: None, @@ -288,6 +361,32 @@ pub fn parse_yaml(bytes: &[u8]) -> Result { warn!("{} parse diagnostics from YAML (malformed intrinsics)", builder.diagnostics.len()); } + for (key, line, col) in &duplicate_keys { + builder.diagnostics.push(crate::make_parse_diagnostic( + RULE_DUPLICATE_KEY, + format!("Duplicate key '{}'", key), + SourceSpan { + start_line: *line, + start_column: *col, + end_line: *line, + end_column: *col + key.len() as u32, + }, + )); + } + + for (line, col) in &merge_keys { + builder.diagnostics.push(crate::make_parse_diagnostic( + RULE_YAML_MERGE, + "YAML merge key '<<' is not supported by CloudFormation — use 'aws cloudformation package' to pre-process".into(), + SourceSpan { + start_line: *line, + start_column: *col, + end_line: *line, + end_column: *col + 2, + }, + )); + } + Ok(TemplateIR { arena: builder.arena, global_index: builder.global_index, @@ -370,7 +469,7 @@ fn equals_argument_error_yaml(val: &Yaml) -> Option { if matches!(val, Yaml::Null | Yaml::BadValue) { return Some("null is not of type 'string'".to_string()); } - if matches!(val, Yaml::String(_) | Yaml::Integer(_) | Yaml::Real(_)) { + if matches!(val, Yaml::String(_) | Yaml::Integer(_) | Yaml::Real(_) | Yaml::Boolean(_)) { return None; } if let Some(hash) = val.as_hash() @@ -438,18 +537,22 @@ impl YamlBuilder { } fn intrinsic_error(&mut self, fn_name: &str, message: &str) { - self.diagnostics.push(crate::make_parse_diagnostic("F1101", format!("{}: {}", fn_name, message), UNKNOWN_SPAN)); + self.diagnostics.push(crate::make_parse_diagnostic(RULE_INTRINSIC_PARSE_ERROR, format!("{}: {}", fn_name, message), UNKNOWN_SPAN)); } - /// Emit a structural diagnostic for Fn::Equals, Fn::And, Fn::Or, Fn::Not. - /// CloudFormation rejects templates with these defects at deploy time. fn condition_fn_error(&mut self, fn_name: &str, message: &str) { - self.diagnostics.push(crate::make_parse_diagnostic("F0014", format!("{}: {}", fn_name, message), UNKNOWN_SPAN)); + let rule_id = match fn_name { + FN_EQUALS => RULE_FN_EQUALS_SHAPE, + FN_AND => RULE_FN_AND_ARITY, + FN_NOT => RULE_FN_NOT_ARITY, + FN_OR => RULE_FN_OR_ARITY, + _ => RULE_FN_EQUALS_SHAPE, + }; + self.diagnostics.push(crate::make_parse_diagnostic(rule_id, format!("{}: {}", fn_name, message), UNKNOWN_SPAN)); } - /// Emit a structural diagnostic for Fn::If. fn fn_if_structural_error(&mut self, message: &str) { - self.diagnostics.push(crate::make_parse_diagnostic("F0013", format!("{}: {}", FN_IF, message), UNKNOWN_SPAN)); + self.diagnostics.push(crate::make_parse_diagnostic(RULE_FN_IF_SHAPE, format!("{}: {}", FN_IF, message), UNKNOWN_SPAN)); } fn try_intrinsic(&mut self, key: &str, val: &Yaml, path: &str) -> Option { @@ -567,7 +670,7 @@ impl YamlBuilder { }; if a.len() != 2 { // Wrong element count — fall through to plain map so downstream - // rules (E1021) can report with proper resource context. + // rules (E1059) can report with proper resource context. return None; } if !matches!(&a[0], Yaml::String(_) | Yaml::Hash(_)) { @@ -587,18 +690,25 @@ impl YamlBuilder { // Value is an intrinsic — cannot validate statically. return None; } - // Non-array value (e.g. string) — fall through to plain map so - // downstream rules (E1017) can report with proper resource context. + self.diagnostics.push(crate::make_parse_diagnostic( + RULE_FN_SELECT_SHAPE, + format!("Fn::Select: {} is not of type 'array'", describe_yaml_value(val)), + UNKNOWN_SPAN, + )); return None; }; if a.len() != 2 { - // Wrong element count — fall through to plain map so downstream - // rules (E1017) can report with proper resource context. + let bound = if a.len() < 2 { "minimum" } else { "maximum" }; + self.diagnostics.push(crate::make_parse_diagnostic( + RULE_FN_SELECT_SHAPE, + format!("Fn::Select: expected {} item count: 2, found: {}", bound, a.len()), + UNKNOWN_SPAN, + )); return None; } if !matches!(&a[0], Yaml::Integer(_) | Yaml::Hash(_)) { self.diagnostics.push(crate::make_parse_diagnostic( - "W1102", + "E1017", "Fn::Select: index (first argument) must be an integer or an intrinsic function".to_string(), UNKNOWN_SPAN, )); @@ -741,14 +851,6 @@ impl YamlBuilder { self.condition_fn_error(FN_AND, &format!("{} is not of type 'array'", describe_yaml_value(val))); return None; }; - if a.len() < 2 { - self.condition_fn_error(FN_AND, &format!("expected minimum item count: 2, found: {}", a.len())); - return None; - } - if a.len() > 10 { - self.condition_fn_error(FN_AND, &format!("expected maximum item count: 10, found: {}", a.len())); - return None; - } for (idx, elem) in a.iter().enumerate() { if let Some(reason) = condition_element_error_yaml(elem) { self.condition_fn_error(FN_AND, &format!("element {}: {}", idx, reason)); @@ -766,14 +868,6 @@ impl YamlBuilder { self.condition_fn_error(FN_OR, &format!("{} is not of type 'array'", describe_yaml_value(val))); return None; }; - if a.len() < 2 { - self.condition_fn_error(FN_OR, &format!("expected minimum item count: 2, found: {}", a.len())); - return None; - } - if a.len() > 10 { - self.condition_fn_error(FN_OR, &format!("expected maximum item count: 10, found: {}", a.len())); - return None; - } for (idx, elem) in a.iter().enumerate() { if let Some(reason) = condition_element_error_yaml(elem) { self.condition_fn_error(FN_OR, &format!("element {}: {}", idx, reason)); @@ -791,9 +885,12 @@ impl YamlBuilder { self.condition_fn_error(FN_NOT, &format!("{} is not of type 'array'", describe_yaml_value(val))); return None; }; + if a.is_empty() { + self.condition_fn_error(FN_NOT, "must have exactly 1 element, got 0"); + return None; + } if a.len() != 1 { self.condition_fn_error(FN_NOT, &format!("must have exactly 1 element, got {}", a.len())); - return None; } if let Some(reason) = condition_element_error_yaml(&a[0]) { self.condition_fn_error(FN_NOT, &format!("element 0: {}", reason)); @@ -961,6 +1058,31 @@ impl YamlBuilder { }; IntrinsicFn::Ref(format!("Condition:{}", s)) } + FN_GET_STACK_OUTPUT => { + // Accept both the AWS spec object form `{StackName, OutputName, Region?, RoleArn?}` + // and the legacy `[StackName, OutputName]` array form. Both normalize into a + // single-NodeRef payload so downstream rules can validate the shape uniformly. + if let Some(a) = val.as_vec() { + if a.len() != 2 { + self.intrinsic_error( + FN_GET_STACK_OUTPUT, + &format!("Fn::GetStackOutput value must be a 2-element array, got {}", a.len()), + ); + return None; + } + let c = self.build_yaml(val, &format!("{}/Fn::GetStackOutput", path)); + IntrinsicFn::GetStackOutput(c) + } else if val.as_hash().is_some() { + let c = self.build_yaml(val, &format!("{}/Fn::GetStackOutput", path)); + IntrinsicFn::GetStackOutput(c) + } else { + self.intrinsic_error( + FN_GET_STACK_OUTPUT, + "Fn::GetStackOutput value must be an object with StackName and OutputName, or a 2-element array", + ); + return None; + } + } _ => return None, }; Some(self.arena.alloc(SpannedNode { node: Node::Intrinsic(i), span: UNKNOWN_SPAN, path: path.into() })) @@ -1089,22 +1211,22 @@ mod tests { /// a type error — `Fn::Contains` is a boolean-producing Rules-section /// intrinsic, not a non-boolean expression. #[test] - fn fn_not_accepts_fn_contains_argument_no_f0014() { + fn fn_not_accepts_fn_contains_argument_no_e8005() { let input = "Parameters:\n BootstrapVersion:\n Type: String\nResources:\n B:\n Type: AWS::S3::Bucket\nRules:\n CheckBootstrapVersion:\n Assertions:\n - Assert:\n Fn::Not:\n - Fn::Contains:\n - [\"1\", \"2\", \"3\", \"4\", \"5\"]\n - Ref: BootstrapVersion\n"; let ir = parse_yaml(input.as_bytes()).unwrap(); - let f0014: Vec<_> = ir.diagnostics.iter().filter(|d| d.rule_id == "F0014").collect(); - assert!(f0014.is_empty(), "Expected no F0014 for Fn::Not(Fn::Contains), got: {:?}", f0014); + let e8005: Vec<_> = ir.diagnostics.iter().filter(|d| d.rule_id == "E8005").collect(); + assert!(e8005.is_empty(), "Expected no E8005 for Fn::Not(Fn::Contains), got: {:?}", e8005); } #[test] - fn fn_not_with_string_argument_still_produces_f0014() { + fn fn_not_with_string_argument_still_produces_e8005() { let input = "Resources:\n B:\n Type: AWS::S3::Bucket\nConditions:\n Bad:\n Fn::Not:\n - definitely-not-boolean\n"; let ir = parse_yaml(input.as_bytes()).unwrap(); assert!( - ir.diagnostics.iter().any(|d| d.rule_id == "F0014" + ir.diagnostics.iter().any(|d| d.rule_id == "E8005" && d.message.contains("Fn::Not") && d.message.contains("is not of type 'boolean'")), - "Expected F0014 for Fn::Not with string arg, got: {:?}", + "Expected E8005 for Fn::Not with string arg, got: {:?}", ir.diagnostics ); } diff --git a/src/template-model/src/resolver.rs b/src/template-model/src/resolver.rs index 495bfab..040c67e 100644 --- a/src/template-model/src/resolver.rs +++ b/src/template-model/src/resolver.rs @@ -109,6 +109,7 @@ pub(crate) struct Resolver<'a> { resolution_source_map: HashMap<(String, String), String>, // (resource_id, property_path) → source description parameter_overrides: &'a HashMap, pseudo_parameter_overrides: &'a crate::model::PseudoParameterOverrides, + transforms: &'a [String], pub(crate) current_resource: Option, condition_stack: Vec<(String, bool)>, // (condition_name, is_true_branch) @@ -126,6 +127,7 @@ impl<'a> Resolver<'a> { resource_ids: HashSet, parameter_overrides: &'a HashMap, pseudo_parameter_overrides: &'a crate::model::PseudoParameterOverrides, + transforms: &'a [String], ) -> Self { Self { arena, @@ -147,6 +149,7 @@ impl<'a> Resolver<'a> { resolution_source_map: HashMap::new(), parameter_overrides, pseudo_parameter_overrides, + transforms, current_resource: None, condition_stack: Vec::new(), @@ -186,7 +189,7 @@ impl<'a> Resolver<'a> { Node::Float(f) => ResolvedValue::Concrete { value: serde_json::json!(*f).into() }, Node::String(s) => { if s.starts_with("{{resolve:") { - ResolvedValue::Dynamic { reason: format!("dynamic reference: {}", s) } + ResolvedValue::Dynamic { reason: format!("{}{}", DYNAMIC_REFERENCE_REASON_PREFIX, s) } } else { self.detect_unsubstituted_variables(s); ResolvedValue::Concrete { value: serde_json::Value::String(s.clone()).into() } @@ -360,6 +363,12 @@ impl<'a> Resolver<'a> { | ResolvedValue::Conditional { condition: _, if_true: _, if_false: _ } ) }) { + // Conditional/enum items collapse into a flat + // Enum or Dynamic inside `join_with_enum_list`, + // which discards the condition reference. Record + // the conditions on the resource first so W8001 + // (unused conditions) does not see them as dead. + self.collect_extra_condition_refs(&values); return join_with_enum_list(ds, items); } // Partial join: substitute concrete items, placeholder for others @@ -494,9 +503,23 @@ impl<'a> Resolver<'a> { _ => ResolvedValue::Dynamic { reason: "Base64 with unresolvable argument".into() }, } } - IntrinsicFn::ImportValue(_) => { + IntrinsicFn::ImportValue(arg_ref) => { + // Walk the argument to register edges from nested intrinsics + self.resolve_node(*arg_ref); ResolvedValue::TypedDynamic { reason: "cross-stack import".into(), param_type: "String".into() } } + IntrinsicFn::GetStackOutput(arg_ref) => { + // Walk the argument list to register edges + self.resolve_node(*arg_ref); + if !self.transforms.contains(&TRANSFORM_LANGUAGE_EXTENSIONS.to_string()) { + self.diagnostics.push(crate::make_parse_diagnostic( + "E1033", + "Fn::GetStackOutput requires the AWS::LanguageExtensions transform".to_string(), + *span, + )); + } + ResolvedValue::TypedDynamic { reason: "cross-stack output".into(), param_type: "String".into() } + } IntrinsicFn::Transform(_, _) => ResolvedValue::Dynamic { reason: "macro output".into() }, IntrinsicFn::GetAZs(region_ref) => { if let Some(ref rid) = self.current_resource { @@ -664,7 +687,10 @@ impl<'a> Resolver<'a> { _ => ResolvedValue::Dynamic { reason: "condition expression".into() }, } } - IntrinsicFn::RefAll(_) => ResolvedValue::Dynamic { reason: "rules-only function".into() }, + IntrinsicFn::RefAll(name) => { + self.record_edge(name, RefKind::Ref, span); + ResolvedValue::Dynamic { reason: "rules-only function".into() } + } IntrinsicFn::ValueOf(param_name, _attr) | IntrinsicFn::ValueOfAll(param_name, _attr) => { // The first argument is a parameter (or parameter group) name — // record a Ref edge so the parameter is counted as referenced. @@ -1094,6 +1120,10 @@ impl<'a> Resolver<'a> { let start = i + 2; if let Some(end) = s[start..].find('}') { let var = s[start..start + end].trim(); + if var.starts_with("stageVariables.") { + i = start + end + 1; + continue; + } if !var.is_empty() && (self.resource_ids.contains(var) || self.parameters.contains_key(var) @@ -1150,7 +1180,7 @@ impl<'a> Resolver<'a> { span: &SourceSpan, ) -> ResolvedValue { if template.contains("${!") { - self.diagnostics.push(crate::make_parse_diagnostic("F1029", "Fn::Sub template contains '${!' which suggests nested intrinsic syntax — use the second argument map instead".to_string(), *span)); + self.diagnostics.push(crate::make_parse_diagnostic("W1056", "Fn::Sub template contains '${!' which suggests nested intrinsic syntax — use the second argument map instead".to_string(), *span)); } let mut vars: Vec = Vec::new(); @@ -1374,18 +1404,7 @@ fn param_string_to_json(value: &str, param_type: &str) -> serde_json::Value { } fn availability_zones_for_region(region: &str) -> Option> { - let suffixes: &[&str] = match region { - "us-east-1" => &["a", "b", "c", "d", "e", "f"], - "us-east-2" => &["a", "b", "c"], - "us-west-1" => &["a", "b"], - "us-west-2" => &["a", "b", "c", "d"], - "eu-west-1" | "eu-west-2" | "eu-west-3" | "eu-central-1" => &["a", "b", "c"], - "ap-southeast-1" | "ap-southeast-2" | "ap-south-1" | "sa-east-1" => &["a", "b", "c"], - "ap-northeast-1" | "ap-northeast-2" => &["a", "b", "c", "d"], - "ca-central-1" => &["a", "b", "d"], - _ => return None, - }; - Some(suffixes.iter().map(|s| format!("{}{}", region, s)).collect()) + crate::aws_regions::availability_zones_for_region(region) } fn calculate_cidr_blocks(ip_block: &str, count: u64, cidr_bits: u64) -> Option> { @@ -2031,6 +2050,7 @@ fn intrinsic_name(intrinsic: &IntrinsicFn) -> &'static str { IntrinsicFn::Contains(_, _) => "Contains", IntrinsicFn::EachMemberEquals(_, _) => "EachMemberEquals", IntrinsicFn::EachMemberIn(_, _) => "EachMemberIn", + IntrinsicFn::GetStackOutput(_) => "GetStackOutput", } } @@ -2049,7 +2069,7 @@ mod tests { let no_param_overrides = HashMap::new(); let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default(); let mut resolver = - Resolver::new(&ir.arena, ¶ms, &mappings, resource_ids, &no_param_overrides, &no_pseudo_overrides); + Resolver::new(&ir.arena, ¶ms, &mappings, resource_ids, &no_param_overrides, &no_pseudo_overrides, &[]); let res_map = ir.arena.as_map(ir.resources).unwrap(); let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap(); let v_ref = ir.arena.map_get(props, "V").unwrap(); @@ -2074,7 +2094,7 @@ mod tests { &mappings, ["R".to_string()].into_iter().collect(), &no_param_overrides, - &no_pseudo_overrides, + &no_pseudo_overrides, &[], ); let res_map = ir.arena.as_map(ir.resources).unwrap(); let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap(); @@ -2102,7 +2122,7 @@ mod tests { &mappings, ["R".to_string()].into_iter().collect(), &no_param_overrides, - &no_pseudo_overrides, + &no_pseudo_overrides, &[], ); let res_map = ir.arena.as_map(ir.resources).unwrap(); let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap(); @@ -2125,7 +2145,7 @@ mod tests { &mappings, ["R".to_string()].into_iter().collect(), &no_param_overrides, - &no_pseudo_overrides, + &no_pseudo_overrides, &[], ); let res_map = ir.arena.as_map(ir.resources).unwrap(); let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap(); @@ -2151,7 +2171,7 @@ mod tests { &mappings, ["R".to_string()].into_iter().collect(), ¶m_overrides, - &no_pseudo_overrides, + &no_pseudo_overrides, &[], ); let res_map = ir.arena.as_map(ir.resources).unwrap(); let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap(); @@ -2179,6 +2199,7 @@ mod tests { ["R".to_string()].into_iter().collect(), &no_param_overrides, &pseudo_overrides, + &[], ); let res_map = ir.arena.as_map(ir.resources).unwrap(); let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap(); @@ -2888,4 +2909,60 @@ mod tests { other => panic!("Expected Dynamic, got {:?}", other), } } + + #[test] + fn getstackoutput_without_langext_emits_e1033() { + let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetStackOutput":["stack","output"]}}}}}"#; + let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap(); + let e1033: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E1033").collect(); + assert_eq!(e1033.len(), 1, "expected E1033, got {:?}", e1033); + assert!(e1033[0].message.contains("AWS::LanguageExtensions")); + } + + #[test] + fn getstackoutput_with_langext_no_transform_e1033() { + let input = r#"{"Transform":"AWS::LanguageExtensions","Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetStackOutput":["stack","output"]}}}}}"#; + let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap(); + let e1033: Vec<_> = model.diagnostics.iter().filter(|d| d.rule_id == "E1033").collect(); + assert!(e1033.is_empty(), "expected no E1033 with LanguageExtensions and valid shape, got {:?}", e1033); + } + + #[test] + fn import_value_walks_nested_sub_registers_edge() { + let input = r#"{ + "Parameters":{"StackPrefix":{"Type":"String","Default":"mystack"}}, + "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":{"Fn::Sub":"${StackPrefix}-export"}}}}} + }"#; + let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap(); + let edges = &model.graph.edges; + let has_ref_edge = edges.iter().any(|e| e.source_resource == "R" && e.target == "StackPrefix"); + assert!(has_ref_edge, "ImportValue should walk nested Sub and register Ref edge to StackPrefix"); + } + + #[test] + fn ref_all_records_ref_edge() { + let input = r#"{ + "Parameters":{"MyGroup":{"Type":"String","Default":"group1"}}, + "Rules":{"MyRule":{"Assertions":[{"Assert":{"Fn::Contains":[{"Fn::RefAll":"MyGroup"},{"Ref":"MyGroup"}]},"AssertDescription":"test"}]}} + }"#; + let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap(); + let edges = &model.graph.edges; + let has_refall_edge = edges.iter().any(|e| e.target == "MyGroup" + && e.source_resource.contains("__rule__")); + assert!(has_refall_edge, "Fn::RefAll should register a Ref edge to the parameter group"); + } + + #[test] + fn getazs_eu_south_1_produces_concrete_not_dynamic() { + let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetAZs":"eu-south-1"}}}}}"#; + let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap(); + match model.resolve("R", "Properties.V") { + Some(ResolvedValue::Concrete { value: v }) => { + let arr = v.as_array().unwrap(); + assert_eq!(arr.len(), 3); + assert_eq!(arr[0].as_str().unwrap(), "eu-south-1a"); + } + other => panic!("Expected Concrete AZ array for eu-south-1, got {:?}", other), + } + } } diff --git a/src/template-model/src/rules.rs b/src/template-model/src/rules.rs index 4d069f8..ec8dc67 100644 --- a/src/template-model/src/rules.rs +++ b/src/template-model/src/rules.rs @@ -233,7 +233,8 @@ fn walk_intrinsic_children(arena: &Arena, intrinsic: &IntrinsicFn, out: &mut Vec | IntrinsicFn::ImportValue(c) | IntrinsicFn::Not(c) | IntrinsicFn::ToJsonString(c) - | IntrinsicFn::Length(c) => { + | IntrinsicFn::Length(c) + | IntrinsicFn::GetStackOutput(c) => { children.push(*c); } IntrinsicFn::And(nodes) | IntrinsicFn::Or(nodes) => { diff --git a/src/template-model/src/serialization.rs b/src/template-model/src/serialization.rs index 3aa977e..c2ec2b3 100644 --- a/src/template-model/src/serialization.rs +++ b/src/template-model/src/serialization.rs @@ -217,6 +217,19 @@ fn build_resources( .iter() .map(|s| PathTarget { path: s.path.clone(), target: s.value.clone() }) .collect(), + split_dynamic_ref_delimiters: res.diagnostics.split_dynamic_ref_delimiters.clone(), + unused_sub_keys: res + .diagnostics + .unused_sub_keys + .iter() + .map(|s| PathVariable { path: s.path.clone(), variable: s.value.clone() }) + .collect(), + base64_disallowed_functions: res + .diagnostics + .base64_disallowed_functions + .iter() + .map(|s| PathVariable { path: s.path.clone(), variable: s.value.clone() }) + .collect(), }, ) }) @@ -226,6 +239,18 @@ fn build_resources( fn build_conditions(conditions: &crate::conditions::ConditionModel) -> HashMap { let mut out = HashMap::new(); for name in conditions.names() { + // Synthetic conditions (Rules-section assertions registered as + // `__rule_*`, resolver inline conditions as `__inline_*`, and malformed + // `__malformed_*` placeholders) are internal SAT bookkeeping, not + // user-authored condition names. They must not surface in the + // serialized condition map, or rules that iterate it — W8001 (unused + // conditions) in particular — would flag synthetic bookkeeping names + // as if they were user-defined and unreferenced. + // The implication/mutex/exclusion fields carry the synthetic + // names separately for cross-condition reasoning. + if name.starts_with("__") { + continue; + } let (expression, deps) = if let Some(expr) = conditions.get(name) { let mut d = Vec::new(); crate::conditions::collect_condition_deps(expr, &mut d); diff --git a/src/template-model/src/transform_expansion.rs b/src/template-model/src/transform_expansion.rs new file mode 100644 index 0000000..5d0e8a9 --- /dev/null +++ b/src/template-model/src/transform_expansion.rs @@ -0,0 +1,588 @@ +//! `AWS::LanguageExtensions` transform expansion. +//! +//! When the template declares the `AWS::LanguageExtensions` transform, the +//! `Fn::ForEach::*` macro form is allowed inside any object section +//! (Conditions, Resources, Outputs, ...) and produces multiple sibling entries +//! after expansion. CloudFormation expands these macros server-side before +//! deployment, so for parity we expand them here — after parsing, before the +//! resolver and `condition_shape` see the model. +//! +//! Without this pass, downstream rules emit false positives because the +//! macro's body references variables that exist only after substitution +//! (`{Ref: {Fn::Sub: "Param${Identifier}"}}` looks like a malformed `Ref`, +//! and parameters used only via the macro look unreferenced). +//! +//! Scope today is intentionally narrow: +//! +//! * The collection (second `Fn::ForEach` argument) must be a literal list of +//! scalar strings (or a `Ref` to a parameter whose default is a known +//! `CommaDelimitedList` literal — not yet supported). +//! * Inside the body, only string-key substitution, `Fn::Sub` template +//! substitution, and `Ref:` substitution are performed. Other +//! intrinsics are walked recursively so nested macros also expand, but +//! their internal structure is preserved. +//! +//! Macros whose collection is dynamic, or that exceed the expansion-depth +//! cap, remain in place and are reported as parse-level diagnostics so the +//! user knows the macro was not expanded. + +use crate::consts::{FN_REF, FN_SUB, TRANSFORM_LANGUAGE_EXTENSIONS}; +use crate::ir::*; +use diagnostics::Diagnostic; +use std::collections::HashMap; + +const FOREACH_PREFIX: &str = "Fn::ForEach::"; +const MAX_EXPANSION_DEPTH: u32 = 16; + +/// Rule ID emitted when a `Fn::ForEach` macro cannot be expanded +/// (dynamic collection, too-deep nesting, malformed shape, ...). The +/// macro is left in place so downstream rules can still flag obvious +/// errors, but the user is told why expansion was skipped. +const RULE_FOREACH_NOT_EXPANDED: &str = "W9032"; + +/// Bindings of iteration-variable name → concrete string value. The same +/// `${name}` placeholder is substituted across map keys, `Fn::Sub` templates, +/// and bare `Ref` targets. +type Bindings = HashMap; + +/// Run the `AWS::LanguageExtensions` transform on `ir`. Returns diagnostics +/// for macros that could not be expanded. When the transform is not declared, +/// this is a no-op. +pub(crate) fn expand_language_extensions(ir: &mut TemplateIR) -> Vec { + if !ir.transforms.iter().any(|t| t == TRANSFORM_LANGUAGE_EXTENSIONS) { + return Vec::new(); + } + + let mut diagnostics = Vec::new(); + let bindings = Bindings::new(); + + // Sections that may contain `Fn::ForEach::*` macros at any depth. + for section_ref in [ir.conditions, ir.resources, ir.outputs] { + if section_ref != NULL_REF { + expand_at(&mut ir.arena, section_ref, &bindings, 0, &mut diagnostics); + } + } + + diagnostics +} + +/// Expand any `Fn::ForEach::*` macros reachable from `node_ref`. Recurses +/// into child maps and lists. Substitutions in `bindings` apply to every +/// substring encountered along the way (used by nested macros which inherit +/// outer bindings). +fn expand_at( + arena: &mut Arena, + node_ref: NodeRef, + bindings: &Bindings, + depth: u32, + diagnostics: &mut Vec, +) { + if node_ref == NULL_REF || !arena.is_valid(node_ref) { + return; + } + if depth > MAX_EXPANSION_DEPTH { + diagnostics.push(crate::make_parse_diagnostic( + RULE_FOREACH_NOT_EXPANDED, + format!( + "Fn::ForEach expansion exceeded maximum depth {}; macro left unexpanded", + MAX_EXPANSION_DEPTH + ), + arena.get(node_ref).span, + )); + return; + } + + match arena.node(node_ref).clone() { + Node::Map(entries) => { + let mut new_entries: Vec<(String, NodeRef)> = Vec::with_capacity(entries.len()); + for (key, value) in entries { + if let Some(macro_uid) = key.strip_prefix(FOREACH_PREFIX) { + match expand_foreach(arena, &key, value, bindings, depth, diagnostics) { + ExpansionResult::Expanded(expanded) => { + for (ek, ev) in expanded { + if let Some(_) = new_entries.iter().find(|(k, _)| k == &ek) { + diagnostics.push(crate::make_parse_diagnostic( + RULE_FOREACH_NOT_EXPANDED, + format!( + "Fn::ForEach::{} produced duplicate key '{}' during expansion", + macro_uid, ek + ), + arena.get(value).span, + )); + } else { + new_entries.push((ek, ev)); + } + } + } + ExpansionResult::Skipped => { + new_entries.push((key, value)); + } + } + } else { + let substituted_key = substitute_placeholders(&key, bindings); + expand_at(arena, value, bindings, depth + 1, diagnostics); + new_entries.push((substituted_key, value)); + } + } + arena.set_node(node_ref, Node::Map(new_entries)); + } + Node::List(items) => { + for item_ref in items { + expand_at(arena, item_ref, bindings, depth + 1, diagnostics); + } + } + // Intrinsics may carry NodeRef children that themselves can contain macros. + Node::Intrinsic(intrinsic) => { + for child in intrinsic_children(&intrinsic) { + expand_at(arena, child, bindings, depth + 1, diagnostics); + } + } + _ => {} + } +} + +enum ExpansionResult { + Expanded(Vec<(String, NodeRef)>), + Skipped, +} + +/// Expand a single `Fn::ForEach::: [iter_var, collection, body]` entry. +/// Returns the list of expanded sibling entries, or `Skipped` if the macro +/// shape is invalid or the collection cannot be statically resolved. +fn expand_foreach( + arena: &mut Arena, + key: &str, + value_ref: NodeRef, + outer_bindings: &Bindings, + depth: u32, + diagnostics: &mut Vec, +) -> ExpansionResult { + let span = arena.get(value_ref).span; + let macro_uid = key.strip_prefix(FOREACH_PREFIX).unwrap_or(""); + + let Some(triple) = arena.as_list(value_ref) else { + diagnostics.push(crate::make_parse_diagnostic( + RULE_FOREACH_NOT_EXPANDED, + format!("Fn::ForEach::{} value must be a 3-element array; macro left unexpanded", macro_uid), + span, + )); + return ExpansionResult::Skipped; + }; + if triple.len() != 3 { + diagnostics.push(crate::make_parse_diagnostic( + RULE_FOREACH_NOT_EXPANDED, + format!( + "Fn::ForEach::{} value must be a 3-element array (got {}); macro left unexpanded", + macro_uid, + triple.len() + ), + span, + )); + return ExpansionResult::Skipped; + } + + let iter_ref = triple[0]; + let collection_ref = triple[1]; + let body_ref = triple[2]; + + let Some(iter_var) = arena.as_str(iter_ref).map(str::to_owned) else { + diagnostics.push(crate::make_parse_diagnostic( + RULE_FOREACH_NOT_EXPANDED, + format!( + "Fn::ForEach::{} first argument must be a string identifier; macro left unexpanded", + macro_uid + ), + span, + )); + return ExpansionResult::Skipped; + }; + + let Some(collection_values) = literal_string_collection(arena, collection_ref) else { + diagnostics.push(crate::make_parse_diagnostic( + RULE_FOREACH_NOT_EXPANDED, + format!( + "Fn::ForEach::{} collection must be a literal list of strings to expand statically; macro left unexpanded", + macro_uid + ), + span, + )); + return ExpansionResult::Skipped; + }; + + let Some(body_entries) = arena.as_map(body_ref).map(<[(String, NodeRef)]>::to_vec) else { + diagnostics.push(crate::make_parse_diagnostic( + RULE_FOREACH_NOT_EXPANDED, + format!("Fn::ForEach::{} body must be a map; macro left unexpanded", macro_uid), + span, + )); + return ExpansionResult::Skipped; + }; + + let mut expanded: Vec<(String, NodeRef)> = Vec::with_capacity(body_entries.len() * collection_values.len()); + for value in &collection_values { + let mut bindings = outer_bindings.clone(); + bindings.insert(iter_var.clone(), value.clone()); + for (body_key, body_val) in &body_entries { + let new_key = substitute_placeholders(body_key, &bindings); + let new_val = clone_with_bindings(arena, *body_val, &bindings); + // Recursively expand any nested Fn::ForEach inside the freshly-cloned subtree. + expand_at(arena, new_val, &Bindings::new(), depth + 1, diagnostics); + expanded.push((new_key, new_val)); + } + } + + ExpansionResult::Expanded(expanded) +} + +/// Returns the literal list of scalar strings if `node_ref` is a List of +/// String nodes (the only collection shape we expand statically). Returns +/// `None` for dynamic collections that need resolver-time evaluation. +fn literal_string_collection(arena: &Arena, node_ref: NodeRef) -> Option> { + let items = arena.as_list(node_ref)?; + let mut out = Vec::with_capacity(items.len()); + for item_ref in items { + match arena.node(*item_ref) { + Node::String(s) => out.push(s.clone()), + Node::Int(n) => out.push(n.to_string()), + Node::Bool(b) => out.push(b.to_string()), + _ => return None, + } + } + Some(out) +} + +/// Allocate a deep copy of the subtree at `src_ref` with `bindings` applied to +/// every map key, `Fn::Sub` template, and `Ref` target whose name matches a +/// bound variable. NodeRef references inside intrinsic children are recursed. +fn clone_with_bindings(arena: &mut Arena, src_ref: NodeRef, bindings: &Bindings) -> NodeRef { + if !arena.is_valid(src_ref) { + return src_ref; + } + let src = arena.get(src_ref).clone(); + let new_node = match src.node { + Node::String(s) => Node::String(substitute_placeholders(&s, bindings)), + Node::Map(entries) => { + let mut new_entries = Vec::with_capacity(entries.len()); + for (k, v) in entries { + let new_key = substitute_placeholders(&k, bindings); + let new_val = clone_with_bindings(arena, v, bindings); + new_entries.push((new_key, new_val)); + } + // Detect the post-substitution `{ "Ref": }` shape that + // the parser would normally have folded into `IntrinsicFn::Ref`, + // and fold it now so condition_shape and the resolver see a + // proper Ref instead of a plain map. + promote_single_intrinsic_map(arena, new_entries) + } + Node::List(items) => { + let new_items = items.iter().map(|&i| clone_with_bindings(arena, i, bindings)).collect(); + Node::List(new_items) + } + Node::Intrinsic(intrinsic) => { + let new_intrinsic = clone_intrinsic(arena, intrinsic, bindings); + // Fold `Fn::Sub("literal", None)` (no remaining placeholders, no + // substitution map) into a plain string. CloudFormation evaluates + // this to the literal at deploy time, so downstream rules should + // see a string here, not an intrinsic. + if let IntrinsicFn::Sub(ref template, None) = new_intrinsic + && !template.contains("${") + { + Node::String(template.clone()) + } else { + Node::Intrinsic(new_intrinsic) + } + } + other => other, + }; + arena.alloc(SpannedNode { node: new_node, span: src.span, path: src.path }) +} + +/// Convert single-key maps that match an intrinsic function shape back into +/// `IntrinsicFn` nodes. The parser leaves these as plain maps when the original +/// value was dynamic (e.g. `{Ref: {Fn::Sub: "Param${X}"}}`); after substitution +/// the value may now be a plain string, at which point the map is equivalent +/// to the intrinsic the downstream rules expect. +fn promote_single_intrinsic_map(arena: &Arena, entries: Vec<(String, NodeRef)>) -> Node { + if entries.len() == 1 { + let (key, value) = &entries[0]; + if key == FN_REF + && let Some(target) = arena.as_str(*value) + { + return Node::Intrinsic(IntrinsicFn::Ref(target.to_string())); + } + } + Node::Map(entries) +} + +/// Recursively rebuild an `IntrinsicFn` with `bindings` applied. NodeRef +/// children are deep-copied via `clone_with_bindings`. Strings inside `Sub` +/// templates and bare `Ref` targets are substituted directly. +fn clone_intrinsic(arena: &mut Arena, intrinsic: IntrinsicFn, bindings: &Bindings) -> IntrinsicFn { + match intrinsic { + IntrinsicFn::Ref(target) => { + let substituted = substitute_placeholders(&target, bindings); + IntrinsicFn::Ref(substituted) + } + IntrinsicFn::GetAtt(resource, attr) => { + IntrinsicFn::GetAtt(substitute_placeholders(&resource, bindings), substitute_placeholders(&attr, bindings)) + } + IntrinsicFn::Sub(template, subs) => { + let new_template = substitute_placeholders(&template, bindings); + let new_subs = subs.map(|entries| { + entries + .into_iter() + .map(|(k, v)| (substitute_placeholders(&k, bindings), clone_with_bindings(arena, v, bindings))) + .collect() + }); + IntrinsicFn::Sub(new_template, new_subs) + } + IntrinsicFn::Join(delim, items) => { + IntrinsicFn::Join(clone_with_bindings(arena, delim, bindings), clone_with_bindings(arena, items, bindings)) + } + IntrinsicFn::Select(idx, list) => { + IntrinsicFn::Select(clone_with_bindings(arena, idx, bindings), clone_with_bindings(arena, list, bindings)) + } + IntrinsicFn::If(cond, t, f) => { + IntrinsicFn::If(cond, clone_with_bindings(arena, t, bindings), clone_with_bindings(arena, f, bindings)) + } + IntrinsicFn::IfExpr(c, t, f) => IntrinsicFn::IfExpr( + clone_with_bindings(arena, c, bindings), + clone_with_bindings(arena, t, bindings), + clone_with_bindings(arena, f, bindings), + ), + IntrinsicFn::FindInMap(a, b, c, d) => IntrinsicFn::FindInMap( + clone_with_bindings(arena, a, bindings), + clone_with_bindings(arena, b, bindings), + clone_with_bindings(arena, c, bindings), + d.map(|n| clone_with_bindings(arena, n, bindings)), + ), + IntrinsicFn::Split(delim, src) => IntrinsicFn::Split( + clone_with_bindings(arena, delim, bindings), + clone_with_bindings(arena, src, bindings), + ), + IntrinsicFn::Base64(c) => IntrinsicFn::Base64(clone_with_bindings(arena, c, bindings)), + IntrinsicFn::Cidr(a, b, c) => IntrinsicFn::Cidr( + clone_with_bindings(arena, a, bindings), + clone_with_bindings(arena, b, bindings), + clone_with_bindings(arena, c, bindings), + ), + IntrinsicFn::GetAZs(c) => IntrinsicFn::GetAZs(clone_with_bindings(arena, c, bindings)), + IntrinsicFn::ImportValue(c) => IntrinsicFn::ImportValue(clone_with_bindings(arena, c, bindings)), + IntrinsicFn::Transform(name, params) => IntrinsicFn::Transform( + name, + params.into_iter().map(|(k, v)| (k, clone_with_bindings(arena, v, bindings))).collect(), + ), + IntrinsicFn::And(items) => { + IntrinsicFn::And(items.into_iter().map(|i| clone_with_bindings(arena, i, bindings)).collect()) + } + IntrinsicFn::Or(items) => { + IntrinsicFn::Or(items.into_iter().map(|i| clone_with_bindings(arena, i, bindings)).collect()) + } + IntrinsicFn::Not(c) => IntrinsicFn::Not(clone_with_bindings(arena, c, bindings)), + IntrinsicFn::Equals(a, b) => { + IntrinsicFn::Equals(clone_with_bindings(arena, a, bindings), clone_with_bindings(arena, b, bindings)) + } + IntrinsicFn::ToJsonString(c) => IntrinsicFn::ToJsonString(clone_with_bindings(arena, c, bindings)), + IntrinsicFn::Length(c) => IntrinsicFn::Length(clone_with_bindings(arena, c, bindings)), + IntrinsicFn::ForEach(uid, var, coll, body) => IntrinsicFn::ForEach( + uid, + var, + clone_with_bindings(arena, coll, bindings), + clone_with_bindings(arena, body, bindings), + ), + IntrinsicFn::ValueOf(a, b) => IntrinsicFn::ValueOf(a, b), + IntrinsicFn::ValueOfAll(a, b) => IntrinsicFn::ValueOfAll(a, b), + IntrinsicFn::RefAll(s) => IntrinsicFn::RefAll(s), + IntrinsicFn::Contains(a, b) => { + IntrinsicFn::Contains(clone_with_bindings(arena, a, bindings), clone_with_bindings(arena, b, bindings)) + } + IntrinsicFn::EachMemberEquals(a, b) => IntrinsicFn::EachMemberEquals( + clone_with_bindings(arena, a, bindings), + clone_with_bindings(arena, b, bindings), + ), + IntrinsicFn::EachMemberIn(a, b) => { + IntrinsicFn::EachMemberIn(clone_with_bindings(arena, a, bindings), clone_with_bindings(arena, b, bindings)) + } + IntrinsicFn::GetStackOutput(c) => IntrinsicFn::GetStackOutput(clone_with_bindings(arena, c, bindings)), + } +} + +/// Replace every `${name}` occurrence in `s` with the string bound to `name`. +/// Variables not in `bindings` are left intact (so `${AWS::Region}` and other +/// unrelated placeholders survive). +fn substitute_placeholders(s: &str, bindings: &Bindings) -> String { + if bindings.is_empty() || !s.contains("${") { + return s.to_owned(); + } + let mut out = String::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' { + // Preserve the `${!literal}` escape — CloudFormation removes the `!` + // server-side, but our caller treats this as a literal pass-through. + if i + 2 < bytes.len() && bytes[i + 2] == b'!' { + out.push_str("${!"); + i += 3; + continue; + } + let var_start = i + 2; + if let Some(end_offset) = s[var_start..].find('}') { + let var = s[var_start..var_start + end_offset].trim(); + if let Some(replacement) = bindings.get(var) { + out.push_str(replacement); + i = var_start + end_offset + 1; + continue; + } + // Unknown placeholder — leave as-is so downstream Fn::Sub + // handling still resolves it correctly. + out.push_str(&s[i..var_start + end_offset + 1]); + i = var_start + end_offset + 1; + continue; + } + } + out.push(bytes[i] as char); + i += 1; + } + out +} + +/// All NodeRef children carried inside an intrinsic (used to walk into nested +/// macros). Variants whose payload is purely scalar (Ref, GetAtt, ValueOf, ...) +/// have no children and contribute nothing. +fn intrinsic_children(intrinsic: &IntrinsicFn) -> Vec { + match intrinsic { + IntrinsicFn::Ref(_) | IntrinsicFn::GetAtt(_, _) => Vec::new(), + IntrinsicFn::Sub(_, subs) => subs.as_ref().map(|e| e.iter().map(|(_, v)| *v).collect()).unwrap_or_default(), + IntrinsicFn::Join(a, b) | IntrinsicFn::Select(a, b) | IntrinsicFn::Split(a, b) | IntrinsicFn::Equals(a, b) => { + vec![*a, *b] + } + IntrinsicFn::If(_, a, b) => vec![*a, *b], + IntrinsicFn::IfExpr(c, a, b) => vec![*c, *a, *b], + IntrinsicFn::FindInMap(a, b, c, d) => { + let mut v = vec![*a, *b, *c]; + if let Some(x) = d { + v.push(*x); + } + v + } + IntrinsicFn::Base64(c) + | IntrinsicFn::GetAZs(c) + | IntrinsicFn::ImportValue(c) + | IntrinsicFn::Not(c) + | IntrinsicFn::ToJsonString(c) + | IntrinsicFn::Length(c) + | IntrinsicFn::GetStackOutput(c) => vec![*c], + IntrinsicFn::Cidr(a, b, c) => vec![*a, *b, *c], + IntrinsicFn::Transform(_, params) => params.iter().map(|(_, v)| *v).collect(), + IntrinsicFn::And(items) | IntrinsicFn::Or(items) => items.clone(), + IntrinsicFn::ForEach(_, _, c, b) => vec![*c, *b], + IntrinsicFn::Contains(a, b) | IntrinsicFn::EachMemberEquals(a, b) | IntrinsicFn::EachMemberIn(a, b) => { + vec![*a, *b] + } + IntrinsicFn::ValueOf(_, _) | IntrinsicFn::ValueOfAll(_, _) | IntrinsicFn::RefAll(_) => Vec::new(), + } +} + +// Re-export for downstream consumers that want to know the constants without +// taking an extra dependency on the consts module via `cfn_function_name` etc. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn substitute_simple() { + let mut bindings = Bindings::new(); + bindings.insert("Identifier".into(), "A".into()); + assert_eq!(substitute_placeholders("Param${Identifier}", &bindings), "ParamA"); + assert_eq!(substitute_placeholders("IsParam${Identifier}Enabled", &bindings), "IsParamAEnabled"); + } + + #[test] + fn substitute_preserves_unknown_placeholders() { + let mut bindings = Bindings::new(); + bindings.insert("Identifier".into(), "A".into()); + assert_eq!(substitute_placeholders("${AWS::Region}-${Identifier}", &bindings), "${AWS::Region}-A"); + } + + #[test] + fn substitute_preserves_literal_escape() { + let bindings = Bindings::new(); + assert_eq!(substitute_placeholders("${!literal}", &bindings), "${!literal}"); + } + + #[test] + fn no_op_when_transform_absent() { + let input = r#"{ + "Conditions": { + "Fn::ForEach::Run": ["X", ["a","b"], {"C${X}": {"Fn::Equals":[{"Ref":"P"},"x"]}}] + }, + "Resources": {"R": {"Type": "T"}} + }"#; + let mut ir = crate::parser::parse(input.as_bytes()).expect("parse"); + let diags = expand_language_extensions(&mut ir); + assert!(diags.is_empty()); + // ForEach key should still be present unchanged. + let conditions = ir.arena.as_map(ir.conditions).expect("conditions map"); + assert!(conditions.iter().any(|(k, _)| k.starts_with(FOREACH_PREFIX))); + } + + #[test] + fn expands_literal_collection_into_sibling_keys() { + let input = r#"{ + "Transform": "AWS::LanguageExtensions", + "Parameters": { + "ParamA": {"Type": "String"}, + "ParamB": {"Type": "String"} + }, + "Conditions": { + "Fn::ForEach::Run": [ + "Identifier", + ["A", "B"], + { + "Is${Identifier}Enabled": { + "Fn::Equals": [{"Ref": {"Fn::Sub": "Param${Identifier}"}}, "true"] + } + } + ] + }, + "Resources": {"R": {"Type": "T"}} + }"#; + let mut ir = crate::parser::parse(input.as_bytes()).expect("parse"); + let diags = expand_language_extensions(&mut ir); + assert!(diags.is_empty(), "unexpected diagnostics: {:?}", diags); + + let conditions = ir.arena.as_map(ir.conditions).expect("conditions map"); + let condition_keys: Vec<_> = conditions.iter().map(|(k, _)| k.clone()).collect(); + assert!(condition_keys.contains(&"IsAEnabled".to_string()), "got {:?}", condition_keys); + assert!(condition_keys.contains(&"IsBEnabled".to_string()), "got {:?}", condition_keys); + assert!( + !condition_keys.iter().any(|k| k.starts_with(FOREACH_PREFIX)), + "ForEach key should be removed after expansion, got {:?}", + condition_keys + ); + } + + #[test] + fn dynamic_collection_emits_diagnostic_and_skips() { + let input = r#"{ + "Transform": "AWS::LanguageExtensions", + "Parameters": {"P": {"Type": "CommaDelimitedList"}}, + "Conditions": { + "Fn::ForEach::Run": ["X", {"Ref": "P"}, {"C${X}": {"Fn::Equals":[{"Ref":"P"},"x"]}}] + }, + "Resources": {"R": {"Type": "T"}} + }"#; + let mut ir = crate::parser::parse(input.as_bytes()).expect("parse"); + let diags = expand_language_extensions(&mut ir); + assert_eq!(diags.len(), 1, "expected one diagnostic for unexpandable macro, got {:?}", diags); + assert_eq!(diags[0].rule_id, RULE_FOREACH_NOT_EXPANDED); + } +} + +// Suppress unused-import warning for FN_REF / FN_SUB which guard reads on +// future expansion paths. +#[allow(dead_code)] +const _: &str = FN_REF; +#[allow(dead_code)] +const _: &str = FN_SUB; diff --git a/src/template-model/tests/integration.rs b/src/template-model/tests/integration.rs index f2b740e..3980454 100644 --- a/src/template-model/tests/integration.rs +++ b/src/template-model/tests/integration.rs @@ -26,7 +26,7 @@ fn model_rego_input_valid_json() { #[test] fn fixture_both_intrinsic_forms() { - let model = model_from_fixture("good/both_forms.yaml"); + let model = model_from_fixture("bad/W9053_equivalent_both_forms.yaml"); assert_eq!(model.resources.len(), 13); @@ -284,10 +284,10 @@ fn parser_minimal_template_no_properties() { } #[test] -fn parser_fn_if_undefined_condition_produces_f1104() { +fn parser_fn_if_undefined_condition_produces_e1028() { let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::If":["NonExistent",1,2]}}}}}"#; let model = SemanticModel::from_bytes(input.as_bytes()).unwrap(); - assert!(model.diagnostics.iter().any(|d| d.rule_id == "F1104" && d.message.contains("NonExistent"))); + assert!(model.diagnostics.iter().any(|d| d.rule_id == "E1028" && d.message.contains("NonExistent"))); } #[test] @@ -517,6 +517,9 @@ fn verify_diagnostic_json_contract() { "forEachExpansions", "unsubstitutedVariables", "invalidRefs", + "splitDynamicRefDelimiters", + "unusedSubKeys", + "base64DisallowedFunctions", ] { assert!(r.contains_key(*key), "Resource missing key: {}", key); } diff --git a/src/template-model/tests/sample_templates.rs b/src/template-model/tests/sample_templates.rs index 706f333..c73667e 100644 --- a/src/template-model/tests/sample_templates.rs +++ b/src/template-model/tests/sample_templates.rs @@ -130,7 +130,8 @@ fn mappings_extraction_and_lookup() { #[test] fn conditions_nested_short_form_tags() { let m = load("lsp/comprehensive.yaml"); - assert_eq!(m.conditions.conditions.len(), 6); + let user_conditions = m.conditions.conditions.keys().filter(|k| !k.starts_with("__")).count(); + assert_eq!(user_conditions, 6); // These use nested !Or [!Condition ..., !Equals [...]] etc. assert!(m.conditions.conditions.contains_key("IsProductionOrStaging"), "missing condition IsProductionOrStaging"); assert!(m.conditions.conditions.contains_key("ComplexCondition"), "missing condition ComplexCondition"); diff --git a/src/validation-engine/src/engine.rs b/src/validation-engine/src/engine.rs index c2208dc..9b30390 100644 --- a/src/validation-engine/src/engine.rs +++ b/src/validation-engine/src/engine.rs @@ -707,7 +707,7 @@ pub(crate) fn build_context( "I9001" => { lifecycle = Some("create-only".into()); } - "W3041" => { + "W9054" => { lifecycle = Some("write-only".into()); } "W2503" | "W2502" => { @@ -1645,9 +1645,9 @@ Resources: } #[test] - fn build_context_w3041_sets_write_only_lifecycle() { + fn build_context_w9054_sets_write_only_lifecycle() { let model = minimal_model(); - let ctx = build_context("W3041", Some("Bucket"), "", &model).expect("W3041 should return context"); + let ctx = build_context("W9054", Some("Bucket"), "", &model).expect("W9054 should return context"); assert_eq!(ctx.lifecycle.as_deref(), Some("write-only")); }