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.
Files changed (44) hide show
  1. cloudgym/__init__.py +3 -0
  2. cloudgym/benchmark/__init__.py +0 -0
  3. cloudgym/benchmark/dataset.py +188 -0
  4. cloudgym/benchmark/evaluator.py +275 -0
  5. cloudgym/cli.py +61 -0
  6. cloudgym/fixer/__init__.py +1 -0
  7. cloudgym/fixer/cli.py +521 -0
  8. cloudgym/fixer/detector.py +81 -0
  9. cloudgym/fixer/formatter.py +55 -0
  10. cloudgym/fixer/lambda_handler.py +126 -0
  11. cloudgym/fixer/repairer.py +237 -0
  12. cloudgym/generator/__init__.py +0 -0
  13. cloudgym/generator/formatter.py +142 -0
  14. cloudgym/generator/pipeline.py +271 -0
  15. cloudgym/inverter/__init__.py +0 -0
  16. cloudgym/inverter/_cf_injectors.py +705 -0
  17. cloudgym/inverter/_cf_utils.py +202 -0
  18. cloudgym/inverter/_hcl_utils.py +182 -0
  19. cloudgym/inverter/_tf_injectors.py +641 -0
  20. cloudgym/inverter/_yaml_cf.py +84 -0
  21. cloudgym/inverter/agentic.py +90 -0
  22. cloudgym/inverter/engine.py +258 -0
  23. cloudgym/inverter/programmatic.py +95 -0
  24. cloudgym/scraper/__init__.py +0 -0
  25. cloudgym/scraper/aws_samples.py +159 -0
  26. cloudgym/scraper/github.py +238 -0
  27. cloudgym/scraper/registry.py +165 -0
  28. cloudgym/scraper/validator.py +116 -0
  29. cloudgym/taxonomy/__init__.py +10 -0
  30. cloudgym/taxonomy/base.py +102 -0
  31. cloudgym/taxonomy/cloudformation.py +258 -0
  32. cloudgym/taxonomy/terraform.py +274 -0
  33. cloudgym/utils/__init__.py +0 -0
  34. cloudgym/utils/config.py +57 -0
  35. cloudgym/utils/ollama.py +66 -0
  36. cloudgym/validator/__init__.py +0 -0
  37. cloudgym/validator/cloudformation.py +55 -0
  38. cloudgym/validator/opentofu.py +103 -0
  39. cloudgym/validator/terraform.py +115 -0
  40. stackfix-0.1.0.dist-info/METADATA +182 -0
  41. stackfix-0.1.0.dist-info/RECORD +44 -0
  42. stackfix-0.1.0.dist-info/WHEEL +4 -0
  43. stackfix-0.1.0.dist-info/entry_points.txt +3 -0
  44. 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