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,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
|
+
)
|