Skip to content

Commit ebb9bc9

Browse files
jsoucheironclaude
andcommitted
Add strict validation for CloudFormation intrinsic functions
This adds comprehensive validation for all CloudFormation intrinsic functions when parsing templates. The FunctionDict class now validates the format of each intrinsic function according to AWS documentation. Validated functions: - Ref: must be a non-empty string - Fn::Sub: string or [string, dict] format - Fn::GetAtt: [resource, attr] list or "resource.attr" string - Fn::Join: [delimiter, list] with string delimiter - Fn::Select: [index, list] format - Fn::Split: [delimiter, string] with string delimiter - Fn::If: [condition, true_value, false_value] - exactly 3 elements - Fn::And/Fn::Or: list of 2-10 conditions - Fn::Not: list with exactly 1 condition - Fn::Equals: list with exactly 2 values - Fn::FindInMap: [map, key1, key2] - exactly 3 elements - Fn::Base64: string or function - Fn::GetAZs: string or function - Fn::ImportValue: string or function - Condition: non-empty string This helps detect malformed CloudFormation templates at parse time rather than at deployment time. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ef21987 commit ebb9bc9

File tree

3 files changed

+738
-0
lines changed

3 files changed

+738
-0
lines changed

pycfmodel/model/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pydantic import BaseModel, ConfigDict, model_validator
22

3+
from pycfmodel.model.intrinsic_functions import validate_intrinsic_function
34
from pycfmodel.utils import is_resolvable_dict
45

56

@@ -15,4 +16,10 @@ class FunctionDict(BaseModel):
1516
def check_if_valid_function(cls, values):
1617
if not is_resolvable_dict(values):
1718
raise ValueError("FunctionDict should only have 1 key and be a function")
19+
20+
# Validate the intrinsic function format
21+
is_valid, error_message = validate_intrinsic_function(values)
22+
if not is_valid:
23+
raise ValueError(f"Invalid intrinsic function: {error_message}")
24+
1825
return values
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
"""
2+
Validators for CloudFormation intrinsic functions.
3+
4+
Based on AWS documentation:
5+
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html
6+
"""
7+
8+
from typing import Any, Dict, Optional, Tuple
9+
10+
11+
def _is_valid_function_or_string(value: Any) -> bool:
12+
"""Check if value is a string or a valid intrinsic function dict."""
13+
if isinstance(value, str):
14+
return True
15+
if isinstance(value, dict) and len(value) == 1:
16+
key = next(iter(value))
17+
return key in FUNCTION_VALIDATORS or key == "Condition"
18+
return False
19+
20+
21+
def _is_valid_function_or_value(value: Any) -> bool:
22+
"""Check if value is any valid value or a valid intrinsic function dict."""
23+
if value is None:
24+
return True
25+
if isinstance(value, (str, int, float, bool, list)):
26+
return True
27+
if isinstance(value, dict):
28+
if len(value) == 1:
29+
key = next(iter(value))
30+
return key in FUNCTION_VALIDATORS or key == "Condition"
31+
# Could be a regular dict/object
32+
return True
33+
return False
34+
35+
36+
def validate_ref(value: Any) -> Tuple[bool, Optional[str]]:
37+
"""
38+
Validate Ref intrinsic function.
39+
40+
Format: {"Ref": "logicalName"}
41+
42+
The value must be a string (logical name of a resource, parameter, or pseudo parameter).
43+
"""
44+
if not isinstance(value, str):
45+
return False, f"Ref value must be a string, got {type(value).__name__}"
46+
if not value:
47+
return False, "Ref value cannot be empty"
48+
return True, None
49+
50+
51+
def validate_fn_base64(value: Any) -> Tuple[bool, Optional[str]]:
52+
"""
53+
Validate Fn::Base64 intrinsic function.
54+
55+
Format: {"Fn::Base64": "valueToEncode"}
56+
{"Fn::Base64": {"Ref": "..."}}
57+
58+
The value must be a string or a function that resolves to a string.
59+
"""
60+
if not _is_valid_function_or_string(value):
61+
return False, f"Fn::Base64 value must be a string or function, got {type(value).__name__}"
62+
return True, None
63+
64+
65+
def validate_fn_and(value: Any) -> Tuple[bool, Optional[str]]:
66+
"""
67+
Validate Fn::And intrinsic function.
68+
69+
Format: {"Fn::And": [condition1, condition2, ...]}
70+
71+
Must be a list of 2-10 conditions.
72+
"""
73+
if not isinstance(value, list):
74+
return False, f"Fn::And value must be a list, got {type(value).__name__}"
75+
if len(value) < 2 or len(value) > 10:
76+
return False, f"Fn::And requires 2-10 conditions, got {len(value)}"
77+
return True, None
78+
79+
80+
def validate_fn_equals(value: Any) -> Tuple[bool, Optional[str]]:
81+
"""
82+
Validate Fn::Equals intrinsic function.
83+
84+
Format: {"Fn::Equals": [value1, value2]}
85+
86+
Must be a list of exactly 2 values.
87+
"""
88+
if not isinstance(value, list):
89+
return False, f"Fn::Equals value must be a list, got {type(value).__name__}"
90+
if len(value) != 2:
91+
return False, f"Fn::Equals requires exactly 2 values, got {len(value)}"
92+
return True, None
93+
94+
95+
def validate_fn_if(value: Any) -> Tuple[bool, Optional[str]]:
96+
"""
97+
Validate Fn::If intrinsic function.
98+
99+
Format: {"Fn::If": [condition_name, value_if_true, value_if_false]}
100+
101+
Must be a list of exactly 3 elements.
102+
"""
103+
if not isinstance(value, list):
104+
return False, f"Fn::If value must be a list, got {type(value).__name__}"
105+
if len(value) != 3:
106+
return False, f"Fn::If requires exactly 3 values (condition, true_value, false_value), got {len(value)}"
107+
return True, None
108+
109+
110+
def validate_fn_not(value: Any) -> Tuple[bool, Optional[str]]:
111+
"""
112+
Validate Fn::Not intrinsic function.
113+
114+
Format: {"Fn::Not": [condition]}
115+
116+
Must be a list with exactly 1 condition.
117+
"""
118+
if not isinstance(value, list):
119+
return False, f"Fn::Not value must be a list, got {type(value).__name__}"
120+
if len(value) != 1:
121+
return False, f"Fn::Not requires exactly 1 condition, got {len(value)}"
122+
return True, None
123+
124+
125+
def validate_fn_or(value: Any) -> Tuple[bool, Optional[str]]:
126+
"""
127+
Validate Fn::Or intrinsic function.
128+
129+
Format: {"Fn::Or": [condition1, condition2, ...]}
130+
131+
Must be a list of 2-10 conditions.
132+
"""
133+
if not isinstance(value, list):
134+
return False, f"Fn::Or value must be a list, got {type(value).__name__}"
135+
if len(value) < 2 or len(value) > 10:
136+
return False, f"Fn::Or requires 2-10 conditions, got {len(value)}"
137+
return True, None
138+
139+
140+
def validate_fn_find_in_map(value: Any) -> Tuple[bool, Optional[str]]:
141+
"""
142+
Validate Fn::FindInMap intrinsic function.
143+
144+
Format: {"Fn::FindInMap": [MapName, TopLevelKey, SecondLevelKey]}
145+
146+
Must be a list of exactly 3 elements.
147+
"""
148+
if not isinstance(value, list):
149+
return False, f"Fn::FindInMap value must be a list, got {type(value).__name__}"
150+
if len(value) != 3:
151+
return False, f"Fn::FindInMap requires exactly 3 values (map, key1, key2), got {len(value)}"
152+
return True, None
153+
154+
155+
def validate_fn_get_att(value: Any) -> Tuple[bool, Optional[str]]:
156+
"""
157+
Validate Fn::GetAtt intrinsic function.
158+
159+
Format: {"Fn::GetAtt": [logicalNameOfResource, attributeName]}
160+
{"Fn::GetAtt": "logicalNameOfResource.attributeName"}
161+
162+
Can be a list of 2 strings or a dot-separated string.
163+
"""
164+
if isinstance(value, str):
165+
if "." not in value:
166+
return False, "Fn::GetAtt string format must contain a dot (resource.attribute)"
167+
return True, None
168+
if isinstance(value, list):
169+
if len(value) != 2:
170+
return False, f"Fn::GetAtt list must have exactly 2 elements, got {len(value)}"
171+
return True, None
172+
return False, f"Fn::GetAtt value must be a string or list, got {type(value).__name__}"
173+
174+
175+
def validate_fn_get_azs(value: Any) -> Tuple[bool, Optional[str]]:
176+
"""
177+
Validate Fn::GetAZs intrinsic function.
178+
179+
Format: {"Fn::GetAZs": "region"}
180+
{"Fn::GetAZs": {"Ref": "AWS::Region"}}
181+
{"Fn::GetAZs": ""} (empty string = current region)
182+
183+
Value must be a string (region name or empty) or a function.
184+
"""
185+
if isinstance(value, str):
186+
return True, None
187+
if isinstance(value, dict) and len(value) == 1:
188+
return True, None
189+
return False, f"Fn::GetAZs value must be a string or function, got {type(value).__name__}"
190+
191+
192+
def validate_fn_import_value(value: Any) -> Tuple[bool, Optional[str]]:
193+
"""
194+
Validate Fn::ImportValue intrinsic function.
195+
196+
Format: {"Fn::ImportValue": "sharedValueToImport"}
197+
{"Fn::ImportValue": {"Fn::Sub": "..."}}
198+
199+
Value must be a string or a function that resolves to a string.
200+
"""
201+
if not _is_valid_function_or_string(value):
202+
return False, f"Fn::ImportValue value must be a string or function, got {type(value).__name__}"
203+
return True, None
204+
205+
206+
def validate_fn_join(value: Any) -> Tuple[bool, Optional[str]]:
207+
"""
208+
Validate Fn::Join intrinsic function.
209+
210+
Format: {"Fn::Join": ["delimiter", [list, of, values]]}
211+
212+
Must be a list of exactly 2 elements: delimiter (string) and list of values.
213+
"""
214+
if not isinstance(value, list):
215+
return False, f"Fn::Join value must be a list, got {type(value).__name__}"
216+
if len(value) != 2:
217+
return False, f"Fn::Join requires exactly 2 values (delimiter, list), got {len(value)}"
218+
delimiter, values_list = value
219+
if not isinstance(delimiter, str):
220+
return False, f"Fn::Join delimiter must be a string, got {type(delimiter).__name__}"
221+
if not isinstance(values_list, list) and not isinstance(values_list, dict):
222+
return False, f"Fn::Join second argument must be a list or function, got {type(values_list).__name__}"
223+
return True, None
224+
225+
226+
def validate_fn_select(value: Any) -> Tuple[bool, Optional[str]]:
227+
"""
228+
Validate Fn::Select intrinsic function.
229+
230+
Format: {"Fn::Select": [index, listOfObjects]}
231+
232+
Must be a list of exactly 2 elements: index (string/int or function) and list.
233+
"""
234+
if not isinstance(value, list):
235+
return False, f"Fn::Select value must be a list, got {type(value).__name__}"
236+
if len(value) != 2:
237+
return False, f"Fn::Select requires exactly 2 values (index, list), got {len(value)}"
238+
return True, None
239+
240+
241+
def validate_fn_split(value: Any) -> Tuple[bool, Optional[str]]:
242+
"""
243+
Validate Fn::Split intrinsic function.
244+
245+
Format: {"Fn::Split": ["delimiter", "source string"]}
246+
247+
Must be a list of exactly 2 elements.
248+
"""
249+
if not isinstance(value, list):
250+
return False, f"Fn::Split value must be a list, got {type(value).__name__}"
251+
if len(value) != 2:
252+
return False, f"Fn::Split requires exactly 2 values (delimiter, string), got {len(value)}"
253+
delimiter = value[0]
254+
if not isinstance(delimiter, str):
255+
return False, f"Fn::Split delimiter must be a string, got {type(delimiter).__name__}"
256+
return True, None
257+
258+
259+
def validate_fn_sub(value: Any) -> Tuple[bool, Optional[str]]:
260+
"""
261+
Validate Fn::Sub intrinsic function.
262+
263+
Format: {"Fn::Sub": "string with ${variables}"}
264+
{"Fn::Sub": ["string with ${variables}", {var: value, ...}]}
265+
266+
Can be a string or a list of [string, dict].
267+
"""
268+
if isinstance(value, str):
269+
return True, None
270+
if isinstance(value, list):
271+
if len(value) != 2:
272+
return False, f"Fn::Sub list format requires exactly 2 values (string, vars), got {len(value)}"
273+
template_string, variables = value
274+
if not isinstance(template_string, str):
275+
return False, f"Fn::Sub template must be a string, got {type(template_string).__name__}"
276+
if not isinstance(variables, dict):
277+
return False, f"Fn::Sub variables must be a dict, got {type(variables).__name__}"
278+
return True, None
279+
return False, f"Fn::Sub value must be a string or list, got {type(value).__name__}"
280+
281+
282+
def validate_condition(value: Any) -> Tuple[bool, Optional[str]]:
283+
"""
284+
Validate Condition intrinsic function.
285+
286+
Format: {"Condition": "conditionName"}
287+
288+
Value must be a string (name of a condition).
289+
"""
290+
if not isinstance(value, str):
291+
return False, f"Condition value must be a string, got {type(value).__name__}"
292+
if not value:
293+
return False, "Condition value cannot be empty"
294+
return True, None
295+
296+
297+
# Mapping of function names to their validators
298+
FUNCTION_VALIDATORS: Dict[str, Any] = {
299+
"Ref": validate_ref,
300+
"Fn::Base64": validate_fn_base64,
301+
"Fn::And": validate_fn_and,
302+
"Fn::Equals": validate_fn_equals,
303+
"Fn::If": validate_fn_if,
304+
"Fn::Not": validate_fn_not,
305+
"Fn::Or": validate_fn_or,
306+
"Fn::FindInMap": validate_fn_find_in_map,
307+
"Fn::GetAtt": validate_fn_get_att,
308+
"Fn::GetAZs": validate_fn_get_azs,
309+
"Fn::ImportValue": validate_fn_import_value,
310+
"Fn::Join": validate_fn_join,
311+
"Fn::Select": validate_fn_select,
312+
"Fn::Split": validate_fn_split,
313+
"Fn::Sub": validate_fn_sub,
314+
"Condition": validate_condition,
315+
}
316+
317+
318+
def validate_intrinsic_function(function_dict: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
319+
"""
320+
Validate a CloudFormation intrinsic function dictionary.
321+
322+
Args:
323+
function_dict: A dictionary with exactly one key (the function name)
324+
325+
Returns:
326+
Tuple of (is_valid, error_message)
327+
"""
328+
if not isinstance(function_dict, dict):
329+
return False, f"Expected dict, got {type(function_dict).__name__}"
330+
331+
if len(function_dict) != 1:
332+
return False, f"Intrinsic function must have exactly 1 key, got {len(function_dict)}"
333+
334+
function_name = next(iter(function_dict))
335+
function_value = function_dict[function_name]
336+
337+
if function_name not in FUNCTION_VALIDATORS:
338+
return False, f"Unknown intrinsic function: {function_name}"
339+
340+
validator = FUNCTION_VALIDATORS[function_name]
341+
return validator(function_value)

0 commit comments

Comments
 (0)