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,641 @@
1
+ """Terraform fault injector functions — one per fault type.
2
+
3
+ Each injector takes (text, parsed_dict) and returns (modified_text, FaultInjection) or None.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import random
9
+ import re
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from cloudgym.inverter._hcl_utils import (
13
+ find_all_attributes,
14
+ find_block_boundaries,
15
+ find_resource_blocks,
16
+ find_resource_refs,
17
+ find_variable_refs,
18
+ remove_lines,
19
+ replace_value,
20
+ )
21
+ from cloudgym.taxonomy.base import FaultInjection
22
+
23
+ if TYPE_CHECKING:
24
+ pass
25
+
26
+ # Registry mapping fault IDs to injector functions
27
+ TF_INJECTOR_REGISTRY: dict[str, Any] = {}
28
+
29
+
30
+ def register_tf_injector(fault_id: str):
31
+ """Decorator to register a TF injector function."""
32
+ def decorator(fn):
33
+ TF_INJECTOR_REGISTRY[fault_id] = fn
34
+ return fn
35
+ return decorator
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # SYNTACTIC injectors
40
+ # ---------------------------------------------------------------------------
41
+
42
+ @register_tf_injector("SYNTACTIC.missing_closing_brace")
43
+ def inject_missing_closing_brace(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
44
+ """Remove a closing brace from a resource block."""
45
+ blocks = find_resource_blocks(text)
46
+ if not blocks:
47
+ return None
48
+
49
+ res_type, res_name, start, end = random.choice(blocks)
50
+ # Find the last closing brace of this block
51
+ block_text = text[start:end]
52
+ last_brace = block_text.rfind('}')
53
+ if last_brace < 0:
54
+ return None
55
+
56
+ abs_pos = start + last_brace
57
+ modified = text[:abs_pos] + text[abs_pos + 1:]
58
+
59
+ return modified, FaultInjection(
60
+ fault_type=None, # Will be set by caller
61
+ original_snippet=text[max(0, abs_pos - 20):abs_pos + 20],
62
+ modified_snippet=modified[max(0, abs_pos - 20):abs_pos + 19],
63
+ location=f"resource \"{res_type}\" \"{res_name}\"",
64
+ description=f"Removed closing brace from resource {res_type}.{res_name}",
65
+ )
66
+
67
+
68
+ @register_tf_injector("SYNTACTIC.wrong_attribute_type")
69
+ def inject_wrong_attribute_type(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
70
+ """Assign a string where a number/bool is expected."""
71
+ blocks = find_resource_blocks(text)
72
+ if not blocks:
73
+ return None
74
+
75
+ res_type, res_name, start, end = random.choice(blocks)
76
+ attrs = find_all_attributes(text, start, end)
77
+ if not attrs:
78
+ return None
79
+
80
+ # Look for numeric or bool attributes
81
+ lines = text.split('\n')
82
+ for attr_name, line_num in attrs:
83
+ line = lines[line_num]
84
+ # Match numeric values
85
+ m = re.search(r'=\s*(\d+)', line)
86
+ if m:
87
+ old_val = m.group(1)
88
+ new_val = f'"{attr_name}_value"'
89
+ modified = replace_value(text, line_num, old_val, new_val)
90
+ return modified, FaultInjection(
91
+ fault_type=None,
92
+ original_snippet=line.strip(),
93
+ modified_snippet=lines[line_num].replace(old_val, new_val, 1).strip() if line_num < len(lines) else "",
94
+ location=f"resource \"{res_type}\" \"{res_name}\", attribute {attr_name}",
95
+ description=f"Changed numeric value to string for {attr_name}",
96
+ )
97
+ # Match bool values
98
+ m = re.search(r'=\s*(true|false)', line)
99
+ if m:
100
+ old_val = m.group(1)
101
+ new_val = '"yes"'
102
+ modified = replace_value(text, line_num, old_val, new_val)
103
+ return modified, FaultInjection(
104
+ fault_type=None,
105
+ original_snippet=line.strip(),
106
+ modified_snippet=lines[line_num].replace(old_val, new_val, 1).strip() if line_num < len(lines) else "",
107
+ location=f"resource \"{res_type}\" \"{res_name}\", attribute {attr_name}",
108
+ description=f"Changed boolean to string for {attr_name}",
109
+ )
110
+
111
+ return None
112
+
113
+
114
+ @register_tf_injector("SYNTACTIC.invalid_hcl_syntax")
115
+ def inject_invalid_hcl_syntax(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
116
+ """Introduce invalid HCL syntax (remove an equals sign)."""
117
+ blocks = find_resource_blocks(text)
118
+ if not blocks:
119
+ return None
120
+
121
+ res_type, res_name, start, end = random.choice(blocks)
122
+ attrs = find_all_attributes(text, start, end)
123
+ if not attrs:
124
+ return None
125
+
126
+ attr_name, line_num = random.choice(attrs)
127
+ lines = text.split('\n')
128
+ original_line = lines[line_num]
129
+ # Remove the equals sign
130
+ modified_line = re.sub(r'\s*=\s*', ' ', original_line, count=1)
131
+ lines[line_num] = modified_line
132
+ modified = '\n'.join(lines)
133
+
134
+ return modified, FaultInjection(
135
+ fault_type=None,
136
+ original_snippet=original_line.strip(),
137
+ modified_snippet=modified_line.strip(),
138
+ location=f"resource \"{res_type}\" \"{res_name}\", line {line_num + 1}",
139
+ description=f"Removed equals sign from attribute {attr_name}",
140
+ )
141
+
142
+
143
+ @register_tf_injector("SYNTACTIC.missing_required_argument")
144
+ def inject_missing_required_argument(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
145
+ """Remove a required argument line from a resource block."""
146
+ blocks = find_resource_blocks(text)
147
+ if not blocks:
148
+ return None
149
+
150
+ # Known required arguments per resource type
151
+ required_args = {
152
+ "aws_instance": ["ami", "instance_type"],
153
+ "aws_launch_configuration": ["image_id", "instance_type"],
154
+ "aws_security_group": ["name"],
155
+ "aws_subnet": ["vpc_id", "cidr_block"],
156
+ "aws_vpc": ["cidr_block"],
157
+ "aws_s3_bucket": [],
158
+ "aws_db_instance": ["engine", "instance_class"],
159
+ "aws_lambda_function": ["function_name", "handler", "runtime", "role"],
160
+ "aws_iam_role": ["assume_role_policy"],
161
+ }
162
+
163
+ random.shuffle(blocks)
164
+ for res_type, res_name, start, end in blocks:
165
+ reqs = required_args.get(res_type, [])
166
+ if not reqs:
167
+ continue
168
+
169
+ attrs = find_all_attributes(text, start, end)
170
+ for attr_name, line_num in attrs:
171
+ if attr_name in reqs:
172
+ original_line = text.split('\n')[line_num]
173
+ modified = remove_lines(text, line_num, line_num)
174
+ return modified, FaultInjection(
175
+ fault_type=None,
176
+ original_snippet=original_line.strip(),
177
+ modified_snippet="(line removed)",
178
+ location=f"resource \"{res_type}\" \"{res_name}\"",
179
+ description=f"Removed required argument '{attr_name}' from {res_type}.{res_name}",
180
+ )
181
+
182
+ # Fallback: remove any attribute from any block
183
+ res_type, res_name, start, end = blocks[0]
184
+ attrs = find_all_attributes(text, start, end)
185
+ if attrs:
186
+ attr_name, line_num = attrs[0]
187
+ original_line = text.split('\n')[line_num]
188
+ modified = remove_lines(text, line_num, line_num)
189
+ return modified, FaultInjection(
190
+ fault_type=None,
191
+ original_snippet=original_line.strip(),
192
+ modified_snippet="(line removed)",
193
+ location=f"resource \"{res_type}\" \"{res_name}\"",
194
+ description=f"Removed argument '{attr_name}' from {res_type}.{res_name}",
195
+ )
196
+
197
+ return None
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # REFERENCE injectors
202
+ # ---------------------------------------------------------------------------
203
+
204
+ @register_tf_injector("REFERENCE.undefined_variable")
205
+ def inject_undefined_variable(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
206
+ """Corrupt a var.X reference to point to a non-existent variable."""
207
+ refs = find_variable_refs(text)
208
+ if not refs:
209
+ return None
210
+
211
+ var_name, offset = random.choice(refs)
212
+ corrupted_name = var_name + "_undefined"
213
+ old_ref = f"var.{var_name}"
214
+ new_ref = f"var.{corrupted_name}"
215
+ modified = text[:offset] + new_ref + text[offset + len(old_ref):]
216
+
217
+ return modified, FaultInjection(
218
+ fault_type=None,
219
+ original_snippet=old_ref,
220
+ modified_snippet=new_ref,
221
+ location=f"offset {offset}",
222
+ description=f"Changed variable reference from {old_ref} to {new_ref}",
223
+ )
224
+
225
+
226
+ @register_tf_injector("REFERENCE.bad_resource_reference")
227
+ def inject_bad_resource_reference(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
228
+ """Misspell a resource reference in an expression."""
229
+ refs = find_resource_refs(text)
230
+ if not refs:
231
+ return None
232
+
233
+ res_type, res_name, offset = random.choice(refs)
234
+ corrupted_name = res_name + "_typo"
235
+ old_ref = f"{res_type}.{res_name}"
236
+ new_ref = f"{res_type}.{corrupted_name}"
237
+ modified = text[:offset] + new_ref + text[offset + len(old_ref):]
238
+
239
+ return modified, FaultInjection(
240
+ fault_type=None,
241
+ original_snippet=old_ref,
242
+ modified_snippet=new_ref,
243
+ location=f"offset {offset}",
244
+ description=f"Misspelled resource reference from {old_ref} to {new_ref}",
245
+ )
246
+
247
+
248
+ @register_tf_injector("REFERENCE.broken_module_source")
249
+ def inject_broken_module_source(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
250
+ """Break a module source path."""
251
+ module_blocks = find_block_boundaries(text, "module")
252
+ if not module_blocks:
253
+ return None
254
+
255
+ start, end = random.choice(module_blocks)
256
+ lines = text.split('\n')
257
+ base_line = text[:start].count('\n')
258
+ block_lines = text[start:end].split('\n')
259
+
260
+ for i, line in enumerate(block_lines):
261
+ if re.match(r'\s*source\s*=', line):
262
+ line_num = base_line + i
263
+ original_line = lines[line_num]
264
+ # Replace source value with broken path
265
+ modified_line = re.sub(
266
+ r'(source\s*=\s*)"[^"]*"',
267
+ r'\1"./nonexistent_module_path"',
268
+ original_line,
269
+ )
270
+ lines[line_num] = modified_line
271
+ modified = '\n'.join(lines)
272
+ return modified, FaultInjection(
273
+ fault_type=None,
274
+ original_snippet=original_line.strip(),
275
+ modified_snippet=modified_line.strip(),
276
+ location=f"module block, line {line_num + 1}",
277
+ description="Changed module source to non-existent path",
278
+ )
279
+
280
+ return None
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # SEMANTIC injectors
285
+ # ---------------------------------------------------------------------------
286
+
287
+ @register_tf_injector("SEMANTIC.invalid_resource_type")
288
+ def inject_invalid_resource_type(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
289
+ """Use a non-existent resource type name."""
290
+ blocks = find_resource_blocks(text)
291
+ if not blocks:
292
+ return None
293
+
294
+ res_type, res_name, start, end = random.choice(blocks)
295
+ # Corrupt the resource type
296
+ type_typos = {
297
+ "aws_instance": "aws_ec2_instance",
298
+ "aws_s3_bucket": "aws_s3_storage",
299
+ "aws_lambda_function": "aws_lambda",
300
+ "aws_security_group": "aws_firewall_group",
301
+ "aws_vpc": "aws_virtual_private_cloud",
302
+ "aws_subnet": "aws_network_subnet",
303
+ "aws_db_instance": "aws_rds_database",
304
+ "aws_iam_role": "aws_iam_service_role",
305
+ }
306
+
307
+ new_type = type_typos.get(res_type, res_type + "_invalid")
308
+ old_decl = f'resource "{res_type}"'
309
+ new_decl = f'resource "{new_type}"'
310
+ modified = text[:start] + text[start:end].replace(old_decl, new_decl, 1) + text[end:]
311
+
312
+ return modified, FaultInjection(
313
+ fault_type=None,
314
+ original_snippet=old_decl,
315
+ modified_snippet=new_decl,
316
+ location=f"resource \"{res_type}\" \"{res_name}\"",
317
+ description=f"Changed resource type from {res_type} to {new_type}",
318
+ )
319
+
320
+
321
+ @register_tf_injector("SEMANTIC.bad_ami_format")
322
+ def inject_bad_ami_format(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
323
+ """Corrupt an AMI ID to invalid format."""
324
+ # Find ami = "ami-XXXX" patterns
325
+ m = re.search(r'(ami\s*=\s*")(ami-[0-9a-f]+)(")', text)
326
+ if not m:
327
+ # Also try image_id
328
+ m = re.search(r'(image_id\s*=\s*")(ami-[0-9a-f]+)(")', text)
329
+ if not m:
330
+ return None
331
+
332
+ old_ami = m.group(2)
333
+ bad_ami = "INVALID-ami-format"
334
+ modified = text[:m.start(2)] + bad_ami + text[m.end(2):]
335
+
336
+ return modified, FaultInjection(
337
+ fault_type=None,
338
+ original_snippet=old_ami,
339
+ modified_snippet=bad_ami,
340
+ location=f"AMI ID at offset {m.start(2)}",
341
+ description=f"Corrupted AMI ID from {old_ami} to {bad_ami}",
342
+ )
343
+
344
+
345
+ @register_tf_injector("SEMANTIC.invalid_cidr")
346
+ def inject_invalid_cidr(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
347
+ """Replace a valid CIDR with an invalid one."""
348
+ m = re.search(r'"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2})"', text)
349
+ if not m:
350
+ return None
351
+
352
+ old_cidr = m.group(1)
353
+ bad_cidr = "999.999.999.999/33"
354
+ modified = text[:m.start(1)] + bad_cidr + text[m.end(1):]
355
+
356
+ return modified, FaultInjection(
357
+ fault_type=None,
358
+ original_snippet=old_cidr,
359
+ modified_snippet=bad_cidr,
360
+ location=f"CIDR block at offset {m.start(1)}",
361
+ description=f"Changed CIDR from {old_cidr} to {bad_cidr}",
362
+ )
363
+
364
+
365
+ @register_tf_injector("SEMANTIC.invalid_region")
366
+ def inject_invalid_region(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
367
+ """Replace a valid region with a fake one."""
368
+ m = re.search(r'(region\s*=\s*")([\w-]+)(")', text)
369
+ if not m:
370
+ return None
371
+
372
+ old_region = m.group(2)
373
+ bad_region = "us-fictional-1"
374
+ modified = text[:m.start(2)] + bad_region + text[m.end(2):]
375
+
376
+ return modified, FaultInjection(
377
+ fault_type=None,
378
+ original_snippet=old_region,
379
+ modified_snippet=bad_region,
380
+ location=f"region at offset {m.start(2)}",
381
+ description=f"Changed region from {old_region} to {bad_region}",
382
+ )
383
+
384
+
385
+ # ---------------------------------------------------------------------------
386
+ # DEPENDENCY injectors
387
+ # ---------------------------------------------------------------------------
388
+
389
+ @register_tf_injector("DEPENDENCY.circular_dependency")
390
+ def inject_circular_dependency(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
391
+ """Add circular depends_on between two resources."""
392
+ blocks = find_resource_blocks(text)
393
+ if len(blocks) < 2:
394
+ return None
395
+
396
+ res1_type, res1_name, start1, end1 = blocks[0]
397
+ res2_type, res2_name, start2, end2 = blocks[1]
398
+
399
+ # Add depends_on to both resources pointing at each other
400
+ lines = text.split('\n')
401
+ # Find the closing brace of each block and insert depends_on before it
402
+ block1_text = text[start1:end1]
403
+ block2_text = text[start2:end2]
404
+
405
+ last_brace1 = block1_text.rfind('}')
406
+ last_brace2 = block2_text.rfind('}')
407
+
408
+ dep1 = f' depends_on = [{res2_type}.{res2_name}]'
409
+ dep2 = f' depends_on = [{res1_type}.{res1_name}]'
410
+
411
+ # Insert deps (work backwards to preserve offsets)
412
+ if start2 > start1:
413
+ modified = (
414
+ text[:start2 + last_brace2]
415
+ + '\n' + dep2 + '\n'
416
+ + text[start2 + last_brace2:start1 + last_brace1]
417
+ if start1 + last_brace1 > start2 + last_brace2 else
418
+ text[:start1 + last_brace1]
419
+ + '\n' + dep1 + '\n'
420
+ + text[start1 + last_brace1:start2 + last_brace2]
421
+ + '\n' + dep2 + '\n'
422
+ + text[start2 + last_brace2:]
423
+ )
424
+ else:
425
+ modified = text
426
+
427
+ # Simpler approach: just insert both depends_on
428
+ modified = text[:start1 + last_brace1] + '\n' + dep1 + '\n' + text[start1 + last_brace1:]
429
+ # Recalculate offset for second block
430
+ offset_shift = len('\n' + dep1 + '\n')
431
+ new_start2 = start2 + offset_shift if start2 > start1 else start2
432
+ new_end2 = end2 + offset_shift if start2 > start1 else end2
433
+ block2_in_modified = modified[new_start2:new_end2]
434
+ last_brace2_new = block2_in_modified.rfind('}')
435
+ modified = (
436
+ modified[:new_start2 + last_brace2_new]
437
+ + '\n' + dep2 + '\n'
438
+ + modified[new_start2 + last_brace2_new:]
439
+ )
440
+
441
+ return modified, FaultInjection(
442
+ fault_type=None,
443
+ original_snippet="(no depends_on)",
444
+ modified_snippet=f"{dep1}\n{dep2}",
445
+ location=f"{res1_type}.{res1_name} <-> {res2_type}.{res2_name}",
446
+ description=f"Added circular dependency between {res1_name} and {res2_name}",
447
+ )
448
+
449
+
450
+ @register_tf_injector("DEPENDENCY.missing_depends_on")
451
+ def inject_missing_depends_on(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
452
+ """Remove an existing depends_on block."""
453
+ m = re.search(r'(\n[ \t]*depends_on\s*=\s*\[[^\]]*\])', text)
454
+ if not m:
455
+ return None
456
+
457
+ original = m.group(1)
458
+ modified = text[:m.start()] + text[m.end():]
459
+
460
+ return modified, FaultInjection(
461
+ fault_type=None,
462
+ original_snippet=original.strip(),
463
+ modified_snippet="(removed)",
464
+ location=f"offset {m.start()}",
465
+ description="Removed depends_on declaration",
466
+ )
467
+
468
+
469
+ # ---------------------------------------------------------------------------
470
+ # PROVIDER injectors
471
+ # ---------------------------------------------------------------------------
472
+
473
+ @register_tf_injector("PROVIDER.missing_provider")
474
+ def inject_missing_provider(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
475
+ """Remove the required_providers or terraform block."""
476
+ terraform_blocks = find_block_boundaries(text, "terraform")
477
+ if terraform_blocks:
478
+ start, end = terraform_blocks[0]
479
+ original = text[start:end]
480
+ modified = text[:start] + text[end:]
481
+ return modified, FaultInjection(
482
+ fault_type=None,
483
+ original_snippet=original[:80] + "..." if len(original) > 80 else original,
484
+ modified_snippet="(block removed)",
485
+ location="terraform block",
486
+ description="Removed terraform/required_providers block",
487
+ )
488
+
489
+ # Try removing provider block
490
+ provider_blocks = find_block_boundaries(text, "provider")
491
+ if provider_blocks:
492
+ start, end = provider_blocks[0]
493
+ original = text[start:end]
494
+ modified = text[:start] + text[end:]
495
+ return modified, FaultInjection(
496
+ fault_type=None,
497
+ original_snippet=original[:80] + "..." if len(original) > 80 else original,
498
+ modified_snippet="(block removed)",
499
+ location="provider block",
500
+ description="Removed provider configuration block",
501
+ )
502
+
503
+ return None
504
+
505
+
506
+ @register_tf_injector("PROVIDER.version_constraint_mismatch")
507
+ def inject_version_constraint_mismatch(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
508
+ """Set an impossible provider version constraint."""
509
+ m = re.search(r'(version\s*=\s*")([\s\S]*?)(")', text)
510
+ if not m:
511
+ # Try required_version
512
+ m = re.search(r'(required_version\s*=\s*")([\s\S]*?)(")', text)
513
+ if not m:
514
+ return None
515
+
516
+ old_version = m.group(2)
517
+ impossible_version = ">= 99.0.0, < 99.0.1"
518
+ modified = text[:m.start(2)] + impossible_version + text[m.end(2):]
519
+
520
+ return modified, FaultInjection(
521
+ fault_type=None,
522
+ original_snippet=old_version,
523
+ modified_snippet=impossible_version,
524
+ location=f"version constraint at offset {m.start(2)}",
525
+ description=f"Changed version constraint to impossible range: {impossible_version}",
526
+ )
527
+
528
+
529
+ # ---------------------------------------------------------------------------
530
+ # SECURITY injectors
531
+ # ---------------------------------------------------------------------------
532
+
533
+ @register_tf_injector("SECURITY.overly_permissive_security_group")
534
+ def inject_overly_permissive_sg(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
535
+ """Open a security group to 0.0.0.0/0 on all ports."""
536
+ blocks = find_resource_blocks(text)
537
+ sg_blocks = [(t, n, s, e) for t, n, s, e in blocks if t == "aws_security_group"]
538
+
539
+ if not sg_blocks:
540
+ return None
541
+
542
+ res_type, res_name, start, end = sg_blocks[0]
543
+ block_text = text[start:end]
544
+ last_brace = block_text.rfind('}')
545
+
546
+ open_ingress = """
547
+ ingress {
548
+ from_port = 0
549
+ to_port = 65535
550
+ protocol = "tcp"
551
+ cidr_blocks = ["0.0.0.0/0"]
552
+ }"""
553
+
554
+ modified = (
555
+ text[:start + last_brace]
556
+ + open_ingress + '\n'
557
+ + text[start + last_brace:]
558
+ )
559
+
560
+ return modified, FaultInjection(
561
+ fault_type=None,
562
+ original_snippet="(security group without open ingress)",
563
+ modified_snippet=open_ingress.strip(),
564
+ location=f"resource \"aws_security_group\" \"{res_name}\"",
565
+ description="Added overly permissive ingress rule (0.0.0.0/0 all TCP ports)",
566
+ )
567
+
568
+
569
+ @register_tf_injector("SECURITY.missing_encryption")
570
+ def inject_missing_encryption(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
571
+ """Remove encryption configuration."""
572
+ # Look for server_side_encryption_configuration, encrypted, kms_key_id, etc.
573
+ encryption_patterns = [
574
+ (r'\n[ \t]*server_side_encryption_configuration\s*\{[^}]*\{[^}]*\}[^}]*\}', "server_side_encryption_configuration block"),
575
+ (r'\n[ \t]*encrypted\s*=\s*true', "encrypted = true"),
576
+ (r'\n[ \t]*kms_key_id\s*=\s*"[^"]*"', "kms_key_id"),
577
+ (r'\n[ \t]*storage_encrypted\s*=\s*true', "storage_encrypted = true"),
578
+ ]
579
+
580
+ for pattern, desc in encryption_patterns:
581
+ m = re.search(pattern, text)
582
+ if m:
583
+ original = m.group(0).strip()
584
+ modified = text[:m.start()] + text[m.end():]
585
+ return modified, FaultInjection(
586
+ fault_type=None,
587
+ original_snippet=original[:80],
588
+ modified_snippet="(removed)",
589
+ location=f"encryption config",
590
+ description=f"Removed {desc}",
591
+ )
592
+
593
+ return None
594
+
595
+
596
+ # ---------------------------------------------------------------------------
597
+ # CROSS_RESOURCE injectors
598
+ # ---------------------------------------------------------------------------
599
+
600
+ @register_tf_injector("CROSS_RESOURCE.subnet_vpc_mismatch")
601
+ def inject_subnet_vpc_mismatch(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
602
+ """Swap vpc_id or subnet_id references."""
603
+ m = re.search(r'(vpc_id\s*=\s*)(\S+)', text)
604
+ if not m:
605
+ m = re.search(r'(subnet_id\s*=\s*)(\S+)', text)
606
+ if not m:
607
+ return None
608
+
609
+ old_ref = m.group(2)
610
+ fake_ref = '"vpc-00000000"' if 'vpc_id' in m.group(1) else '"subnet-00000000"'
611
+ modified = text[:m.start(2)] + fake_ref + text[m.end(2):]
612
+
613
+ return modified, FaultInjection(
614
+ fault_type=None,
615
+ original_snippet=f"{m.group(1)}{old_ref}",
616
+ modified_snippet=f"{m.group(1)}{fake_ref}",
617
+ location=f"offset {m.start(2)}",
618
+ description=f"Replaced {m.group(1).strip()} reference with hardcoded invalid ID",
619
+ )
620
+
621
+
622
+ @register_tf_injector("CROSS_RESOURCE.wrong_az_reference")
623
+ def inject_wrong_az_reference(text: str, parsed: dict) -> tuple[str, FaultInjection] | None:
624
+ """Change availability zone to a mismatched value."""
625
+ m = re.search(r'(availability_zone\s*=\s*")([\w-]+)(")', text)
626
+ if not m:
627
+ return None
628
+
629
+ old_az = m.group(2)
630
+ # Pick a different AZ
631
+ wrong_azs = ["ap-southeast-99a", "eu-fictional-1b", "us-nowhere-2c"]
632
+ bad_az = random.choice(wrong_azs)
633
+ modified = text[:m.start(2)] + bad_az + text[m.end(2):]
634
+
635
+ return modified, FaultInjection(
636
+ fault_type=None,
637
+ original_snippet=old_az,
638
+ modified_snippet=bad_az,
639
+ location=f"availability_zone at offset {m.start(2)}",
640
+ description=f"Changed availability zone from {old_az} to {bad_az}",
641
+ )