awslabs.well-architected-security-mcp-server 0.1.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.
@@ -0,0 +1,1618 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Utility functions for checking AWS security services and retrieving findings."""
16
+
17
+ import datetime
18
+ import json
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ import boto3
22
+ from botocore.config import Config
23
+ from mcp.server.fastmcp import Context
24
+
25
+ from awslabs.well_architected_security_mcp_server import __version__
26
+
27
+ # User agent configuration for AWS API calls
28
+ USER_AGENT_CONFIG = Config(
29
+ user_agent_extra=f"awslabs/mcp/well-architected-security-mcp-server/{__version__}"
30
+ )
31
+
32
+
33
+ async def get_analyzer_findings_count(
34
+ analyzer_arn: str, analyzer_client: Any, ctx: Context
35
+ ) -> str:
36
+ """Get the number of findings for an IAM Access Analyzer.
37
+
38
+ Args:
39
+ analyzer_arn: ARN of the IAM Access Analyzer
40
+ analyzer_client: boto3 client for Access Analyzer
41
+ ctx: MCP context for error reporting
42
+
43
+ Returns:
44
+ Count of findings as string, or "Unknown" if there was an error
45
+ """
46
+ try:
47
+ response = analyzer_client.list_findings(analyzerArn=analyzer_arn)
48
+ return str(len(response.get("findings", [])))
49
+ except Exception as e:
50
+ await ctx.warning(f"Error getting findings count: {e}")
51
+ return "Unknown"
52
+
53
+
54
+ async def check_access_analyzer(region: str, session: boto3.Session, ctx: Context) -> Dict:
55
+ """Check if IAM Access Analyzer is enabled in the specified region.
56
+
57
+ Args:
58
+ region: AWS region to check
59
+ session: boto3 Session for AWS API calls
60
+ ctx: MCP context for error reporting
61
+
62
+ Returns:
63
+ Dictionary with status information about IAM Access Analyzer
64
+ """
65
+ try:
66
+ analyzer_client = session.client(
67
+ "accessanalyzer", region_name=region, config=USER_AGENT_CONFIG
68
+ )
69
+ response = analyzer_client.list_analyzers()
70
+
71
+ # Extract analyzers - verify the field exists to prevent KeyError
72
+ flag = True
73
+ if "analyzers" not in response:
74
+ flag = False
75
+ elif len(response["analyzers"]) == 0:
76
+ flag = False
77
+
78
+ if not flag:
79
+ return {
80
+ "enabled": False,
81
+ "analyzers": [],
82
+ "debug_info": {"raw_response": response},
83
+ "setup_instructions": """
84
+ # IAM Access Analyzer Setup Instructions
85
+
86
+ IAM Access Analyzer is not enabled in this region. To enable it:
87
+
88
+ 1. Open the IAM console: https://console.aws.amazon.com/iam/
89
+ 2. Choose Access analyzer
90
+ 3. Choose Create analyzer
91
+ 4. Enter a name for the analyzer
92
+ 5. Choose the type of analyzer (account or organization)
93
+ 6. Choose Create analyzer
94
+
95
+ This is strongly recommended before proceeding with the security review.
96
+
97
+ Learn more: https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html
98
+ """,
99
+ "message": "IAM Access Analyzer is not enabled in this region.",
100
+ }
101
+
102
+ analyzers = response.get("analyzers", [])
103
+
104
+ # Check if any of the analyzers are active
105
+ active_analyzers = [a for a in analyzers if a.get("status") == "ACTIVE"]
106
+
107
+ # Access Analyzer is enabled if there's at least one analyzer, even if not all are ACTIVE
108
+ analyzer_details = []
109
+ for analyzer in analyzers:
110
+ analyzer_arn = analyzer.get("arn")
111
+ if analyzer_arn:
112
+ try:
113
+ findings_count = await get_analyzer_findings_count(
114
+ analyzer_arn, analyzer_client, ctx
115
+ )
116
+
117
+ except Exception:
118
+ findings_count = "Error"
119
+ else:
120
+ findings_count = "Unknown (No ARN)"
121
+
122
+ analyzer_details.append(
123
+ {
124
+ "name": analyzer.get("name"),
125
+ "type": analyzer.get("type"),
126
+ "status": analyzer.get("status"),
127
+ "created_at": str(analyzer.get("createdAt")),
128
+ "findings_count": findings_count,
129
+ }
130
+ )
131
+
132
+ # Consider IAM Access Analyzer enabled if there's at least one analyzer, even if not all are ACTIVE
133
+ return {
134
+ "enabled": True,
135
+ "analyzers": analyzer_details,
136
+ "message": f"IAM Access Analyzer is enabled with {len(analyzers)} analyzer(s) ({len(active_analyzers)} active).",
137
+ }
138
+ except Exception as e:
139
+ await ctx.error(f"Error checking IAM Access Analyzer status: {e}")
140
+ return {
141
+ "enabled": False,
142
+ "error": str(e),
143
+ "message": "Error checking IAM Access Analyzer status.",
144
+ }
145
+
146
+
147
+ async def check_security_hub(region: str, session: boto3.Session, ctx: Context) -> Dict:
148
+ """Check if AWS Security Hub is enabled in the specified region.
149
+
150
+ Args:
151
+ region: AWS region to check
152
+ session: boto3 Session for AWS API calls
153
+ ctx: MCP context for error reporting
154
+
155
+ Returns:
156
+ Dictionary with status information about AWS Security Hub
157
+ """
158
+ try:
159
+ # Create Security Hub client
160
+ securityhub_client = session.client(
161
+ "securityhub", region_name=region, config=USER_AGENT_CONFIG
162
+ )
163
+
164
+ try:
165
+ # Check if Security Hub is enabled
166
+ hub_response = securityhub_client.describe_hub()
167
+
168
+ # Security Hub is enabled, get enabled standards
169
+ try:
170
+ standards_response = securityhub_client.get_enabled_standards()
171
+ standards = standards_response.get("StandardsSubscriptions", [])
172
+
173
+ # Safely process standards with better error handling
174
+ processed_standards = []
175
+ for standard in standards:
176
+ try:
177
+ standard_name = standard.get("StandardsArn", "").split("/")[-1]
178
+ standard_status = standard.get("StandardsStatus", "UNKNOWN")
179
+
180
+ # Handle the nested structure carefully
181
+ enabled_at = ""
182
+ if "StandardsSubscriptionArn" in standard:
183
+ # Sometimes EnabledAt is in the root or might not exist
184
+ enabled_at = str(standard.get("EnabledAt", ""))
185
+
186
+ processed_standards.append(
187
+ {
188
+ "name": standard_name,
189
+ "status": standard_status,
190
+ "enabled_at": enabled_at,
191
+ }
192
+ )
193
+ except Exception:
194
+ pass
195
+
196
+ return {
197
+ "enabled": True,
198
+ "standards": processed_standards,
199
+ "message": f"Security Hub is enabled with {len(standards)} standards.",
200
+ "debug_info": {
201
+ "hub_arn": hub_response.get("HubArn", "Unknown"),
202
+ "standards_count": len(standards),
203
+ },
204
+ }
205
+ except Exception as std_ex:
206
+ # Security Hub is enabled but we couldn't get standards
207
+ return {
208
+ "enabled": True,
209
+ "standards": [],
210
+ "message": "Security Hub is enabled but there was an error retrieving standards.",
211
+ "debug_info": {
212
+ "hub_arn": hub_response.get("HubArn", "Unknown"),
213
+ "error_getting_standards": str(std_ex),
214
+ },
215
+ }
216
+
217
+ except securityhub_client.exceptions.InvalidAccessException:
218
+ # Security Hub is not enabled
219
+ return {
220
+ "enabled": False,
221
+ "standards": [],
222
+ "setup_instructions": """
223
+ # AWS Security Hub Setup Instructions
224
+ AWS Security Hub is not enabled in this region. To enable it:
225
+ https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-get-started.html
226
+ """,
227
+ "message": "AWS Security Hub is not enabled in this region.",
228
+ }
229
+ except securityhub_client.exceptions.ResourceNotFoundException:
230
+ # Hub not found - not enabled
231
+ return {
232
+ "enabled": False,
233
+ "standards": [],
234
+ "setup_instructions": """
235
+ # AWS Security Hub Setup Instructions
236
+
237
+ AWS Security Hub is not enabled in this region. To enable it:
238
+
239
+ 1. Open the Security Hub console: https://console.aws.amazon.com/securityhub/
240
+ 2. Choose Go to Security Hub
241
+ 3. Configure your security standards
242
+ 4. Choose Enable Security Hub
243
+
244
+ This is strongly recommended for maintaining security best practices.
245
+
246
+ Learn more: https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-get-started.html
247
+ """,
248
+ "message": "AWS Security Hub is not enabled in this region.",
249
+ }
250
+ except Exception as e:
251
+ return {
252
+ "enabled": False,
253
+ "error": str(e),
254
+ "message": "Error checking Security Hub status.",
255
+ "debug_info": {"exception": str(e), "exception_type": type(e).__name__},
256
+ }
257
+
258
+
259
+ async def check_guard_duty(region: str, session: boto3.Session, ctx: Context) -> Dict:
260
+ """Check if Amazon GuardDuty is enabled in the specified region.
261
+
262
+ Args:
263
+ region: AWS region to check
264
+ session: boto3 Session for AWS API calls
265
+ ctx: MCP context for error reporting
266
+
267
+ Returns:
268
+ Dictionary with status information about Amazon GuardDuty
269
+ """
270
+ try:
271
+ # Create GuardDuty client
272
+ guardduty_client = session.client(
273
+ "guardduty", region_name=region, config=USER_AGENT_CONFIG
274
+ )
275
+
276
+ # List detectors
277
+ detector_response = guardduty_client.list_detectors()
278
+ detector_ids = detector_response.get("DetectorIds", [])
279
+
280
+ if not detector_ids:
281
+ # GuardDuty is not enabled
282
+ return {
283
+ "enabled": False,
284
+ "detector_details": {},
285
+ "setup_instructions": """
286
+ # Amazon GuardDuty Setup Instructions
287
+
288
+ Amazon GuardDuty is not enabled in this region. To enable it:
289
+
290
+ 1. Open the GuardDuty console: https://console.aws.amazon.com/guardduty/
291
+ 2. Choose Get Started
292
+ 3. Choose Enable GuardDuty
293
+
294
+ This is strongly recommended for detecting threats to your AWS environment.
295
+
296
+ Learn more: https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_settingup.html
297
+ """,
298
+ "message": "Amazon GuardDuty is not enabled in this region.",
299
+ }
300
+
301
+ # GuardDuty is enabled, get detector details
302
+ detector_id = detector_ids[0] # Use the first detector
303
+ detector_details = guardduty_client.get_detector(DetectorId=detector_id)
304
+
305
+ return {
306
+ "enabled": True,
307
+ "detector_details": {
308
+ "id": detector_id,
309
+ "status": "ENABLED",
310
+ "finding_publishing_frequency": detector_details.get("FindingPublishingFrequency"),
311
+ "data_sources": detector_details.get("DataSources"),
312
+ "features": detector_details.get("Features", []),
313
+ },
314
+ "message": "Amazon GuardDuty is enabled and active.",
315
+ }
316
+ except Exception as e:
317
+ await ctx.error(f"Error checking GuardDuty status: {e}")
318
+ return {"enabled": False, "error": str(e), "message": "Error checking GuardDuty status."}
319
+
320
+
321
+ async def check_inspector(region: str, session: boto3.Session, ctx: Context) -> Dict:
322
+ """Check if Amazon Inspector is enabled in the specified region.
323
+
324
+ Args:
325
+ region: AWS region to check
326
+ session: boto3 Session for AWS API calls
327
+ ctx: MCP context for error reporting
328
+
329
+ Returns:
330
+ Dictionary with status information about Amazon Inspector
331
+ """
332
+ try:
333
+ # Create Inspector client (using inspector2)
334
+ inspector_client = session.client(
335
+ "inspector2", region_name=region, config=USER_AGENT_CONFIG
336
+ )
337
+
338
+ try:
339
+ # Get Inspector status
340
+ try:
341
+ # First try using get_status API
342
+ status_response = inspector_client.get_status()
343
+ print(
344
+ f"[DEBUG:Inspector] get_status() successful, raw response: {status_response}"
345
+ )
346
+
347
+ # If we can call get_status successfully, Inspector2 is enabled
348
+ # Now we need to determine which scan types are enabled
349
+
350
+ # The service exists and is enabled at this point, since get_status worked
351
+ is_enabled = True
352
+
353
+ # Attempt to extract status from different possible response structures
354
+ status = {}
355
+
356
+ # Check all possible paths where status might be located
357
+ if isinstance(status_response, dict):
358
+ # Direct status fields in response root
359
+ for scan_type in ["EC2", "ECR", "LAMBDA", "ec2", "ecr", "lambda"]:
360
+ # Try all possible field name patterns for each scan type
361
+ for field_pattern in [
362
+ f"{scan_type}Status",
363
+ f"{scan_type.lower()}Status",
364
+ f"{scan_type}_status",
365
+ f"{scan_type.lower()}_status",
366
+ scan_type,
367
+ scan_type.lower(),
368
+ ]:
369
+ if field_pattern in status_response:
370
+ status[field_pattern] = status_response[field_pattern]
371
+
372
+ # Try the 'status' nested object too
373
+ if "status" in status_response and isinstance(status_response["status"], dict):
374
+ for key, value in status_response["status"].items():
375
+ # Avoid duplicates if we've already found this info
376
+ if key not in status:
377
+ status[key] = value
378
+
379
+ print(f"[DEBUG:Inspector] Extracted status fields: {status}")
380
+
381
+ # Check for enabled scan types
382
+ scan_types = ["EC2", "ECR", "LAMBDA"]
383
+ enabled_scans = []
384
+
385
+ for scan_type in scan_types:
386
+ found_enabled = False
387
+ # Check all possible status keys for this scan type
388
+ for status_key in [
389
+ f"{scan_type}Status",
390
+ f"{scan_type.lower()}Status",
391
+ f"{scan_type}_status",
392
+ f"{scan_type.lower()}_status",
393
+ scan_type,
394
+ scan_type.lower(),
395
+ ]:
396
+ status_value = None
397
+
398
+ # Try direct key in status dictionary
399
+ if status_key in status:
400
+ status_value = status[status_key]
401
+ print(
402
+ f"[DEBUG:Inspector] Found status for {scan_type} via key {status_key}: {status_value}"
403
+ )
404
+
405
+ # Check if the status value indicates "enabled"
406
+ if status_value and (
407
+ (isinstance(status_value, str) and status_value.upper() == "ENABLED")
408
+ or (isinstance(status_value, bool) and status_value is True)
409
+ ):
410
+ enabled_scans.append(scan_type)
411
+ found_enabled = True
412
+ print(f"[DEBUG:Inspector] {scan_type} scan type is ENABLED")
413
+ break
414
+
415
+ if not found_enabled:
416
+ # If we haven't found an "enabled" status for this scan type, try one more approach
417
+ # Looking for any key that contains the scan type name and has "enabled" value
418
+ for status_key, status_value in status.items():
419
+ if (
420
+ scan_type.lower() in status_key.lower()
421
+ and isinstance(status_value, str)
422
+ and "enable" in status_value.lower()
423
+ ):
424
+ enabled_scans.append(scan_type)
425
+ print(
426
+ f"[DEBUG:Inspector] {scan_type} scan type is potentially enabled via fuzzy match"
427
+ )
428
+ break
429
+
430
+ print(f"[DEBUG:Inspector] Final enabled scan types: {enabled_scans}")
431
+
432
+ # Build the scan status dictionary
433
+ scan_status = {}
434
+ for scan_type in scan_types:
435
+ scan_found = False
436
+ scan_status_key = f"{scan_type.lower()}_status"
437
+
438
+ # Look for this scan type in the status dictionary
439
+ for status_key, status_value in status.items():
440
+ if scan_type.lower() in status_key.lower():
441
+ scan_status[scan_status_key] = status_value
442
+ scan_found = True
443
+ break
444
+
445
+ # If no matching key found, indicate unknown
446
+ if not scan_found:
447
+ scan_status[scan_status_key] = "UNKNOWN"
448
+
449
+ # By this point, if we successfully called get_status, the service itself is enabled
450
+ # Even if no scan types are explicitly shown as enabled
451
+ return {
452
+ "enabled": is_enabled,
453
+ "scan_status": scan_status,
454
+ "message": f"Amazon Inspector is enabled with the following scan types: {', '.join(enabled_scans) if enabled_scans else 'unknown'}",
455
+ }
456
+
457
+ except Exception as status_error:
458
+ # log the error but continue with the alternative checks
459
+ print(f"[DEBUG:Inspector] get_status() error: {status_error}")
460
+ await ctx.warning(f"Error calling Inspector2 get_status(): {status_error}")
461
+
462
+ # If get_status failed or didn't find scan types, try another approach
463
+ # Try calling batch_get_account_status which may give different information
464
+ try:
465
+ account_status = inspector_client.batch_get_account_status()
466
+
467
+ # If we get here, the service is enabled
468
+ if "accounts" in account_status and account_status["accounts"]:
469
+ account_info = account_status["accounts"][0]
470
+ resource_status = account_info.get("resourceStatus", {})
471
+
472
+ # Check which resources are enabled
473
+ ec2_enabled = resource_status.get("ec2", {}).get("status") == "ENABLED"
474
+ ecr_enabled = resource_status.get("ecr", {}).get("status") == "ENABLED"
475
+ lambda_enabled = resource_status.get("lambda", {}).get("status") == "ENABLED"
476
+
477
+ enabled_scans = []
478
+ if ec2_enabled:
479
+ enabled_scans.append("EC2")
480
+ if ecr_enabled:
481
+ enabled_scans.append("ECR")
482
+ if lambda_enabled:
483
+ enabled_scans.append("LAMBDA")
484
+
485
+ print(
486
+ f"[DEBUG:Inspector] From batch_get_account_status, enabled scans: {enabled_scans}"
487
+ )
488
+
489
+ # If we successfully called batch_get_account_status, treat Inspector as enabled
490
+ return {
491
+ "enabled": True,
492
+ "scan_status": {
493
+ "ec2_status": "ENABLED" if ec2_enabled else "DISABLED",
494
+ "ecr_status": "ENABLED" if ecr_enabled else "DISABLED",
495
+ "lambda_status": "ENABLED" if lambda_enabled else "DISABLED",
496
+ },
497
+ "message": f"Amazon Inspector is enabled with the following scan types: {', '.join(enabled_scans) if enabled_scans else 'none'}",
498
+ }
499
+ except Exception as account_error:
500
+ print(f"[DEBUG:Inspector] batch_get_account_status() error: {account_error}")
501
+
502
+ # As a last resort, try listing findings
503
+ # If this works, it means Inspector is enabled
504
+ try:
505
+ # Try listing a small number of findings just to test API access
506
+ findings_response = inspector_client.list_findings(maxResults=1)
507
+ flag = False
508
+ if findings_response:
509
+ flag = True
510
+ # If we can call list_findings, Inspector is definitely enabled
511
+ return {
512
+ "enabled": flag,
513
+ "scan_status": {
514
+ "ec2_status": "UNKNOWN",
515
+ "ecr_status": "UNKNOWN",
516
+ "lambda_status": "UNKNOWN",
517
+ },
518
+ "message": "Amazon Inspector is enabled, but specific scan types could not be determined.",
519
+ }
520
+ except Exception as findings_error:
521
+ print(f"[DEBUG:Inspector] list_findings() error: {findings_error}")
522
+
523
+ # If we get here, we've tried multiple methods but can't confirm Inspector is enabled
524
+ print("[DEBUG:Inspector] All detection methods failed, treating as not enabled")
525
+ return {
526
+ "enabled": False,
527
+ "scan_status": {
528
+ "ec2_status": "UNKNOWN",
529
+ "ecr_status": "UNKNOWN",
530
+ "lambda_status": "UNKNOWN",
531
+ },
532
+ "setup_instructions": """
533
+ # Amazon Inspector Setup Instructions
534
+
535
+ Amazon Inspector may not be fully enabled in this region. To enable it:
536
+
537
+ 1. Open the Inspector console: https://console.aws.amazon.com/inspector/
538
+ 2. Choose Settings
539
+ 3. Enable the scan types you need (EC2, ECR, Lambda)
540
+
541
+ This is strongly recommended for identifying vulnerabilities in your workloads.
542
+
543
+ Learn more: https://docs.aws.amazon.com/inspector/latest/user/enabling-disable-scanning-account.html
544
+ """,
545
+ "message": "Amazon Inspector status could not be determined. Multiple detection methods failed.",
546
+ }
547
+ except inspector_client.exceptions.AccessDeniedException:
548
+ # Inspector is not enabled or permissions issue
549
+ return {
550
+ "enabled": False,
551
+ "setup_instructions": """
552
+ # Amazon Inspector Setup Instructions
553
+ Amazon Inspector is not enabled in this region. To enable it:
554
+ 1. Open the Inspector console: https://console.aws.amazon.com/inspector/
555
+ 2. Choose Get started
556
+ 3. Choose Enable Amazon Inspector
557
+ 4. Select the scan types to enable
558
+ """,
559
+ "message": "Amazon Inspector is not enabled in this region.",
560
+ }
561
+ except Exception as e:
562
+ await ctx.error(f"Error checking Inspector status: {e}")
563
+ return {"enabled": False, "error": str(e), "message": "Error checking Inspector status."}
564
+
565
+
566
+ # New functions to get findings from security services
567
+
568
+
569
+ async def get_guardduty_findings(
570
+ region: str,
571
+ session: boto3.Session,
572
+ ctx: Context,
573
+ max_findings: int = 100,
574
+ filter_criteria: Optional[Dict] = None,
575
+ ) -> Dict:
576
+ """Get findings from Amazon GuardDuty in the specified region.
577
+
578
+ Args:
579
+ region: AWS region to get findings from
580
+ session: boto3 Session for AWS API calls
581
+ ctx: MCP context for error reporting
582
+ max_findings: Maximum number of findings to return (default: 100)
583
+ filter_criteria: Optional filter criteria for findings
584
+
585
+ Returns:
586
+ Dictionary containing GuardDuty findings
587
+ """
588
+ try:
589
+ # First check if GuardDuty is enabled
590
+ print(f"[DEBUG:GuardDuty] Checking if GuardDuty is enabled in {region}")
591
+ guardduty_status = await check_guard_duty(region, session, ctx)
592
+ if not guardduty_status.get("enabled", False):
593
+ print(f"[DEBUG:GuardDuty] GuardDuty is not enabled in {region}")
594
+ return {
595
+ "enabled": False,
596
+ "message": "Amazon GuardDuty is not enabled in this region",
597
+ "findings": [],
598
+ "debug_info": "GuardDuty is not enabled, no findings retrieved",
599
+ }
600
+
601
+ # Get detector ID
602
+ print("[DEBUG:GuardDuty] GuardDuty is enabled, retrieving detector ID")
603
+ detector_id = guardduty_status.get("detector_details", {}).get("id")
604
+ if not detector_id:
605
+ print("[DEBUG:GuardDuty] ERROR: No GuardDuty detector ID found")
606
+ await ctx.error("No GuardDuty detector ID found")
607
+ return {
608
+ "enabled": True,
609
+ "error": "No GuardDuty detector ID found",
610
+ "findings": [],
611
+ "debug_info": "GuardDuty is enabled but no detector ID was found",
612
+ }
613
+
614
+ print(f"[DEBUG:GuardDuty] Using detector ID: {detector_id}")
615
+
616
+ # Create GuardDuty client
617
+ guardduty_client = session.client(
618
+ "guardduty", region_name=region, config=USER_AGENT_CONFIG
619
+ )
620
+
621
+ # Set up default finding criteria if none provided
622
+ if filter_criteria is None:
623
+ print("[DEBUG:GuardDuty] No filter criteria provided, creating default criteria")
624
+ # By default, get findings from the last 30 days with high or medium severity
625
+ # Calculate timestamp in milliseconds (GuardDuty expects integer timestamp)
626
+ thirty_days_ago = int(
627
+ (datetime.datetime.now() - datetime.timedelta(days=30)).timestamp() * 1000
628
+ )
629
+
630
+ filter_criteria = {
631
+ "Criterion": {
632
+ "severity": {
633
+ "Eq": ["7", "5", "8"] # High (7), Medium (5), and Critical (8) findings
634
+ },
635
+ "updatedAt": {"GreaterThanOrEqual": thirty_days_ago},
636
+ }
637
+ }
638
+ print(
639
+ f"[DEBUG:GuardDuty] Created default filter criteria with timestamp: {thirty_days_ago} ({datetime.datetime.fromtimestamp(thirty_days_ago / 1000).isoformat()})"
640
+ )
641
+ else:
642
+ print(
643
+ f"[DEBUG:GuardDuty] Using provided filter criteria: {json.dumps(filter_criteria)}"
644
+ )
645
+
646
+ # List findings with the filter criteria
647
+ print(f"[DEBUG:GuardDuty] Calling list_findings with max results: {max_findings}")
648
+ findings_response = guardduty_client.list_findings(
649
+ DetectorId=detector_id, FindingCriteria=filter_criteria, MaxResults=max_findings
650
+ )
651
+
652
+ finding_ids = findings_response.get("FindingIds", [])
653
+ print(f"[DEBUG:GuardDuty] Retrieved {len(finding_ids)} finding IDs")
654
+
655
+ if not finding_ids:
656
+ print("[DEBUG:GuardDuty] No findings match the filter criteria")
657
+ return {
658
+ "enabled": True,
659
+ "message": "No GuardDuty findings match the filter criteria",
660
+ "findings": [],
661
+ "debug_info": "GuardDuty query returned no findings matching the criteria",
662
+ }
663
+
664
+ # Get finding details
665
+ print(f"[DEBUG:GuardDuty] Retrieving details for {len(finding_ids)} findings")
666
+ findings_details = guardduty_client.get_findings(
667
+ DetectorId=detector_id, FindingIds=finding_ids
668
+ )
669
+
670
+ # Process findings to clean up non-serializable objects (like datetime)
671
+ findings = []
672
+ raw_findings_count = len(findings_details.get("Findings", []))
673
+ print(
674
+ f"[DEBUG:GuardDuty] Processing {raw_findings_count} findings from get_findings response"
675
+ )
676
+
677
+ for finding in findings_details.get("Findings", []):
678
+ # Convert datetime objects to strings
679
+ finding = _clean_datetime_objects(finding)
680
+ findings.append(finding)
681
+
682
+ print(f"[DEBUG:GuardDuty] Successfully processed {len(findings)} findings")
683
+
684
+ # Generate summary
685
+ summary = _summarize_guardduty_findings(findings)
686
+ print(f"[DEBUG:GuardDuty] Generated summary with {summary['total_count']} findings")
687
+ print(
688
+ f"[DEBUG:GuardDuty] Severity breakdown: High={summary['severity_counts']['high']}, Medium={summary['severity_counts']['medium']}, Low={summary['severity_counts']['low']}"
689
+ )
690
+
691
+ return {
692
+ "enabled": True,
693
+ "message": f"Retrieved {len(findings)} GuardDuty findings",
694
+ "findings": findings,
695
+ "summary": summary,
696
+ "debug_info": {
697
+ "detector_id": detector_id,
698
+ "finding_ids_retrieved": len(finding_ids),
699
+ "findings_details_retrieved": raw_findings_count,
700
+ "findings_processed": len(findings),
701
+ "filter_criteria": filter_criteria,
702
+ },
703
+ }
704
+ except Exception as e:
705
+ await ctx.error(f"Error getting GuardDuty findings: {e}")
706
+ return {
707
+ "enabled": True,
708
+ "error": str(e),
709
+ "message": "Error getting GuardDuty findings",
710
+ "findings": [],
711
+ }
712
+
713
+
714
+ async def get_securityhub_findings(
715
+ region: str,
716
+ session: boto3.Session,
717
+ ctx: Context,
718
+ max_findings: int = 100,
719
+ filter_criteria: Optional[Dict] = None,
720
+ ) -> Dict:
721
+ """Get findings from AWS Security Hub in the specified region.
722
+
723
+ Args:
724
+ region: AWS region to get findings from
725
+ session: boto3 Session for AWS API calls
726
+ ctx: MCP context for error reporting
727
+ max_findings: Maximum number of findings to return (default: 100)
728
+ filter_criteria: Optional filter criteria for findings
729
+
730
+ Returns:
731
+ Dictionary containing Security Hub findings
732
+ """
733
+ try:
734
+ # First check if Security Hub is enabled
735
+ securityhub_status = await check_security_hub(region, session, ctx)
736
+ if not securityhub_status.get("enabled", False):
737
+ return {
738
+ "enabled": False,
739
+ "message": "AWS Security Hub is not enabled in this region",
740
+ "findings": [],
741
+ }
742
+
743
+ # Create Security Hub client
744
+ securityhub_client = session.client(
745
+ "securityhub", region_name=region, config=USER_AGENT_CONFIG
746
+ )
747
+
748
+ # Set up default finding criteria if none provided
749
+ if filter_criteria is None:
750
+ # By default, get active findings from the last 30 days with high severity
751
+ filter_criteria = {
752
+ "RecordState": [{"Comparison": "EQUALS", "Value": "ACTIVE"}],
753
+ "WorkflowStatus": [{"Comparison": "EQUALS", "Value": "NEW"}],
754
+ "UpdatedAt": [
755
+ {
756
+ "Start": (datetime.datetime.now() - datetime.timedelta(days=30)).strftime(
757
+ "%Y-%m-%dT%H:%M:%S.%fZ"
758
+ ),
759
+ "End": datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
760
+ }
761
+ ],
762
+ "SeverityLabel": [
763
+ {"Comparison": "EQUALS", "Value": "HIGH"},
764
+ {"Comparison": "EQUALS", "Value": "CRITICAL"},
765
+ ],
766
+ }
767
+
768
+ # Get findings with the filter criteria
769
+ findings_response = securityhub_client.get_findings(
770
+ Filters=filter_criteria, MaxResults=max_findings
771
+ )
772
+
773
+ findings = findings_response.get("Findings", [])
774
+
775
+ if not findings:
776
+ return {
777
+ "enabled": True,
778
+ "message": "No Security Hub findings match the filter criteria",
779
+ "findings": [],
780
+ }
781
+
782
+ # Process findings to clean up non-serializable objects (like datetime)
783
+ processed_findings = []
784
+ for finding in findings:
785
+ # Convert datetime objects to strings
786
+ finding = _clean_datetime_objects(finding)
787
+ processed_findings.append(finding)
788
+
789
+ return {
790
+ "enabled": True,
791
+ "message": f"Retrieved {len(processed_findings)} Security Hub findings",
792
+ "findings": processed_findings,
793
+ "summary": _summarize_securityhub_findings(processed_findings),
794
+ }
795
+ except Exception as e:
796
+ await ctx.error(f"Error getting Security Hub findings: {e}")
797
+ return {
798
+ "enabled": True,
799
+ "error": str(e),
800
+ "message": "Error getting Security Hub findings",
801
+ "findings": [],
802
+ }
803
+
804
+
805
+ async def get_inspector_findings(
806
+ region: str,
807
+ session: boto3.Session,
808
+ ctx: Context,
809
+ max_findings: int = 100,
810
+ filter_criteria: Optional[Dict] = None,
811
+ ) -> Dict:
812
+ """Get findings from Amazon Inspector in the specified region.
813
+
814
+ Args:
815
+ region: AWS region to get findings from
816
+ session: boto3 Session for AWS API calls
817
+ ctx: MCP context for error reporting
818
+ max_findings: Maximum number of findings to return (default: 100)
819
+ filter_criteria: Optional filter criteria for findings
820
+
821
+ Returns:
822
+ Dictionary containing Inspector findings
823
+ """
824
+ try:
825
+ # First check if Inspector is enabled
826
+ inspector_status = await check_inspector(region, session, ctx)
827
+ if not inspector_status.get("enabled", False):
828
+ return {
829
+ "enabled": False,
830
+ "message": "Amazon Inspector is not enabled in this region",
831
+ "findings": [],
832
+ }
833
+
834
+ # Create Inspector client
835
+ inspector_client = session.client(
836
+ "inspector2", region_name=region, config=USER_AGENT_CONFIG
837
+ )
838
+
839
+ # Set up default finding criteria if none provided
840
+ if filter_criteria is None:
841
+ # By default, get findings with high or critical severity
842
+ filter_criteria = {
843
+ "severities": [
844
+ {"comparison": "EQUALS", "value": "HIGH"},
845
+ {"comparison": "EQUALS", "value": "CRITICAL"},
846
+ ],
847
+ "findingStatus": [{"comparison": "EQUALS", "value": "ACTIVE"}],
848
+ }
849
+
850
+ # List findings with the filter criteria
851
+ findings_response = inspector_client.list_findings(
852
+ filterCriteria=filter_criteria, maxResults=max_findings
853
+ )
854
+
855
+ findings = findings_response.get("findings", [])
856
+
857
+ if not findings:
858
+ return {
859
+ "enabled": True,
860
+ "message": "No Inspector findings match the filter criteria",
861
+ "findings": [],
862
+ }
863
+
864
+ # Process findings to clean up non-serializable objects (like datetime)
865
+ processed_findings = []
866
+ for finding in findings:
867
+ # Convert datetime objects to strings
868
+ finding = _clean_datetime_objects(finding)
869
+ processed_findings.append(finding)
870
+
871
+ return {
872
+ "enabled": True,
873
+ "message": f"Retrieved {len(processed_findings)} Inspector findings",
874
+ "findings": processed_findings,
875
+ "summary": _summarize_inspector_findings(processed_findings),
876
+ }
877
+ except Exception as e:
878
+ await ctx.error(f"Error getting Inspector findings: {e}")
879
+ return {
880
+ "enabled": True,
881
+ "error": str(e),
882
+ "message": "Error getting Inspector findings",
883
+ "findings": [],
884
+ }
885
+
886
+
887
+ async def get_access_analyzer_findings(
888
+ region: str, session: boto3.Session, ctx: Context, analyzer_arn: Optional[str] = None
889
+ ) -> Dict:
890
+ """Get findings from IAM Access Analyzer in the specified region.
891
+
892
+ Args:
893
+ region: AWS region to get findings from
894
+ session: boto3 Session for AWS API calls
895
+ ctx: MCP context for error reporting
896
+ analyzer_arn: Optional ARN of a specific analyzer to get findings from
897
+
898
+ Returns:
899
+ Dictionary containing IAM Access Analyzer findings
900
+ """
901
+ try:
902
+ # First check if Access Analyzer is enabled
903
+ analyzer_status = await check_access_analyzer(region, session, ctx)
904
+ if not analyzer_status.get("enabled", False):
905
+ return {
906
+ "enabled": False,
907
+ "message": "IAM Access Analyzer is not enabled in this region",
908
+ "findings": [],
909
+ }
910
+
911
+ # Create Access Analyzer client
912
+ analyzer_client = session.client(
913
+ "accessanalyzer", region_name=region, config=USER_AGENT_CONFIG
914
+ )
915
+
916
+ analyzers = analyzer_status.get("analyzers", [])
917
+ if not analyzers:
918
+ return {
919
+ "enabled": True,
920
+ "message": "No IAM Access Analyzer analyzers found in this region",
921
+ "findings": [],
922
+ }
923
+
924
+ all_findings = []
925
+
926
+ # If analyzer_arn is provided, only get findings for that analyzer
927
+ if analyzer_arn:
928
+ analyzers = [a for a in analyzers if a.get("arn") == analyzer_arn]
929
+
930
+ # Get findings for each analyzer
931
+ for analyzer in analyzers:
932
+ analyzer_arn = analyzer.get("arn")
933
+ if not analyzer_arn:
934
+ continue
935
+
936
+ findings_response = analyzer_client.list_findings(
937
+ analyzerArn=analyzer_arn, maxResults=100
938
+ )
939
+
940
+ finding_ids = findings_response.get("findings", [])
941
+
942
+ # Get details for each finding
943
+ for finding_id in finding_ids:
944
+ finding_details = analyzer_client.get_finding(
945
+ analyzerArn=analyzer_arn, id=finding_id
946
+ )
947
+
948
+ # Clean up non-serializable objects
949
+ finding_details = _clean_datetime_objects(finding_details)
950
+ all_findings.append(finding_details)
951
+
952
+ if not all_findings:
953
+ return {
954
+ "enabled": True,
955
+ "message": "No IAM Access Analyzer findings found",
956
+ "findings": [],
957
+ }
958
+
959
+ return {
960
+ "enabled": True,
961
+ "message": f"Retrieved {len(all_findings)} IAM Access Analyzer findings",
962
+ "findings": all_findings,
963
+ "summary": _summarize_access_analyzer_findings(all_findings),
964
+ }
965
+ except Exception as e:
966
+ await ctx.error(f"Error getting IAM Access Analyzer findings: {e}")
967
+ return {
968
+ "enabled": True,
969
+ "error": str(e),
970
+ "message": "Error getting IAM Access Analyzer findings",
971
+ "findings": [],
972
+ }
973
+
974
+
975
+ # Helper functions for processing findings
976
+
977
+
978
+ def _clean_datetime_objects(obj: Any) -> Any:
979
+ """Convert datetime objects in a nested dictionary to ISO format strings.
980
+
981
+ Args:
982
+ obj: Object that may contain datetime objects
983
+
984
+ Returns:
985
+ Object with datetime objects converted to strings
986
+ """
987
+ if isinstance(obj, datetime.datetime):
988
+ return obj.isoformat()
989
+ elif isinstance(obj, list):
990
+ return [_clean_datetime_objects(item) for item in obj]
991
+ elif isinstance(obj, dict):
992
+ return {k: _clean_datetime_objects(v) for k, v in obj.items()}
993
+ else:
994
+ return obj
995
+
996
+
997
+ def _summarize_guardduty_findings(findings: List[Dict]) -> Dict:
998
+ """Generate a summary of GuardDuty findings.
999
+
1000
+ Args:
1001
+ findings: List of GuardDuty finding dictionaries
1002
+
1003
+ Returns:
1004
+ Dictionary with summary information
1005
+ """
1006
+ summary = {
1007
+ "total_count": len(findings),
1008
+ "severity_counts": {"high": 0, "medium": 0, "low": 0},
1009
+ "type_counts": {},
1010
+ "resource_counts": {},
1011
+ }
1012
+
1013
+ for finding in findings:
1014
+ # Count by severity
1015
+ severity = finding.get("Severity", 0)
1016
+ if severity >= 7:
1017
+ summary["severity_counts"]["high"] += 1
1018
+ elif severity >= 4:
1019
+ summary["severity_counts"]["medium"] += 1
1020
+ else:
1021
+ summary["severity_counts"]["low"] += 1
1022
+
1023
+ # Count by finding type
1024
+ finding_type = finding.get("Type", "unknown")
1025
+ if finding_type in summary["type_counts"]:
1026
+ summary["type_counts"][finding_type] += 1
1027
+ else:
1028
+ summary["type_counts"][finding_type] = 1
1029
+
1030
+ # Count by resource type
1031
+ resource_type = finding.get("Resource", {}).get("ResourceType", "unknown")
1032
+ if resource_type in summary["resource_counts"]:
1033
+ summary["resource_counts"][resource_type] += 1
1034
+ else:
1035
+ summary["resource_counts"][resource_type] = 1
1036
+
1037
+ return summary
1038
+
1039
+
1040
+ def _summarize_securityhub_findings(findings: List[Dict]) -> Dict:
1041
+ """Generate a summary of Security Hub findings.
1042
+
1043
+ Args:
1044
+ findings: List of Security Hub finding dictionaries
1045
+
1046
+ Returns:
1047
+ Dictionary with summary information
1048
+ """
1049
+ summary = {
1050
+ "total_count": len(findings),
1051
+ "severity_counts": {"critical": 0, "high": 0, "medium": 0, "low": 0},
1052
+ "standard_counts": {},
1053
+ "resource_type_counts": {},
1054
+ }
1055
+
1056
+ for finding in findings:
1057
+ # Count by severity
1058
+ severity = finding.get("Severity", {}).get("Label", "MEDIUM").upper()
1059
+ if severity == "CRITICAL":
1060
+ summary["severity_counts"]["critical"] += 1
1061
+ elif severity == "HIGH":
1062
+ summary["severity_counts"]["high"] += 1
1063
+ elif severity == "MEDIUM":
1064
+ summary["severity_counts"]["medium"] += 1
1065
+ else:
1066
+ summary["severity_counts"]["low"] += 1
1067
+
1068
+ # Count by compliance standard
1069
+ product_name = finding.get("ProductName", "unknown")
1070
+ if product_name in summary["standard_counts"]:
1071
+ summary["standard_counts"][product_name] += 1
1072
+ else:
1073
+ summary["standard_counts"][product_name] = 1
1074
+
1075
+ # Count by resource type
1076
+ resources = finding.get("Resources", [])
1077
+ for resource in resources:
1078
+ resource_type = resource.get("Type", "unknown")
1079
+ if resource_type in summary["resource_type_counts"]:
1080
+ summary["resource_type_counts"][resource_type] += 1
1081
+ else:
1082
+ summary["resource_type_counts"][resource_type] = 1
1083
+
1084
+ return summary
1085
+
1086
+
1087
+ def _summarize_inspector_findings(findings: List[Dict]) -> Dict:
1088
+ """Generate a summary of Inspector findings.
1089
+
1090
+ Args:
1091
+ findings: List of Inspector finding dictionaries
1092
+
1093
+ Returns:
1094
+ Dictionary with summary information
1095
+ """
1096
+ summary = {
1097
+ "total_count": len(findings),
1098
+ "severity_counts": {"critical": 0, "high": 0, "medium": 0, "low": 0},
1099
+ "type_counts": {},
1100
+ "resource_type_counts": {},
1101
+ }
1102
+
1103
+ for finding in findings:
1104
+ # Count by severity
1105
+ severity = finding.get("severity", "MEDIUM")
1106
+ if severity == "CRITICAL":
1107
+ summary["severity_counts"]["critical"] += 1
1108
+ elif severity == "HIGH":
1109
+ summary["severity_counts"]["high"] += 1
1110
+ elif severity == "MEDIUM":
1111
+ summary["severity_counts"]["medium"] += 1
1112
+ else:
1113
+ summary["severity_counts"]["low"] += 1
1114
+
1115
+ # Count by finding type
1116
+ finding_type = finding.get("type", "unknown")
1117
+ if finding_type in summary["type_counts"]:
1118
+ summary["type_counts"][finding_type] += 1
1119
+ else:
1120
+ summary["type_counts"][finding_type] = 1
1121
+
1122
+ # Count by resource type
1123
+ resource_type = finding.get("resourceType", "unknown")
1124
+ if resource_type in summary["resource_type_counts"]:
1125
+ summary["resource_type_counts"][resource_type] += 1
1126
+ else:
1127
+ summary["resource_type_counts"][resource_type] = 1
1128
+
1129
+ return summary
1130
+
1131
+
1132
+ def _summarize_access_analyzer_findings(findings: List[Dict]) -> Dict:
1133
+ """Generate a summary of IAM Access Analyzer findings.
1134
+
1135
+ Args:
1136
+ findings: List of IAM Access Analyzer finding dictionaries
1137
+
1138
+ Returns:
1139
+ Dictionary with summary information
1140
+ """
1141
+ summary = {"total_count": len(findings), "resource_type_counts": {}, "action_counts": {}}
1142
+
1143
+ for finding in findings:
1144
+ # Count by resource type
1145
+ resource_type = finding.get("resourceType", "unknown")
1146
+ if resource_type in summary["resource_type_counts"]:
1147
+ summary["resource_type_counts"][resource_type] += 1
1148
+ else:
1149
+ summary["resource_type_counts"][resource_type] = 1
1150
+
1151
+ # Count by action
1152
+ actions = finding.get("action", [])
1153
+ for action in actions:
1154
+ if action in summary["action_counts"]:
1155
+ summary["action_counts"][action] += 1
1156
+ else:
1157
+ summary["action_counts"][action] = 1
1158
+
1159
+ return summary
1160
+
1161
+
1162
+ async def check_trusted_advisor(region: str, session: boto3.Session, ctx: Context) -> Dict:
1163
+ """Check if AWS Trusted Advisor is accessible in the account.
1164
+
1165
+ Args:
1166
+ region: AWS region to check (Trusted Advisor is a global service, but API calls must be made to us-east-1)
1167
+ session: boto3 Session for AWS API calls
1168
+ ctx: MCP context for error reporting
1169
+
1170
+ Returns:
1171
+ Dictionary with status information about AWS Trusted Advisor
1172
+
1173
+ Note:
1174
+ Full Trusted Advisor functionality requires Business or Enterprise Support plan.
1175
+ """
1176
+ try:
1177
+ print("[DEBUG:TrustedAdvisor] Starting Trusted Advisor check")
1178
+
1179
+ # Trusted Advisor API is only available in us-east-1
1180
+ support_client = session.client(
1181
+ "support", region_name="us-east-1", config=USER_AGENT_CONFIG
1182
+ )
1183
+
1184
+ try:
1185
+ # Try to describe Trusted Advisor checks to see if we have access
1186
+ print("[DEBUG:TrustedAdvisor] Calling describe_trusted_advisor_checks API")
1187
+ checks_response = support_client.describe_trusted_advisor_checks(language="en")
1188
+
1189
+ # If we get here, we have access to Trusted Advisor
1190
+ checks = checks_response.get("checks", [])
1191
+ print(
1192
+ f"[DEBUG:TrustedAdvisor] Successfully retrieved {len(checks)} Trusted Advisor checks"
1193
+ )
1194
+
1195
+ # Count checks by category
1196
+ category_counts = {}
1197
+ for check in checks:
1198
+ category = check.get("category", "unknown")
1199
+ if category in category_counts:
1200
+ category_counts[category] += 1
1201
+ else:
1202
+ category_counts[category] = 1
1203
+
1204
+ # Count security checks specifically
1205
+ security_checks = [check for check in checks if check.get("category") == "security"]
1206
+ print(f"[DEBUG:TrustedAdvisor] Found {len(security_checks)} security-related checks")
1207
+
1208
+ # Determine support tier based on number of checks
1209
+ # Basic support typically has 7 core checks, Business/Enterprise has 100+
1210
+ support_tier = "Basic" if len(checks) < 20 else "Business/Enterprise"
1211
+
1212
+ return {
1213
+ "enabled": True,
1214
+ "support_tier": support_tier,
1215
+ "total_checks": len(checks),
1216
+ "security_checks": len(security_checks),
1217
+ "category_counts": category_counts,
1218
+ "message": f"AWS Trusted Advisor is accessible with {support_tier} Support ({len(checks)} checks available, {len(security_checks)} security checks).",
1219
+ }
1220
+
1221
+ except support_client.exceptions.SubscriptionRequiredException:
1222
+ # This exception means Trusted Advisor is not available with the current support plan
1223
+ return {
1224
+ "enabled": False,
1225
+ "support_tier": "Basic",
1226
+ "setup_instructions": """
1227
+ # AWS Trusted Advisor Full Access Requirements
1228
+
1229
+ Full access to AWS Trusted Advisor requires Business or Enterprise Support plan.
1230
+
1231
+ With your current support plan, you have limited access to Trusted Advisor.
1232
+ To get full access to all Trusted Advisor checks:
1233
+
1234
+ 1. Open the AWS Support Center Console: https://console.aws.amazon.com/support/
1235
+ 2. Choose Support Center
1236
+ 3. Choose Compare or change your Support plan
1237
+ 4. Upgrade to Business or Enterprise Support
1238
+
1239
+ Learn more: https://aws.amazon.com/premiumsupport/
1240
+ """,
1241
+ "message": "Full AWS Trusted Advisor functionality requires Business or Enterprise Support plan.",
1242
+ }
1243
+
1244
+ except Exception as e:
1245
+ await ctx.error(f"Error checking Trusted Advisor status: {e}")
1246
+ return {
1247
+ "enabled": False,
1248
+ "error": str(e),
1249
+ "message": "Error checking Trusted Advisor status.",
1250
+ }
1251
+
1252
+
1253
+ async def get_trusted_advisor_findings(
1254
+ region: str,
1255
+ session: boto3.Session,
1256
+ ctx: Context,
1257
+ max_findings: int = 100,
1258
+ status_filter: Optional[List[str]] = None,
1259
+ category_filter: Optional[str] = None,
1260
+ ) -> Dict:
1261
+ """Retrieve check results from AWS Trusted Advisor.
1262
+
1263
+ Args:
1264
+ region: AWS region (Trusted Advisor is global, but API calls must be made to us-east-1)
1265
+ session: boto3 Session for AWS API calls
1266
+ ctx: MCP context for error reporting
1267
+ max_findings: Maximum number of findings to return (default: 100)
1268
+ status_filter: Optional list of statuses to filter by (e.g., ['error', 'warning'])
1269
+ category_filter: Optional category to filter by (e.g., 'security')
1270
+
1271
+ Returns:
1272
+ Dictionary containing Trusted Advisor check results
1273
+ """
1274
+ try:
1275
+ print("[DEBUG:TrustedAdvisor] Starting findings retrieval")
1276
+
1277
+ # Set default status filter if not provided
1278
+ if status_filter is None:
1279
+ status_filter = ["error", "warning"]
1280
+
1281
+ # First check if Trusted Advisor is accessible
1282
+ ta_status = await check_trusted_advisor(region, session, ctx)
1283
+ if not ta_status.get("enabled", False):
1284
+ print("[DEBUG:TrustedAdvisor] Trusted Advisor is not fully accessible")
1285
+ return {
1286
+ "enabled": False,
1287
+ "message": ta_status.get("message", "AWS Trusted Advisor is not accessible"),
1288
+ "findings": [],
1289
+ "support_tier": ta_status.get("support_tier", "Unknown"),
1290
+ }
1291
+
1292
+ # Create Support client (Trusted Advisor API is only available in us-east-1)
1293
+ support_client = session.client(
1294
+ "support", region_name="us-east-1", config=USER_AGENT_CONFIG
1295
+ )
1296
+
1297
+ # Get all available checks
1298
+ print("[DEBUG:TrustedAdvisor] Getting all available checks")
1299
+ checks_response = support_client.describe_trusted_advisor_checks(language="en")
1300
+ all_checks = checks_response.get("checks", [])
1301
+
1302
+ # Filter checks by category if specified
1303
+ filtered_checks = all_checks
1304
+ if category_filter:
1305
+ filtered_checks = [
1306
+ check
1307
+ for check in all_checks
1308
+ if check.get("category", "").lower() == category_filter.lower()
1309
+ ]
1310
+ print(
1311
+ f"[DEBUG:TrustedAdvisor] Filtered to {len(filtered_checks)} {category_filter} checks"
1312
+ )
1313
+
1314
+ # Limit the number of checks to process based on max_findings
1315
+ checks_to_process = filtered_checks[:max_findings]
1316
+
1317
+ # Get check results
1318
+ findings = []
1319
+ for check in checks_to_process:
1320
+ check_id = check.get("id", "unknown") # Initialize check_id outside try block
1321
+ try:
1322
+ result = support_client.describe_trusted_advisor_check_result(
1323
+ checkId=check_id, language="en"
1324
+ )
1325
+
1326
+ # Extract the result
1327
+ check_result = result.get("result", {})
1328
+ status = check_result.get("status", "").lower()
1329
+
1330
+ # Skip checks that don't match the status filter
1331
+ if status_filter and status not in status_filter:
1332
+ continue
1333
+
1334
+ # Format the finding
1335
+ finding = {
1336
+ "check_id": check_id,
1337
+ "name": check.get("name"),
1338
+ "description": check.get("description"),
1339
+ "category": check.get("category"),
1340
+ "status": status,
1341
+ "timestamp": check_result.get("timestamp"),
1342
+ "resources_flagged": check_result.get("resourcesSummary", {}).get(
1343
+ "resourcesFlagged", 0
1344
+ ),
1345
+ "resources_processed": check_result.get("resourcesSummary", {}).get(
1346
+ "resourcesProcessed", 0
1347
+ ),
1348
+ "resources_suppressed": check_result.get("resourcesSummary", {}).get(
1349
+ "resourcesSuppressed", 0
1350
+ ),
1351
+ "flagged_resources": [],
1352
+ }
1353
+
1354
+ # Add flagged resources
1355
+ flagged_resources = check_result.get("flaggedResources", [])
1356
+ for resource in flagged_resources:
1357
+ # Clean up the resource data
1358
+ resource_data = _clean_datetime_objects(resource)
1359
+ finding["flagged_resources"].append(resource_data)
1360
+
1361
+ findings.append(finding)
1362
+ print(
1363
+ f"[DEBUG:TrustedAdvisor] Added finding: {finding['name']} (status: {finding['status']}, resources: {finding['resources_flagged']})"
1364
+ )
1365
+
1366
+ except Exception as check_error:
1367
+ await ctx.warning(
1368
+ f"Error getting results for Trusted Advisor check {check_id}: {check_error}"
1369
+ )
1370
+
1371
+ # Generate summary
1372
+ summary = _summarize_trusted_advisor_findings(findings)
1373
+
1374
+ return {
1375
+ "enabled": True,
1376
+ "message": f"Retrieved {len(findings)} Trusted Advisor findings",
1377
+ "findings": findings,
1378
+ "summary": summary,
1379
+ "support_tier": ta_status.get("support_tier", "Unknown"),
1380
+ }
1381
+
1382
+ except Exception as e:
1383
+ await ctx.error(f"Error getting Trusted Advisor findings: {e}")
1384
+ return {
1385
+ "enabled": True,
1386
+ "error": str(e),
1387
+ "message": "Error getting Trusted Advisor findings",
1388
+ "findings": [],
1389
+ }
1390
+
1391
+
1392
+ def _summarize_trusted_advisor_findings(findings: List[Dict]) -> Dict:
1393
+ """Generate a summary of Trusted Advisor findings.
1394
+
1395
+ Args:
1396
+ findings: List of Trusted Advisor finding dictionaries
1397
+
1398
+ Returns:
1399
+ Dictionary with summary information
1400
+ """
1401
+ summary = {
1402
+ "total_count": len(findings),
1403
+ "status_counts": {"error": 0, "warning": 0, "ok": 0, "not_available": 0},
1404
+ "category_counts": {},
1405
+ "resources_flagged": 0,
1406
+ }
1407
+
1408
+ for finding in findings:
1409
+ # Count by status
1410
+ status = finding.get("status", "").lower()
1411
+ if status in summary["status_counts"]:
1412
+ summary["status_counts"][status] += 1
1413
+ else:
1414
+ summary["status_counts"]["not_available"] += 1
1415
+
1416
+ # Count by category
1417
+ category = finding.get("category", "unknown")
1418
+ if category in summary["category_counts"]:
1419
+ summary["category_counts"][category] += 1
1420
+ else:
1421
+ summary["category_counts"][category] = 1
1422
+
1423
+ # Count total flagged resources
1424
+ summary["resources_flagged"] += finding.get("resources_flagged", 0)
1425
+
1426
+ return summary
1427
+
1428
+
1429
+ async def check_macie(region: str, session: boto3.Session, ctx: Context) -> Dict:
1430
+ """Check if Amazon Macie is enabled in the specified region.
1431
+
1432
+ Args:
1433
+ region: AWS region to check
1434
+ session: boto3 Session for AWS API calls
1435
+ ctx: MCP context for error reporting
1436
+
1437
+ Returns:
1438
+ Dictionary with status information about Amazon Macie
1439
+ """
1440
+ try:
1441
+ print(f"[DEBUG:Macie] Starting Macie check for region: {region}")
1442
+ # Create Macie client
1443
+ macie_client = session.client("macie2", region_name=region, config=USER_AGENT_CONFIG)
1444
+
1445
+ # Check if Macie is enabled
1446
+ try:
1447
+ print("[DEBUG:Macie] Calling get_macie_session() API")
1448
+ status = macie_client.get_macie_session()
1449
+ print(f"[DEBUG:Macie] get_macie_session() successful, status: {status.get('status')}")
1450
+
1451
+ # If we get here without exception, Macie is enabled
1452
+ return {
1453
+ "enabled": True,
1454
+ "status": status.get("status"),
1455
+ "created_at": str(status.get("createdAt")),
1456
+ "service_role": status.get("serviceRole"),
1457
+ "finding_publishing_frequency": status.get("findingPublishingFrequency"),
1458
+ "message": "Amazon Macie is enabled in this region.",
1459
+ }
1460
+ except macie_client.exceptions.AccessDeniedException:
1461
+ return {
1462
+ "enabled": False,
1463
+ "setup_instructions": """
1464
+ # Amazon Macie Setup Instructions
1465
+
1466
+ Amazon Macie is not enabled in this region. To enable it:
1467
+
1468
+ 1. Open the Macie console: https://console.aws.amazon.com/macie/
1469
+ 2. Choose Get Started
1470
+ 3. Configure your settings and choose Enable Macie
1471
+
1472
+ This is recommended for discovering and protecting sensitive data in S3 buckets.
1473
+
1474
+ Learn more: https://docs.aws.amazon.com/macie/latest/user/getting-started.html
1475
+ """,
1476
+ "message": "Amazon Macie is not enabled in this region.",
1477
+ }
1478
+ except Exception as e:
1479
+ await ctx.error(f"Error checking Macie status: {e}")
1480
+ return {
1481
+ "enabled": False,
1482
+ "error": str(e),
1483
+ "message": "Error checking Macie status.",
1484
+ "debug_info": {"exception": str(e), "exception_type": type(e).__name__},
1485
+ }
1486
+
1487
+
1488
+ async def get_macie_findings(
1489
+ region: str,
1490
+ session: boto3.Session,
1491
+ ctx: Context,
1492
+ max_findings: int = 100,
1493
+ filter_criteria: Optional[Dict] = None,
1494
+ ) -> Dict:
1495
+ """Get findings from Amazon Macie in the specified region.
1496
+
1497
+ Args:
1498
+ region: AWS region to get findings from
1499
+ session: boto3 Session for AWS API calls
1500
+ ctx: MCP context for error reporting
1501
+ max_findings: Maximum number of findings to return (default: 100)
1502
+ filter_criteria: Optional filter criteria for findings
1503
+
1504
+ Returns:
1505
+ Dictionary containing Macie findings
1506
+ """
1507
+ try:
1508
+ print(f"[DEBUG:Macie] Starting findings retrieval for region: {region}")
1509
+ # First check if Macie is enabled
1510
+ macie_status = await check_macie(region, session, ctx)
1511
+ if not macie_status.get("enabled", False):
1512
+ print(f"[DEBUG:Macie] Macie is not enabled in {region}")
1513
+ return {
1514
+ "enabled": False,
1515
+ "message": "Amazon Macie is not enabled in this region",
1516
+ "findings": [],
1517
+ }
1518
+
1519
+ # Create Macie client
1520
+ macie_client = session.client("macie2", region_name=region, config=USER_AGENT_CONFIG)
1521
+
1522
+ # Set up default finding criteria if none provided
1523
+ if filter_criteria is None:
1524
+ filter_criteria = {"criterion": {"severity.score": {"gt": 7}}}
1525
+
1526
+ # List findings with the filter criteria
1527
+ findings_response = macie_client.list_findings(
1528
+ findingCriteria=filter_criteria, maxResults=max_findings
1529
+ )
1530
+
1531
+ finding_ids = findings_response.get("findingIds", [])
1532
+ print(f"[DEBUG:Macie] Retrieved {len(finding_ids)} finding IDs")
1533
+
1534
+ if not finding_ids:
1535
+ return {
1536
+ "enabled": True,
1537
+ "message": "No Macie findings match the filter criteria",
1538
+ "findings": [],
1539
+ }
1540
+
1541
+ # Get finding details
1542
+ print(f"[DEBUG:Macie] Retrieving details for {len(finding_ids)} findings")
1543
+ findings_details = macie_client.get_findings(findingIds=finding_ids)
1544
+
1545
+ # Process findings to clean up non-serializable objects (like datetime)
1546
+ findings = []
1547
+ raw_findings_count = len(findings_details.get("findings", []))
1548
+ print(f"[DEBUG:Macie] Processing {raw_findings_count} findings from get_findings response")
1549
+
1550
+ for finding in findings_details.get("findings", []):
1551
+ # Convert datetime objects to strings
1552
+ finding = _clean_datetime_objects(finding)
1553
+ findings.append(finding)
1554
+
1555
+ print(f"[DEBUG:Macie] Successfully processed {len(findings)} findings")
1556
+
1557
+ # Generate summary
1558
+ summary = _summarize_macie_findings(findings)
1559
+ print(f"[DEBUG:Macie] Generated summary with {summary['total_count']} findings")
1560
+
1561
+ return {
1562
+ "enabled": True,
1563
+ "message": f"Retrieved {len(findings)} Macie findings",
1564
+ "findings": findings,
1565
+ "summary": summary,
1566
+ }
1567
+ except Exception as e:
1568
+ await ctx.error(f"Error getting Macie findings: {e}")
1569
+ return {
1570
+ "enabled": True,
1571
+ "error": str(e),
1572
+ "message": "Error getting Macie findings",
1573
+ "findings": [],
1574
+ }
1575
+
1576
+
1577
+ def _summarize_macie_findings(findings: List[Dict]) -> Dict:
1578
+ """Generate a summary of Macie findings.
1579
+
1580
+ Args:
1581
+ findings: List of Macie finding dictionaries
1582
+
1583
+ Returns:
1584
+ Dictionary with summary information
1585
+ """
1586
+ summary = {
1587
+ "total_count": len(findings),
1588
+ "severity_counts": {"high": 0, "medium": 0, "low": 0},
1589
+ "type_counts": {},
1590
+ "bucket_counts": {},
1591
+ }
1592
+
1593
+ for finding in findings:
1594
+ # Count by severity
1595
+ severity = finding.get("severity", {}).get("score", 0)
1596
+ if severity >= 7:
1597
+ summary["severity_counts"]["high"] += 1
1598
+ elif severity >= 4:
1599
+ summary["severity_counts"]["medium"] += 1
1600
+ else:
1601
+ summary["severity_counts"]["low"] += 1
1602
+
1603
+ # Count by finding type
1604
+ finding_type = finding.get("type", "unknown")
1605
+ if finding_type in summary["type_counts"]:
1606
+ summary["type_counts"][finding_type] += 1
1607
+ else:
1608
+ summary["type_counts"][finding_type] = 1
1609
+
1610
+ # Count by S3 bucket
1611
+ resource = finding.get("resourcesAffected", {}).get("s3Bucket", {})
1612
+ bucket_name = resource.get("name", "unknown")
1613
+ if bucket_name in summary["bucket_counts"]:
1614
+ summary["bucket_counts"][bucket_name] += 1
1615
+ else:
1616
+ summary["bucket_counts"][bucket_name] = 1
1617
+
1618
+ return summary