stackfix 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudgym/__init__.py +3 -0
- cloudgym/benchmark/__init__.py +0 -0
- cloudgym/benchmark/dataset.py +188 -0
- cloudgym/benchmark/evaluator.py +275 -0
- cloudgym/cli.py +61 -0
- cloudgym/fixer/__init__.py +1 -0
- cloudgym/fixer/cli.py +521 -0
- cloudgym/fixer/detector.py +81 -0
- cloudgym/fixer/formatter.py +55 -0
- cloudgym/fixer/lambda_handler.py +126 -0
- cloudgym/fixer/repairer.py +237 -0
- cloudgym/generator/__init__.py +0 -0
- cloudgym/generator/formatter.py +142 -0
- cloudgym/generator/pipeline.py +271 -0
- cloudgym/inverter/__init__.py +0 -0
- cloudgym/inverter/_cf_injectors.py +705 -0
- cloudgym/inverter/_cf_utils.py +202 -0
- cloudgym/inverter/_hcl_utils.py +182 -0
- cloudgym/inverter/_tf_injectors.py +641 -0
- cloudgym/inverter/_yaml_cf.py +84 -0
- cloudgym/inverter/agentic.py +90 -0
- cloudgym/inverter/engine.py +258 -0
- cloudgym/inverter/programmatic.py +95 -0
- cloudgym/scraper/__init__.py +0 -0
- cloudgym/scraper/aws_samples.py +159 -0
- cloudgym/scraper/github.py +238 -0
- cloudgym/scraper/registry.py +165 -0
- cloudgym/scraper/validator.py +116 -0
- cloudgym/taxonomy/__init__.py +10 -0
- cloudgym/taxonomy/base.py +102 -0
- cloudgym/taxonomy/cloudformation.py +258 -0
- cloudgym/taxonomy/terraform.py +274 -0
- cloudgym/utils/__init__.py +0 -0
- cloudgym/utils/config.py +57 -0
- cloudgym/utils/ollama.py +66 -0
- cloudgym/validator/__init__.py +0 -0
- cloudgym/validator/cloudformation.py +55 -0
- cloudgym/validator/opentofu.py +103 -0
- cloudgym/validator/terraform.py +115 -0
- stackfix-0.1.0.dist-info/METADATA +182 -0
- stackfix-0.1.0.dist-info/RECORD +44 -0
- stackfix-0.1.0.dist-info/WHEEL +4 -0
- stackfix-0.1.0.dist-info/entry_points.txt +3 -0
- stackfix-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""CloudFormation fault injector functions — one per fault type.
|
|
2
|
+
|
|
3
|
+
Each injector takes (text, parsed_dict) and returns (modified_text, FaultInjection) or None.
|
|
4
|
+
CF faults use dict mutation via yaml/json (fully round-trippable), except for
|
|
5
|
+
syntactic faults which do raw text corruption.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from cloudgym.inverter._yaml_cf import cf_dump, cf_load
|
|
17
|
+
|
|
18
|
+
from cloudgym.inverter._cf_utils import (
|
|
19
|
+
REQUIRED_PROPERTIES,
|
|
20
|
+
RESOURCE_TYPE_TYPOS,
|
|
21
|
+
find_getatt,
|
|
22
|
+
find_ifs,
|
|
23
|
+
find_joins,
|
|
24
|
+
find_refs,
|
|
25
|
+
find_selects,
|
|
26
|
+
find_subs,
|
|
27
|
+
get_condition_names,
|
|
28
|
+
get_parameter_names,
|
|
29
|
+
get_resource_logical_ids,
|
|
30
|
+
get_resource_type,
|
|
31
|
+
)
|
|
32
|
+
from cloudgym.taxonomy.base import FaultInjection
|
|
33
|
+
|
|
34
|
+
# Registry mapping fault IDs to injector functions
|
|
35
|
+
CF_INJECTOR_REGISTRY: dict[str, Any] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_cf_injector(fault_id: str):
|
|
39
|
+
"""Decorator to register a CF injector function."""
|
|
40
|
+
def decorator(fn):
|
|
41
|
+
CF_INJECTOR_REGISTRY[fault_id] = fn
|
|
42
|
+
return fn
|
|
43
|
+
return decorator
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_json(text: str) -> bool:
|
|
47
|
+
"""Check if the template is JSON format."""
|
|
48
|
+
stripped = text.strip()
|
|
49
|
+
return stripped.startswith('{')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _dump(template: dict, is_json: bool) -> str:
|
|
53
|
+
"""Serialize template back to text."""
|
|
54
|
+
if is_json:
|
|
55
|
+
return json.dumps(template, indent=2)
|
|
56
|
+
return cf_dump(template)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# SYNTACTIC injectors (raw text manipulation)
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
@register_cf_injector("SYNTACTIC.invalid_yaml")
|
|
64
|
+
def inject_invalid_yaml(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
65
|
+
"""Introduce invalid YAML syntax."""
|
|
66
|
+
if _is_json(text):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
lines = text.split('\n')
|
|
70
|
+
# Find a non-empty, non-comment line and insert a tab character
|
|
71
|
+
candidates = [
|
|
72
|
+
i for i, line in enumerate(lines)
|
|
73
|
+
if line.strip() and not line.strip().startswith('#') and ':' in line
|
|
74
|
+
]
|
|
75
|
+
if not candidates:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
target_line = random.choice(candidates)
|
|
79
|
+
original_line = lines[target_line]
|
|
80
|
+
# Insert a tab at the beginning (YAML forbids tabs for indentation)
|
|
81
|
+
lines[target_line] = '\t' + original_line.lstrip()
|
|
82
|
+
modified = '\n'.join(lines)
|
|
83
|
+
|
|
84
|
+
return modified, FaultInjection(
|
|
85
|
+
fault_type=None,
|
|
86
|
+
original_snippet=original_line.strip(),
|
|
87
|
+
modified_snippet=lines[target_line].strip(),
|
|
88
|
+
location=f"line {target_line + 1}",
|
|
89
|
+
description="Inserted tab character in YAML indentation",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@register_cf_injector("SYNTACTIC.invalid_json_template")
|
|
94
|
+
def inject_invalid_json_template(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
95
|
+
"""For JSON templates: add trailing comma."""
|
|
96
|
+
if not _is_json(text):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
# Find a line with }, or ] and add a trailing comma before it
|
|
100
|
+
lines = text.split('\n')
|
|
101
|
+
candidates = [
|
|
102
|
+
i for i, line in enumerate(lines)
|
|
103
|
+
if line.rstrip().endswith('}') or line.rstrip().endswith('"')
|
|
104
|
+
]
|
|
105
|
+
if not candidates:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
target_line = random.choice(candidates)
|
|
109
|
+
original_line = lines[target_line]
|
|
110
|
+
# Add a trailing comma after the value
|
|
111
|
+
lines[target_line] = original_line.rstrip() + ','
|
|
112
|
+
# Also insert after a closing brace line that's followed by another closing brace
|
|
113
|
+
modified = '\n'.join(lines)
|
|
114
|
+
|
|
115
|
+
return modified, FaultInjection(
|
|
116
|
+
fault_type=None,
|
|
117
|
+
original_snippet=original_line.strip(),
|
|
118
|
+
modified_snippet=lines[target_line].strip(),
|
|
119
|
+
location=f"line {target_line + 1}",
|
|
120
|
+
description="Added trailing comma to create invalid JSON",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@register_cf_injector("SYNTACTIC.wrong_property_type")
|
|
125
|
+
def inject_wrong_property_type(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
126
|
+
"""Set a property to the wrong type (e.g., string where list expected)."""
|
|
127
|
+
resources = parsed.get("Resources", {})
|
|
128
|
+
if not resources:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
is_json = _is_json(text)
|
|
132
|
+
template = copy.deepcopy(parsed)
|
|
133
|
+
|
|
134
|
+
for logical_id, resource in template.get("Resources", {}).items():
|
|
135
|
+
props = resource.get("Properties", {})
|
|
136
|
+
for prop_name, prop_value in props.items():
|
|
137
|
+
if isinstance(prop_value, list):
|
|
138
|
+
# Change list to string
|
|
139
|
+
original = prop_value
|
|
140
|
+
template["Resources"][logical_id]["Properties"][prop_name] = "not-a-list"
|
|
141
|
+
modified = _dump(template, is_json)
|
|
142
|
+
return modified, FaultInjection(
|
|
143
|
+
fault_type=None,
|
|
144
|
+
original_snippet=f"{prop_name}: {str(original)[:60]}",
|
|
145
|
+
modified_snippet=f'{prop_name}: "not-a-list"',
|
|
146
|
+
location=f"Resources.{logical_id}.Properties.{prop_name}",
|
|
147
|
+
description=f"Changed list property {prop_name} to string",
|
|
148
|
+
)
|
|
149
|
+
if isinstance(prop_value, dict) and not any(
|
|
150
|
+
k.startswith("Fn::") or k in ("Ref",) for k in prop_value
|
|
151
|
+
):
|
|
152
|
+
# Change dict to string
|
|
153
|
+
original = prop_value
|
|
154
|
+
template["Resources"][logical_id]["Properties"][prop_name] = "not-a-dict"
|
|
155
|
+
modified = _dump(template, is_json)
|
|
156
|
+
return modified, FaultInjection(
|
|
157
|
+
fault_type=None,
|
|
158
|
+
original_snippet=f"{prop_name}: {str(original)[:60]}",
|
|
159
|
+
modified_snippet=f'{prop_name}: "not-a-dict"',
|
|
160
|
+
location=f"Resources.{logical_id}.Properties.{prop_name}",
|
|
161
|
+
description=f"Changed dict property {prop_name} to string",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@register_cf_injector("SYNTACTIC.missing_required_property")
|
|
168
|
+
def inject_missing_required_property(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
169
|
+
"""Remove a required property from a resource."""
|
|
170
|
+
is_json = _is_json(text)
|
|
171
|
+
template = copy.deepcopy(parsed)
|
|
172
|
+
resources = template.get("Resources", {})
|
|
173
|
+
|
|
174
|
+
for logical_id, resource in resources.items():
|
|
175
|
+
res_type = resource.get("Type", "")
|
|
176
|
+
required = REQUIRED_PROPERTIES.get(res_type, [])
|
|
177
|
+
props = resource.get("Properties", {})
|
|
178
|
+
|
|
179
|
+
for req_prop in required:
|
|
180
|
+
if req_prop in props:
|
|
181
|
+
original_val = props[req_prop]
|
|
182
|
+
del template["Resources"][logical_id]["Properties"][req_prop]
|
|
183
|
+
modified = _dump(template, is_json)
|
|
184
|
+
return modified, FaultInjection(
|
|
185
|
+
fault_type=None,
|
|
186
|
+
original_snippet=f"{req_prop}: {str(original_val)[:60]}",
|
|
187
|
+
modified_snippet="(property removed)",
|
|
188
|
+
location=f"Resources.{logical_id}.Properties.{req_prop}",
|
|
189
|
+
description=f"Removed required property '{req_prop}' from {res_type}",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Fallback: remove any property
|
|
193
|
+
for logical_id, resource in resources.items():
|
|
194
|
+
props = resource.get("Properties", {})
|
|
195
|
+
if props:
|
|
196
|
+
prop_name = next(iter(props))
|
|
197
|
+
original_val = props[prop_name]
|
|
198
|
+
del template["Resources"][logical_id]["Properties"][prop_name]
|
|
199
|
+
modified = _dump(template, is_json)
|
|
200
|
+
return modified, FaultInjection(
|
|
201
|
+
fault_type=None,
|
|
202
|
+
original_snippet=f"{prop_name}: {str(original_val)[:60]}",
|
|
203
|
+
modified_snippet="(property removed)",
|
|
204
|
+
location=f"Resources.{logical_id}.Properties.{prop_name}",
|
|
205
|
+
description=f"Removed property '{prop_name}' from {resource.get('Type', 'unknown')}",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# REFERENCE injectors
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
@register_cf_injector("REFERENCE.broken_ref")
|
|
216
|
+
def inject_broken_ref(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
217
|
+
"""Corrupt a !Ref target to point to non-existent resource."""
|
|
218
|
+
refs = find_refs(parsed)
|
|
219
|
+
if not refs:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
is_json = _is_json(text)
|
|
223
|
+
template = copy.deepcopy(parsed)
|
|
224
|
+
|
|
225
|
+
# Pick a Ref that points to a resource or parameter
|
|
226
|
+
resource_ids = set(get_resource_logical_ids(parsed))
|
|
227
|
+
param_names = set(get_parameter_names(parsed))
|
|
228
|
+
valid_refs = [
|
|
229
|
+
(target, path) for target, path in refs
|
|
230
|
+
if target in resource_ids or target in param_names
|
|
231
|
+
]
|
|
232
|
+
if not valid_refs:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
target, path = random.choice(valid_refs)
|
|
236
|
+
corrupted = target + "Nonexistent"
|
|
237
|
+
|
|
238
|
+
# Navigate to the Ref and replace
|
|
239
|
+
obj = template
|
|
240
|
+
for key in path[:-1]:
|
|
241
|
+
if isinstance(obj, dict):
|
|
242
|
+
obj = obj[key]
|
|
243
|
+
elif isinstance(obj, list) and key.isdigit():
|
|
244
|
+
obj = obj[int(key)]
|
|
245
|
+
if isinstance(obj, dict) and path[-1] in obj:
|
|
246
|
+
obj[path[-1]] = corrupted
|
|
247
|
+
|
|
248
|
+
modified = _dump(template, is_json)
|
|
249
|
+
return modified, FaultInjection(
|
|
250
|
+
fault_type=None,
|
|
251
|
+
original_snippet=f"Ref: {target}",
|
|
252
|
+
modified_snippet=f"Ref: {corrupted}",
|
|
253
|
+
location=".".join(path),
|
|
254
|
+
description=f"Changed Ref target from {target} to {corrupted}",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@register_cf_injector("REFERENCE.bad_getatt")
|
|
259
|
+
def inject_bad_getatt(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
260
|
+
"""Corrupt a !GetAtt target."""
|
|
261
|
+
getatt_refs = find_getatt(parsed)
|
|
262
|
+
if not getatt_refs:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
is_json = _is_json(text)
|
|
266
|
+
template = copy.deepcopy(parsed)
|
|
267
|
+
|
|
268
|
+
value, path = random.choice(getatt_refs)
|
|
269
|
+
|
|
270
|
+
# Navigate and corrupt
|
|
271
|
+
obj = template
|
|
272
|
+
for key in path[:-1]:
|
|
273
|
+
if isinstance(obj, dict):
|
|
274
|
+
obj = obj[key]
|
|
275
|
+
elif isinstance(obj, list) and key.isdigit():
|
|
276
|
+
obj = obj[int(key)]
|
|
277
|
+
|
|
278
|
+
if isinstance(obj, dict) and path[-1] in obj:
|
|
279
|
+
original = obj[path[-1]]
|
|
280
|
+
if isinstance(original, list) and len(original) >= 2:
|
|
281
|
+
obj[path[-1]] = [original[0] + "Broken", original[1]]
|
|
282
|
+
corrupted_str = f"{original[0]}Broken.{original[1]}"
|
|
283
|
+
elif isinstance(original, str) and '.' in original:
|
|
284
|
+
parts = original.split('.', 1)
|
|
285
|
+
obj[path[-1]] = parts[0] + "Broken." + parts[1]
|
|
286
|
+
corrupted_str = obj[path[-1]]
|
|
287
|
+
else:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
modified = _dump(template, is_json)
|
|
291
|
+
return modified, FaultInjection(
|
|
292
|
+
fault_type=None,
|
|
293
|
+
original_snippet=f"GetAtt: {original}",
|
|
294
|
+
modified_snippet=f"GetAtt: {corrupted_str}",
|
|
295
|
+
location=".".join(path),
|
|
296
|
+
description=f"Corrupted GetAtt resource reference",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@register_cf_injector("REFERENCE.undefined_parameter")
|
|
303
|
+
def inject_undefined_parameter(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
304
|
+
"""Delete a parameter from Parameters that is referenced in Resources."""
|
|
305
|
+
params = get_parameter_names(parsed)
|
|
306
|
+
if not params:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
# Find a parameter that is referenced
|
|
310
|
+
refs = find_refs(parsed)
|
|
311
|
+
referenced_params = [t for t, _ in refs if t in params]
|
|
312
|
+
if not referenced_params:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
is_json = _is_json(text)
|
|
316
|
+
template = copy.deepcopy(parsed)
|
|
317
|
+
|
|
318
|
+
param_to_remove = random.choice(referenced_params)
|
|
319
|
+
del template["Parameters"][param_to_remove]
|
|
320
|
+
|
|
321
|
+
# Remove empty Parameters section
|
|
322
|
+
if not template["Parameters"]:
|
|
323
|
+
del template["Parameters"]
|
|
324
|
+
|
|
325
|
+
modified = _dump(template, is_json)
|
|
326
|
+
return modified, FaultInjection(
|
|
327
|
+
fault_type=None,
|
|
328
|
+
original_snippet=f"Parameter: {param_to_remove}",
|
|
329
|
+
modified_snippet="(parameter removed)",
|
|
330
|
+
location=f"Parameters.{param_to_remove}",
|
|
331
|
+
description=f"Removed parameter '{param_to_remove}' that is referenced in Resources",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
# SEMANTIC injectors
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
@register_cf_injector("SEMANTIC.cf_invalid_resource_type")
|
|
340
|
+
def inject_cf_invalid_resource_type(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
341
|
+
"""Use a non-existent resource type."""
|
|
342
|
+
is_json = _is_json(text)
|
|
343
|
+
template = copy.deepcopy(parsed)
|
|
344
|
+
resources = template.get("Resources", {})
|
|
345
|
+
|
|
346
|
+
for logical_id, resource in resources.items():
|
|
347
|
+
res_type = resource.get("Type", "")
|
|
348
|
+
if res_type in RESOURCE_TYPE_TYPOS:
|
|
349
|
+
new_type = RESOURCE_TYPE_TYPOS[res_type]
|
|
350
|
+
elif res_type.startswith("AWS::"):
|
|
351
|
+
# Generic corruption
|
|
352
|
+
parts = res_type.split("::")
|
|
353
|
+
if len(parts) == 3:
|
|
354
|
+
new_type = f"{parts[0]}::{parts[1]}::{parts[2]}Invalid"
|
|
355
|
+
else:
|
|
356
|
+
new_type = res_type + "Invalid"
|
|
357
|
+
else:
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
template["Resources"][logical_id]["Type"] = new_type
|
|
361
|
+
modified = _dump(template, is_json)
|
|
362
|
+
return modified, FaultInjection(
|
|
363
|
+
fault_type=None,
|
|
364
|
+
original_snippet=f"Type: {res_type}",
|
|
365
|
+
modified_snippet=f"Type: {new_type}",
|
|
366
|
+
location=f"Resources.{logical_id}.Type",
|
|
367
|
+
description=f"Changed resource type from {res_type} to {new_type}",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@register_cf_injector("SEMANTIC.wrong_property_value")
|
|
374
|
+
def inject_wrong_property_value(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
375
|
+
"""Set a property to an invalid value."""
|
|
376
|
+
is_json = _is_json(text)
|
|
377
|
+
template = copy.deepcopy(parsed)
|
|
378
|
+
resources = template.get("Resources", {})
|
|
379
|
+
|
|
380
|
+
# Look for InstanceType, Engine, or other constrained properties
|
|
381
|
+
value_corruptions = {
|
|
382
|
+
"InstanceType": "x1.superlarge",
|
|
383
|
+
"Engine": "postgres-invalid-engine",
|
|
384
|
+
"Runtime": "python2.5",
|
|
385
|
+
"Protocol": "INVALID",
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
for logical_id, resource in resources.items():
|
|
389
|
+
props = resource.get("Properties", {})
|
|
390
|
+
for prop_name, bad_val in value_corruptions.items():
|
|
391
|
+
if prop_name in props and isinstance(props[prop_name], str):
|
|
392
|
+
original = props[prop_name]
|
|
393
|
+
template["Resources"][logical_id]["Properties"][prop_name] = bad_val
|
|
394
|
+
modified = _dump(template, is_json)
|
|
395
|
+
return modified, FaultInjection(
|
|
396
|
+
fault_type=None,
|
|
397
|
+
original_snippet=f"{prop_name}: {original}",
|
|
398
|
+
modified_snippet=f"{prop_name}: {bad_val}",
|
|
399
|
+
location=f"Resources.{logical_id}.Properties.{prop_name}",
|
|
400
|
+
description=f"Changed {prop_name} to invalid value '{bad_val}'",
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@register_cf_injector("SEMANTIC.cf_bad_ami")
|
|
407
|
+
def inject_cf_bad_ami(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
408
|
+
"""Corrupt an AMI ID in ImageId property."""
|
|
409
|
+
is_json = _is_json(text)
|
|
410
|
+
template = copy.deepcopy(parsed)
|
|
411
|
+
resources = template.get("Resources", {})
|
|
412
|
+
|
|
413
|
+
for logical_id, resource in resources.items():
|
|
414
|
+
props = resource.get("Properties", {})
|
|
415
|
+
if "ImageId" in props and isinstance(props["ImageId"], str):
|
|
416
|
+
original = props["ImageId"]
|
|
417
|
+
template["Resources"][logical_id]["Properties"]["ImageId"] = "INVALID-ami-format"
|
|
418
|
+
modified = _dump(template, is_json)
|
|
419
|
+
return modified, FaultInjection(
|
|
420
|
+
fault_type=None,
|
|
421
|
+
original_snippet=f"ImageId: {original}",
|
|
422
|
+
modified_snippet="ImageId: INVALID-ami-format",
|
|
423
|
+
location=f"Resources.{logical_id}.Properties.ImageId",
|
|
424
|
+
description=f"Corrupted AMI ID from {original} to INVALID-ami-format",
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
# DEPENDENCY injectors
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
@register_cf_injector("DEPENDENCY.circular_depends_on")
|
|
435
|
+
def inject_circular_depends_on(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
436
|
+
"""Create a circular DependsOn chain."""
|
|
437
|
+
is_json = _is_json(text)
|
|
438
|
+
template = copy.deepcopy(parsed)
|
|
439
|
+
resources = template.get("Resources", {})
|
|
440
|
+
logical_ids = list(resources.keys())
|
|
441
|
+
|
|
442
|
+
if len(logical_ids) < 2:
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
id1, id2 = logical_ids[0], logical_ids[1]
|
|
446
|
+
template["Resources"][id1]["DependsOn"] = [id2]
|
|
447
|
+
template["Resources"][id2]["DependsOn"] = [id1]
|
|
448
|
+
|
|
449
|
+
modified = _dump(template, is_json)
|
|
450
|
+
return modified, FaultInjection(
|
|
451
|
+
fault_type=None,
|
|
452
|
+
original_snippet="(no circular dependency)",
|
|
453
|
+
modified_snippet=f"{id1} -> {id2} -> {id1}",
|
|
454
|
+
location=f"Resources.{id1} <-> Resources.{id2}",
|
|
455
|
+
description=f"Created circular DependsOn between {id1} and {id2}",
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@register_cf_injector("DEPENDENCY.missing_dependency")
|
|
460
|
+
def inject_missing_dependency(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
461
|
+
"""Remove DependsOn from a resource."""
|
|
462
|
+
is_json = _is_json(text)
|
|
463
|
+
template = copy.deepcopy(parsed)
|
|
464
|
+
resources = template.get("Resources", {})
|
|
465
|
+
|
|
466
|
+
for logical_id, resource in resources.items():
|
|
467
|
+
if "DependsOn" in resource:
|
|
468
|
+
original = resource["DependsOn"]
|
|
469
|
+
del template["Resources"][logical_id]["DependsOn"]
|
|
470
|
+
modified = _dump(template, is_json)
|
|
471
|
+
return modified, FaultInjection(
|
|
472
|
+
fault_type=None,
|
|
473
|
+
original_snippet=f"DependsOn: {original}",
|
|
474
|
+
modified_snippet="(DependsOn removed)",
|
|
475
|
+
location=f"Resources.{logical_id}.DependsOn",
|
|
476
|
+
description=f"Removed DependsOn from {logical_id}",
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
# INTRINSIC function injectors
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
@register_cf_injector("INTRINSIC.malformed_sub")
|
|
487
|
+
def inject_malformed_sub(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
488
|
+
"""Break !Sub syntax (unclosed ${, reference non-existent variable)."""
|
|
489
|
+
subs = find_subs(parsed)
|
|
490
|
+
if not subs:
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
is_json = _is_json(text)
|
|
494
|
+
template = copy.deepcopy(parsed)
|
|
495
|
+
|
|
496
|
+
value, path = random.choice(subs)
|
|
497
|
+
|
|
498
|
+
obj = template
|
|
499
|
+
for key in path[:-1]:
|
|
500
|
+
if isinstance(obj, dict):
|
|
501
|
+
obj = obj[key]
|
|
502
|
+
elif isinstance(obj, list) and key.isdigit():
|
|
503
|
+
obj = obj[int(key)]
|
|
504
|
+
|
|
505
|
+
if isinstance(obj, dict) and path[-1] in obj:
|
|
506
|
+
original = obj[path[-1]]
|
|
507
|
+
if isinstance(original, str):
|
|
508
|
+
# Add an unclosed ${
|
|
509
|
+
obj[path[-1]] = original + " ${UndefinedVar"
|
|
510
|
+
elif isinstance(original, list) and len(original) >= 1 and isinstance(original[0], str):
|
|
511
|
+
obj[path[-1]][0] = original[0] + " ${UndefinedVar"
|
|
512
|
+
else:
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
modified = _dump(template, is_json)
|
|
516
|
+
return modified, FaultInjection(
|
|
517
|
+
fault_type=None,
|
|
518
|
+
original_snippet=f"Sub: {str(original)[:60]}",
|
|
519
|
+
modified_snippet=f"Sub: {str(obj[path[-1]])[:60]}",
|
|
520
|
+
location=".".join(path),
|
|
521
|
+
description="Added unclosed variable reference in !Sub",
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@register_cf_injector("INTRINSIC.wrong_select_index")
|
|
528
|
+
def inject_wrong_select_index(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
529
|
+
"""Use !Select with an out-of-bounds index."""
|
|
530
|
+
selects = find_selects(parsed)
|
|
531
|
+
if not selects:
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
is_json = _is_json(text)
|
|
535
|
+
template = copy.deepcopy(parsed)
|
|
536
|
+
|
|
537
|
+
value, path = random.choice(selects)
|
|
538
|
+
|
|
539
|
+
obj = template
|
|
540
|
+
for key in path[:-1]:
|
|
541
|
+
if isinstance(obj, dict):
|
|
542
|
+
obj = obj[key]
|
|
543
|
+
elif isinstance(obj, list) and key.isdigit():
|
|
544
|
+
obj = obj[int(key)]
|
|
545
|
+
|
|
546
|
+
if isinstance(obj, dict) and path[-1] in obj:
|
|
547
|
+
original = obj[path[-1]]
|
|
548
|
+
if isinstance(original, list) and len(original) == 2:
|
|
549
|
+
# Set index to very large number
|
|
550
|
+
obj[path[-1]] = [999, original[1]]
|
|
551
|
+
modified = _dump(template, is_json)
|
|
552
|
+
return modified, FaultInjection(
|
|
553
|
+
fault_type=None,
|
|
554
|
+
original_snippet=f"Select: [{original[0]}, ...]",
|
|
555
|
+
modified_snippet="Select: [999, ...]",
|
|
556
|
+
location=".".join(path),
|
|
557
|
+
description="Set !Select index to 999 (out of bounds)",
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@register_cf_injector("INTRINSIC.bad_if_condition")
|
|
564
|
+
def inject_bad_if_condition(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
565
|
+
"""Use !If with a condition name not defined in Conditions."""
|
|
566
|
+
ifs = find_ifs(parsed)
|
|
567
|
+
if not ifs:
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
is_json = _is_json(text)
|
|
571
|
+
template = copy.deepcopy(parsed)
|
|
572
|
+
|
|
573
|
+
value, path = random.choice(ifs)
|
|
574
|
+
|
|
575
|
+
obj = template
|
|
576
|
+
for key in path[:-1]:
|
|
577
|
+
if isinstance(obj, dict):
|
|
578
|
+
obj = obj[key]
|
|
579
|
+
elif isinstance(obj, list) and key.isdigit():
|
|
580
|
+
obj = obj[int(key)]
|
|
581
|
+
|
|
582
|
+
if isinstance(obj, dict) and path[-1] in obj:
|
|
583
|
+
original = obj[path[-1]]
|
|
584
|
+
if isinstance(original, list) and len(original) >= 1:
|
|
585
|
+
old_condition = original[0]
|
|
586
|
+
obj[path[-1]][0] = "NonExistentCondition"
|
|
587
|
+
modified = _dump(template, is_json)
|
|
588
|
+
return modified, FaultInjection(
|
|
589
|
+
fault_type=None,
|
|
590
|
+
original_snippet=f"If: [{old_condition}, ...]",
|
|
591
|
+
modified_snippet="If: [NonExistentCondition, ...]",
|
|
592
|
+
location=".".join(path),
|
|
593
|
+
description=f"Changed !If condition from '{old_condition}' to 'NonExistentCondition'",
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
return None
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@register_cf_injector("INTRINSIC.invalid_join")
|
|
600
|
+
def inject_invalid_join(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
601
|
+
"""Break !Join syntax (wrong number of arguments)."""
|
|
602
|
+
joins = find_joins(parsed)
|
|
603
|
+
if not joins:
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
is_json = _is_json(text)
|
|
607
|
+
template = copy.deepcopy(parsed)
|
|
608
|
+
|
|
609
|
+
value, path = random.choice(joins)
|
|
610
|
+
|
|
611
|
+
obj = template
|
|
612
|
+
for key in path[:-1]:
|
|
613
|
+
if isinstance(obj, dict):
|
|
614
|
+
obj = obj[key]
|
|
615
|
+
elif isinstance(obj, list) and key.isdigit():
|
|
616
|
+
obj = obj[int(key)]
|
|
617
|
+
|
|
618
|
+
if isinstance(obj, dict) and path[-1] in obj:
|
|
619
|
+
original = obj[path[-1]]
|
|
620
|
+
# Replace with invalid structure (string instead of [delimiter, list])
|
|
621
|
+
obj[path[-1]] = "invalid-join-not-a-list"
|
|
622
|
+
modified = _dump(template, is_json)
|
|
623
|
+
return modified, FaultInjection(
|
|
624
|
+
fault_type=None,
|
|
625
|
+
original_snippet=f"Join: {str(original)[:60]}",
|
|
626
|
+
modified_snippet='Join: "invalid-join-not-a-list"',
|
|
627
|
+
location=".".join(path),
|
|
628
|
+
description="Changed !Join value to invalid string (should be [delimiter, list])",
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---------------------------------------------------------------------------
|
|
635
|
+
# SECURITY injectors
|
|
636
|
+
# ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
@register_cf_injector("SECURITY.open_ingress")
|
|
639
|
+
def inject_open_ingress(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
640
|
+
"""Open security group ingress to 0.0.0.0/0."""
|
|
641
|
+
is_json = _is_json(text)
|
|
642
|
+
template = copy.deepcopy(parsed)
|
|
643
|
+
resources = template.get("Resources", {})
|
|
644
|
+
|
|
645
|
+
for logical_id, resource in resources.items():
|
|
646
|
+
if resource.get("Type") == "AWS::EC2::SecurityGroup":
|
|
647
|
+
props = resource.get("Properties", {})
|
|
648
|
+
open_rule = {
|
|
649
|
+
"IpProtocol": "tcp",
|
|
650
|
+
"FromPort": 0,
|
|
651
|
+
"ToPort": 65535,
|
|
652
|
+
"CidrIp": "0.0.0.0/0",
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
ingress = props.get("SecurityGroupIngress", [])
|
|
656
|
+
if isinstance(ingress, list):
|
|
657
|
+
ingress.append(open_rule)
|
|
658
|
+
else:
|
|
659
|
+
ingress = [open_rule]
|
|
660
|
+
template["Resources"][logical_id]["Properties"]["SecurityGroupIngress"] = ingress
|
|
661
|
+
|
|
662
|
+
modified = _dump(template, is_json)
|
|
663
|
+
return modified, FaultInjection(
|
|
664
|
+
fault_type=None,
|
|
665
|
+
original_snippet="(restricted ingress)",
|
|
666
|
+
modified_snippet="CidrIp: 0.0.0.0/0, Ports: 0-65535",
|
|
667
|
+
location=f"Resources.{logical_id}.Properties.SecurityGroupIngress",
|
|
668
|
+
description=f"Added overly permissive ingress rule to {logical_id}",
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
return None
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@register_cf_injector("SECURITY.cf_missing_encryption")
|
|
675
|
+
def inject_cf_missing_encryption(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
|
|
676
|
+
"""Remove encryption configuration from S3 bucket or RDS."""
|
|
677
|
+
is_json = _is_json(text)
|
|
678
|
+
template = copy.deepcopy(parsed)
|
|
679
|
+
resources = template.get("Resources", {})
|
|
680
|
+
|
|
681
|
+
encryption_props = {
|
|
682
|
+
"AWS::S3::Bucket": ["BucketEncryption"],
|
|
683
|
+
"AWS::RDS::DBInstance": ["StorageEncrypted", "KmsKeyId"],
|
|
684
|
+
"AWS::EBS::Volume": ["Encrypted", "KmsKeyId"],
|
|
685
|
+
"AWS::DynamoDB::Table": ["SSESpecification"],
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
for logical_id, resource in resources.items():
|
|
689
|
+
res_type = resource.get("Type", "")
|
|
690
|
+
if res_type in encryption_props:
|
|
691
|
+
props = resource.get("Properties", {})
|
|
692
|
+
for enc_prop in encryption_props[res_type]:
|
|
693
|
+
if enc_prop in props:
|
|
694
|
+
original = props[enc_prop]
|
|
695
|
+
del template["Resources"][logical_id]["Properties"][enc_prop]
|
|
696
|
+
modified = _dump(template, is_json)
|
|
697
|
+
return modified, FaultInjection(
|
|
698
|
+
fault_type=None,
|
|
699
|
+
original_snippet=f"{enc_prop}: {str(original)[:60]}",
|
|
700
|
+
modified_snippet="(property removed)",
|
|
701
|
+
location=f"Resources.{logical_id}.Properties.{enc_prop}",
|
|
702
|
+
description=f"Removed encryption property '{enc_prop}' from {res_type}",
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
return None
|