awslabs.terraform-mcp-server 0.0.1__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.
Potentially problematic release.
This version of awslabs.terraform-mcp-server might be problematic. Click here for more details.
- awslabs/__init__.py +2 -0
- awslabs/terraform_mcp_server/__init__.py +3 -0
- awslabs/terraform_mcp_server/impl/resources/__init__.py +11 -0
- awslabs/terraform_mcp_server/impl/resources/terraform_aws_provider_resources_listing.py +52 -0
- awslabs/terraform_mcp_server/impl/resources/terraform_awscc_provider_resources_listing.py +55 -0
- awslabs/terraform_mcp_server/impl/tools/__init__.py +15 -0
- awslabs/terraform_mcp_server/impl/tools/execute_terraform_command.py +206 -0
- awslabs/terraform_mcp_server/impl/tools/run_checkov_scan.py +359 -0
- awslabs/terraform_mcp_server/impl/tools/search_aws_provider_docs.py +677 -0
- awslabs/terraform_mcp_server/impl/tools/search_awscc_provider_docs.py +627 -0
- awslabs/terraform_mcp_server/impl/tools/search_specific_aws_ia_modules.py +444 -0
- awslabs/terraform_mcp_server/impl/tools/utils.py +558 -0
- awslabs/terraform_mcp_server/models/__init__.py +27 -0
- awslabs/terraform_mcp_server/models/models.py +260 -0
- awslabs/terraform_mcp_server/scripts/generate_aws_provider_resources.py +1224 -0
- awslabs/terraform_mcp_server/scripts/generate_awscc_provider_resources.py +1020 -0
- awslabs/terraform_mcp_server/scripts/scrape_aws_terraform_best_practices.py +129 -0
- awslabs/terraform_mcp_server/server.py +329 -0
- awslabs/terraform_mcp_server/static/AWSCC_PROVIDER_RESOURCES.md +3125 -0
- awslabs/terraform_mcp_server/static/AWS_PROVIDER_RESOURCES.md +3833 -0
- awslabs/terraform_mcp_server/static/AWS_TERRAFORM_BEST_PRACTICES.md +2523 -0
- awslabs/terraform_mcp_server/static/MCP_INSTRUCTIONS.md +126 -0
- awslabs/terraform_mcp_server/static/TERRAFORM_WORKFLOW_GUIDE.md +198 -0
- awslabs/terraform_mcp_server/static/__init__.py +22 -0
- awslabs/terraform_mcp_server/tests/__init__.py +1 -0
- awslabs/terraform_mcp_server/tests/run_tests.sh +35 -0
- awslabs/terraform_mcp_server/tests/test_parameter_annotations.py +207 -0
- awslabs/terraform_mcp_server/tests/test_tool_implementations.py +309 -0
- awslabs_terraform_mcp_server-0.0.1.dist-info/METADATA +97 -0
- awslabs_terraform_mcp_server-0.0.1.dist-info/RECORD +32 -0
- awslabs_terraform_mcp_server-0.0.1.dist-info/WHEEL +4 -0
- awslabs_terraform_mcp_server-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""Utility functions for Terraform MCP server tools."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import requests
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from awslabs.terraform_mcp_server.models import SubmoduleInfo, TerraformVariable
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def clean_description(description: str) -> str:
|
|
14
|
+
"""Remove emoji characters from description strings.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
description: The module description text
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Cleaned description without emojis
|
|
21
|
+
"""
|
|
22
|
+
# This regex pattern targets common emoji Unicode ranges
|
|
23
|
+
emoji_pattern = re.compile(
|
|
24
|
+
'['
|
|
25
|
+
'\U0001f1e0-\U0001f1ff' # flags (iOS)
|
|
26
|
+
'\U0001f300-\U0001f5ff' # symbols & pictographs
|
|
27
|
+
'\U0001f600-\U0001f64f' # emoticons
|
|
28
|
+
'\U0001f680-\U0001f6ff' # transport & map symbols
|
|
29
|
+
'\U0001f700-\U0001f77f' # alchemical symbols
|
|
30
|
+
'\U0001f780-\U0001f7ff' # Geometric Shapes
|
|
31
|
+
'\U0001f800-\U0001f8ff' # Supplemental Arrows-C
|
|
32
|
+
'\U0001f900-\U0001f9ff' # Supplemental Symbols and Pictographs
|
|
33
|
+
'\U0001fa00-\U0001fa6f' # Chess Symbols
|
|
34
|
+
'\U0001fa70-\U0001faff' # Symbols and Pictographs Extended-A
|
|
35
|
+
'\U00002702-\U000027b0' # Dingbats
|
|
36
|
+
']+',
|
|
37
|
+
flags=re.UNICODE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Clean the description
|
|
41
|
+
return emoji_pattern.sub(r'', description).strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def get_github_release_details(owner: str, repo: str) -> Dict[str, Any]:
|
|
45
|
+
"""Fetch detailed release information from GitHub API.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
owner: The GitHub repository owner
|
|
49
|
+
repo: The GitHub repository name
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Dictionary containing version details and cleaned version string
|
|
53
|
+
"""
|
|
54
|
+
logger.info(f'Fetching GitHub release details for {owner}/{repo}')
|
|
55
|
+
|
|
56
|
+
# Try to get the latest release first
|
|
57
|
+
release_url = f'https://api.github.com/repos/{owner}/{repo}/releases/latest'
|
|
58
|
+
logger.debug(f'Making request to GitHub releases API: {release_url}')
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
response = requests.get(release_url)
|
|
62
|
+
logger.debug(f'GitHub releases API response code: {response.status_code}')
|
|
63
|
+
|
|
64
|
+
if response.status_code == 200:
|
|
65
|
+
release_data = response.json()
|
|
66
|
+
logger.info(f'Found latest GitHub release: {release_data.get("tag_name")}')
|
|
67
|
+
|
|
68
|
+
# Extract just the requested fields (tag name and publish date)
|
|
69
|
+
version_details = {
|
|
70
|
+
'tag_name': release_data.get('tag_name'),
|
|
71
|
+
'published_at': release_data.get('published_at'),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Use clean version for the module result
|
|
75
|
+
clean_version = release_data.get('tag_name', '')
|
|
76
|
+
if clean_version.startswith('v'):
|
|
77
|
+
clean_version = clean_version[1:]
|
|
78
|
+
|
|
79
|
+
logger.debug(f'Extracted version: {clean_version}')
|
|
80
|
+
|
|
81
|
+
return {'details': version_details, 'version': clean_version}
|
|
82
|
+
except Exception as ex:
|
|
83
|
+
logger.error(f'Error fetching GitHub release details: {ex}')
|
|
84
|
+
logger.debug(f'Stack trace: {traceback.format_exc()}')
|
|
85
|
+
|
|
86
|
+
# Fallback to tags if no releases found
|
|
87
|
+
tags_url = f'https://api.github.com/repos/{owner}/{repo}/tags'
|
|
88
|
+
logger.debug(f'No releases found, trying tags: {tags_url}')
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
response = requests.get(tags_url)
|
|
92
|
+
logger.debug(f'GitHub tags API response code: {response.status_code}')
|
|
93
|
+
|
|
94
|
+
if response.status_code == 200 and response.json():
|
|
95
|
+
tags_data = response.json()
|
|
96
|
+
if tags_data:
|
|
97
|
+
latest_tag = tags_data[0] # Tags are typically sorted newest first
|
|
98
|
+
logger.info(f'Found latest GitHub tag: {latest_tag.get("name")}')
|
|
99
|
+
|
|
100
|
+
version_details = {
|
|
101
|
+
'tag_name': latest_tag.get('name'),
|
|
102
|
+
'published_at': None, # Tags don't have publish dates in GitHub API
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Use clean version for the module result
|
|
106
|
+
clean_version = latest_tag.get('name', '')
|
|
107
|
+
if clean_version.startswith('v'):
|
|
108
|
+
clean_version = clean_version[1:]
|
|
109
|
+
|
|
110
|
+
logger.debug(f'Extracted version from tag: {clean_version}')
|
|
111
|
+
|
|
112
|
+
return {'details': version_details, 'version': clean_version}
|
|
113
|
+
except Exception as ex:
|
|
114
|
+
logger.error(f'Error fetching GitHub tags: {ex}')
|
|
115
|
+
logger.debug(f'Stack trace: {traceback.format_exc()}')
|
|
116
|
+
|
|
117
|
+
# Return empty details if nothing was found
|
|
118
|
+
logger.warning('No GitHub release or tag information found')
|
|
119
|
+
return {'details': {}, 'version': ''}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def get_submodules(owner: str, repo: str, branch: str = 'master') -> List[SubmoduleInfo]:
|
|
123
|
+
"""Fetch submodules from a module's GitHub repository.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
owner: GitHub repository owner
|
|
127
|
+
repo: GitHub repository name
|
|
128
|
+
branch: Branch name (default: master)
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
List of SubmoduleInfo objects
|
|
132
|
+
"""
|
|
133
|
+
logger.info(f'Checking for submodules in {owner}/{repo} ({branch} branch)')
|
|
134
|
+
submodules = []
|
|
135
|
+
|
|
136
|
+
# Check if modules directory exists
|
|
137
|
+
modules_url = f'https://api.github.com/repos/{owner}/{repo}/contents/modules?ref={branch}'
|
|
138
|
+
logger.debug(f'Checking for modules directory: {modules_url}')
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
# Get list of directories in /modules
|
|
142
|
+
start_time = time.time()
|
|
143
|
+
response = requests.get(
|
|
144
|
+
modules_url,
|
|
145
|
+
headers={'Accept': 'application/vnd.github.v3+json'},
|
|
146
|
+
timeout=3.0, # Add timeout
|
|
147
|
+
)
|
|
148
|
+
logger.debug(f'GitHub API request took {time.time() - start_time:.2f} seconds')
|
|
149
|
+
|
|
150
|
+
if response.status_code == 404:
|
|
151
|
+
logger.debug(f'No modules directory found in {branch} branch')
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
if response.status_code == 403:
|
|
155
|
+
logger.warning(f'GitHub API rate limit reached, status: {response.status_code}')
|
|
156
|
+
# Return empty list but don't fail completely
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
if response.status_code != 200:
|
|
160
|
+
logger.warning(f'Failed to get modules directory: status {response.status_code}')
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
modules_list = response.json()
|
|
164
|
+
if not isinstance(modules_list, list):
|
|
165
|
+
logger.warning('Unexpected API response format for modules listing')
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
# Filter for directories only
|
|
169
|
+
submodule_dirs = [item for item in modules_list if item.get('type') == 'dir']
|
|
170
|
+
logger.info(f'Found {len(submodule_dirs)} potential submodules')
|
|
171
|
+
|
|
172
|
+
# Process submodules with concurrency limits
|
|
173
|
+
# Only process up to 5 submodules to avoid timeouts
|
|
174
|
+
max_submodules = min(len(submodule_dirs), 5)
|
|
175
|
+
logger.info(f'Processing {max_submodules} out of {len(submodule_dirs)} submodules')
|
|
176
|
+
|
|
177
|
+
# Process each submodule
|
|
178
|
+
for i, submodule in enumerate(submodule_dirs[:max_submodules]):
|
|
179
|
+
name = submodule.get('name')
|
|
180
|
+
path = submodule.get('path', f'modules/{name}')
|
|
181
|
+
|
|
182
|
+
# Create basic submodule info
|
|
183
|
+
submodule_info = SubmoduleInfo(
|
|
184
|
+
name=name,
|
|
185
|
+
path=path,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Add a slight delay between API requests to avoid rate limiting
|
|
189
|
+
if i > 0:
|
|
190
|
+
await asyncio.sleep(0.2) # 200ms delay between requests
|
|
191
|
+
|
|
192
|
+
# Try to get README content
|
|
193
|
+
readme_url = (
|
|
194
|
+
f'https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}/README.md'
|
|
195
|
+
)
|
|
196
|
+
logger.debug(f'Fetching README for submodule {name}: {readme_url}')
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
start_time = time.time()
|
|
200
|
+
readme_response = requests.get(readme_url, timeout=2.0) # Add timeout
|
|
201
|
+
logger.debug(f'README fetch took {time.time() - start_time:.2f} seconds')
|
|
202
|
+
|
|
203
|
+
if readme_response.status_code == 200:
|
|
204
|
+
readme_content = readme_response.text
|
|
205
|
+
# Truncate if too long
|
|
206
|
+
if len(readme_content) > 8000:
|
|
207
|
+
readme_content = (
|
|
208
|
+
readme_content[:8000] + '...\n[README truncated due to length]'
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Extract description from first paragraph if available
|
|
212
|
+
description = extract_description_from_readme(readme_content)
|
|
213
|
+
if description:
|
|
214
|
+
submodule_info.description = description
|
|
215
|
+
|
|
216
|
+
submodule_info.readme_content = readme_content
|
|
217
|
+
logger.debug(
|
|
218
|
+
f'Found README for submodule {name} ({len(readme_content)} chars)'
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
logger.debug(
|
|
222
|
+
f'No README found for submodule {name}, status: {readme_response.status_code}'
|
|
223
|
+
)
|
|
224
|
+
# Try lowercase readme.md as fallback
|
|
225
|
+
lowercase_readme_url = f'https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}/readme.md'
|
|
226
|
+
logger.debug(f'Trying lowercase readme.md: {lowercase_readme_url}')
|
|
227
|
+
|
|
228
|
+
lowercase_response = requests.get(lowercase_readme_url, timeout=2.0)
|
|
229
|
+
if lowercase_response.status_code == 200:
|
|
230
|
+
readme_content = lowercase_response.text
|
|
231
|
+
if len(readme_content) > 8000:
|
|
232
|
+
readme_content = (
|
|
233
|
+
readme_content[:8000] + '...\n[README truncated due to length]'
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
description = extract_description_from_readme(readme_content)
|
|
237
|
+
if description:
|
|
238
|
+
submodule_info.description = description
|
|
239
|
+
|
|
240
|
+
submodule_info.readme_content = readme_content
|
|
241
|
+
logger.debug(
|
|
242
|
+
f'Found lowercase readme.md for {name} ({len(readme_content)} chars)'
|
|
243
|
+
)
|
|
244
|
+
except Exception as ex:
|
|
245
|
+
logger.error(f'Error fetching README for submodule {name}: {ex}')
|
|
246
|
+
|
|
247
|
+
# Add the submodule to our result list
|
|
248
|
+
submodules.append(submodule_info)
|
|
249
|
+
|
|
250
|
+
if len(submodule_dirs) > max_submodules:
|
|
251
|
+
logger.warning(
|
|
252
|
+
f'Only processed {max_submodules} out of {len(submodule_dirs)} submodules to avoid timeouts'
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return submodules
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.error(f'Error fetching submodules: {e}')
|
|
259
|
+
logger.debug(f'Stack trace: {traceback.format_exc()}')
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def extract_description_from_readme(readme_content: str) -> Optional[str]:
|
|
264
|
+
"""Extract a short description from the README content.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
readme_content: The README markdown content
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Short description or None if not found
|
|
271
|
+
"""
|
|
272
|
+
if not readme_content:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
# Try to find the first paragraph after any headings
|
|
276
|
+
lines = readme_content.split('\n')
|
|
277
|
+
paragraph_text = []
|
|
278
|
+
|
|
279
|
+
for line in lines:
|
|
280
|
+
# Skip headings, horizontal rules and blank lines
|
|
281
|
+
if line.startswith('#') or line.startswith('---') or not line.strip():
|
|
282
|
+
# If we already found a paragraph, return it
|
|
283
|
+
if paragraph_text:
|
|
284
|
+
break
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
# Found text content, add to paragraph
|
|
288
|
+
paragraph_text.append(line)
|
|
289
|
+
|
|
290
|
+
# If this line ends a paragraph, break
|
|
291
|
+
if not line.endswith('\\') and len(paragraph_text) > 0:
|
|
292
|
+
break
|
|
293
|
+
|
|
294
|
+
if paragraph_text:
|
|
295
|
+
description = ' '.join(paragraph_text).strip()
|
|
296
|
+
# Limit to 200 chars max
|
|
297
|
+
if len(description) > 200:
|
|
298
|
+
description = description[:197] + '...'
|
|
299
|
+
return description
|
|
300
|
+
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def extract_outputs_from_readme(readme_content: str) -> List[Dict[str, str]]:
|
|
305
|
+
"""Extract module outputs from the README content.
|
|
306
|
+
|
|
307
|
+
Looks for the Outputs section in the README, which is typically at the bottom
|
|
308
|
+
of the file and contains a table of outputs with descriptions.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
readme_content: The README markdown content
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
List of dictionaries containing output name and description
|
|
315
|
+
"""
|
|
316
|
+
if not readme_content:
|
|
317
|
+
return []
|
|
318
|
+
|
|
319
|
+
outputs = []
|
|
320
|
+
|
|
321
|
+
# Find the Outputs section
|
|
322
|
+
lines = readme_content.split('\n')
|
|
323
|
+
in_outputs_section = False
|
|
324
|
+
in_outputs_table = False
|
|
325
|
+
|
|
326
|
+
for i, line in enumerate(lines):
|
|
327
|
+
# Look for Outputs heading
|
|
328
|
+
if re.match(r'^#+\s+Outputs?$', line, re.IGNORECASE):
|
|
329
|
+
in_outputs_section = True
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
# If we're in the outputs section, look for the table header
|
|
333
|
+
if in_outputs_section and not in_outputs_table:
|
|
334
|
+
if '|' in line and ('Name' in line or 'Output' in line) and 'Description' in line:
|
|
335
|
+
in_outputs_table = True
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
# If we're in the outputs table, parse each row
|
|
339
|
+
if in_outputs_section and in_outputs_table:
|
|
340
|
+
# Skip the table header separator line
|
|
341
|
+
if line.strip().startswith('|') and all(c in '|-: ' for c in line):
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
# If we hit another heading or the table ends, stop parsing
|
|
345
|
+
if line.strip().startswith('#') or not line.strip() or '|' not in line:
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
# Parse the table row
|
|
349
|
+
if '|' in line:
|
|
350
|
+
parts = [part.strip() for part in line.split('|')]
|
|
351
|
+
if len(parts) >= 3: # Should have at least empty, name, description columns
|
|
352
|
+
name_part = parts[1].strip()
|
|
353
|
+
desc_part = parts[2].strip()
|
|
354
|
+
|
|
355
|
+
# Clean up any markdown formatting
|
|
356
|
+
name = re.sub(r'`(.*?)`', r'\1', name_part).strip()
|
|
357
|
+
description = re.sub(r'`(.*?)`', r'\1', desc_part).strip()
|
|
358
|
+
|
|
359
|
+
if name:
|
|
360
|
+
outputs.append({'name': name, 'description': description})
|
|
361
|
+
|
|
362
|
+
# If we didn't find a table, try looking for a list format
|
|
363
|
+
if not outputs and in_outputs_section:
|
|
364
|
+
for line in lines:
|
|
365
|
+
# If we hit another heading, stop parsing
|
|
366
|
+
if line.strip().startswith('#'):
|
|
367
|
+
break
|
|
368
|
+
|
|
369
|
+
# Look for list items that might be outputs
|
|
370
|
+
list_match = re.match(r'^[-*]\s+`([^`]+)`\s*[-:]\s*(.+)$', line)
|
|
371
|
+
if list_match:
|
|
372
|
+
name = list_match.group(1).strip()
|
|
373
|
+
description = list_match.group(2).strip()
|
|
374
|
+
|
|
375
|
+
outputs.append({'name': name, 'description': description})
|
|
376
|
+
|
|
377
|
+
logger.debug(f'Extracted {len(outputs)} outputs from README')
|
|
378
|
+
return outputs
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
async def get_variables_tf(
|
|
382
|
+
owner: str, repo: str, branch: str = 'main'
|
|
383
|
+
) -> Tuple[Optional[str], Optional[List[TerraformVariable]]]:
|
|
384
|
+
"""Fetch and parse the variables.tf file from a GitHub repository.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
owner: GitHub repository owner
|
|
388
|
+
repo: GitHub repository name
|
|
389
|
+
branch: Branch name (default: main)
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Tuple containing the raw variables.tf content and a list of parsed TerraformVariable objects
|
|
393
|
+
"""
|
|
394
|
+
logger.info(f'Fetching variables.tf from {owner}/{repo} ({branch} branch)')
|
|
395
|
+
|
|
396
|
+
# Try to get the variables.tf file
|
|
397
|
+
variables_url = f'https://raw.githubusercontent.com/{owner}/{repo}/{branch}/variables.tf'
|
|
398
|
+
logger.debug(f'Fetching variables.tf: {variables_url}')
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
start_time = time.time()
|
|
402
|
+
response = requests.get(variables_url, timeout=3.0)
|
|
403
|
+
logger.debug(f'variables.tf fetch took {time.time() - start_time:.2f} seconds')
|
|
404
|
+
|
|
405
|
+
if response.status_code == 200:
|
|
406
|
+
variables_content = response.text
|
|
407
|
+
logger.info(f'Found variables.tf ({len(variables_content)} chars)')
|
|
408
|
+
|
|
409
|
+
# Parse the variables.tf file
|
|
410
|
+
variables = parse_variables_tf(variables_content)
|
|
411
|
+
logger.info(f'Parsed {len(variables)} variables from variables.tf')
|
|
412
|
+
|
|
413
|
+
return variables_content, variables
|
|
414
|
+
else:
|
|
415
|
+
logger.debug(
|
|
416
|
+
f'No variables.tf found at {branch} branch, status: {response.status_code}'
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Try master branch as fallback
|
|
420
|
+
if branch != 'master':
|
|
421
|
+
logger.debug('Trying master branch for variables.tf')
|
|
422
|
+
master_variables_url = (
|
|
423
|
+
f'https://raw.githubusercontent.com/{owner}/{repo}/master/variables.tf'
|
|
424
|
+
)
|
|
425
|
+
master_response = requests.get(master_variables_url, timeout=3.0)
|
|
426
|
+
|
|
427
|
+
if master_response.status_code == 200:
|
|
428
|
+
variables_content = master_response.text
|
|
429
|
+
logger.info(
|
|
430
|
+
f'Found variables.tf in master branch ({len(variables_content)} chars)'
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Parse the variables.tf file
|
|
434
|
+
variables = parse_variables_tf(variables_content)
|
|
435
|
+
logger.info(f'Parsed {len(variables)} variables from variables.tf')
|
|
436
|
+
|
|
437
|
+
return variables_content, variables
|
|
438
|
+
except Exception as ex:
|
|
439
|
+
logger.error(f'Error fetching variables.tf: {ex}')
|
|
440
|
+
logger.debug(f'Stack trace: {traceback.format_exc()}')
|
|
441
|
+
|
|
442
|
+
return None, None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def parse_variables_tf(content: str) -> List[TerraformVariable]:
|
|
446
|
+
"""Parse variables.tf content to extract variable definitions.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
content: The content of the variables.tf file
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
List of TerraformVariable objects
|
|
453
|
+
"""
|
|
454
|
+
if not content:
|
|
455
|
+
return []
|
|
456
|
+
|
|
457
|
+
variables = []
|
|
458
|
+
|
|
459
|
+
# Simple regex pattern to match variable blocks
|
|
460
|
+
# This is a simplified approach and may not handle all complex HCL syntax
|
|
461
|
+
variable_blocks = re.finditer(r'variable\s+"([^"]+)"\s*{([^}]+)}', content, re.DOTALL)
|
|
462
|
+
|
|
463
|
+
for match in variable_blocks:
|
|
464
|
+
var_name = match.group(1)
|
|
465
|
+
var_block = match.group(2)
|
|
466
|
+
|
|
467
|
+
# Initialize variable with name
|
|
468
|
+
variable = TerraformVariable(name=var_name)
|
|
469
|
+
|
|
470
|
+
# Extract type
|
|
471
|
+
type_match = re.search(r'type\s*=\s*(.+?)($|\n)', var_block)
|
|
472
|
+
if type_match:
|
|
473
|
+
variable.type = type_match.group(1).strip()
|
|
474
|
+
|
|
475
|
+
# Extract description
|
|
476
|
+
desc_match = re.search(r'description\s*=\s*"([^"]+)"', var_block)
|
|
477
|
+
if desc_match:
|
|
478
|
+
variable.description = desc_match.group(1).strip()
|
|
479
|
+
|
|
480
|
+
# Check for default value
|
|
481
|
+
default_match = re.search(r'default\s*=\s*(.+?)($|\n)', var_block)
|
|
482
|
+
if default_match:
|
|
483
|
+
default_value = default_match.group(1).strip()
|
|
484
|
+
variable.default = default_value
|
|
485
|
+
variable.required = False
|
|
486
|
+
|
|
487
|
+
variables.append(variable)
|
|
488
|
+
|
|
489
|
+
return variables
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# Security-related constants and utilities
|
|
493
|
+
# These are used to prevent command injection and other security issues
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def get_dangerous_patterns() -> List[str]:
|
|
497
|
+
"""Get a list of dangerous patterns for command injection detection.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
List of dangerous patterns to check for
|
|
501
|
+
"""
|
|
502
|
+
# Dangerous patterns that could indicate command injection attempts
|
|
503
|
+
# Separated by platform for better organization and maintainability
|
|
504
|
+
patterns = [
|
|
505
|
+
'|',
|
|
506
|
+
';',
|
|
507
|
+
'&',
|
|
508
|
+
'&&',
|
|
509
|
+
'||', # Command chaining
|
|
510
|
+
'>',
|
|
511
|
+
'>>',
|
|
512
|
+
'<', # Redirection
|
|
513
|
+
'`',
|
|
514
|
+
'$(', # Command substitution
|
|
515
|
+
'--', # Double dash options
|
|
516
|
+
'rm',
|
|
517
|
+
'mv',
|
|
518
|
+
'cp', # Potentially dangerous commands
|
|
519
|
+
'/bin/',
|
|
520
|
+
'/usr/bin/', # Path references
|
|
521
|
+
'../',
|
|
522
|
+
'./', # Directory traversal
|
|
523
|
+
# Unix/Linux specific dangerous patterns
|
|
524
|
+
'sudo', # Privilege escalation
|
|
525
|
+
'chmod',
|
|
526
|
+
'chown', # File permission changes
|
|
527
|
+
'su', # Switch user
|
|
528
|
+
'bash',
|
|
529
|
+
'sh',
|
|
530
|
+
'zsh', # Shell execution
|
|
531
|
+
'curl',
|
|
532
|
+
'wget', # Network access
|
|
533
|
+
'ssh',
|
|
534
|
+
'scp', # Remote access
|
|
535
|
+
'eval', # Command evaluation
|
|
536
|
+
'exec', # Command execution
|
|
537
|
+
'source', # Script sourcing
|
|
538
|
+
# Windows specific dangerous patterns
|
|
539
|
+
'cmd',
|
|
540
|
+
'powershell',
|
|
541
|
+
'pwsh', # Command shells
|
|
542
|
+
'net', # Network commands
|
|
543
|
+
'reg', # Registry access
|
|
544
|
+
'runas', # Privilege escalation
|
|
545
|
+
'del',
|
|
546
|
+
'rmdir', # File deletion
|
|
547
|
+
'start', # Process execution
|
|
548
|
+
'taskkill', # Process termination
|
|
549
|
+
'sc', # Service control
|
|
550
|
+
'schtasks', # Scheduled tasks
|
|
551
|
+
'wmic', # WMI commands
|
|
552
|
+
'%SYSTEMROOT%',
|
|
553
|
+
'%WINDIR%', # System directories
|
|
554
|
+
'.bat',
|
|
555
|
+
'.cmd',
|
|
556
|
+
'.ps1', # Script files
|
|
557
|
+
]
|
|
558
|
+
return patterns
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .models import (
|
|
2
|
+
ModuleSearchResult,
|
|
3
|
+
TerraformAWSProviderDocsResult,
|
|
4
|
+
TerraformAWSCCProviderDocsResult,
|
|
5
|
+
SubmoduleInfo,
|
|
6
|
+
TerraformExecutionRequest,
|
|
7
|
+
TerraformExecutionResult,
|
|
8
|
+
CheckovVulnerability,
|
|
9
|
+
CheckovScanRequest,
|
|
10
|
+
CheckovScanResult,
|
|
11
|
+
TerraformVariable,
|
|
12
|
+
TerraformOutput,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
'ModuleSearchResult',
|
|
17
|
+
'TerraformAWSProviderDocsResult',
|
|
18
|
+
'TerraformAWSCCProviderDocsResult',
|
|
19
|
+
'SubmoduleInfo',
|
|
20
|
+
'TerraformExecutionRequest',
|
|
21
|
+
'TerraformExecutionResult',
|
|
22
|
+
'CheckovVulnerability',
|
|
23
|
+
'CheckovScanRequest',
|
|
24
|
+
'CheckovScanResult',
|
|
25
|
+
'TerraformVariable',
|
|
26
|
+
'TerraformOutput',
|
|
27
|
+
]
|