runbooks 0.7.0__py3-none-any.whl → 0.7.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. runbooks/__init__.py +87 -37
  2. runbooks/cfat/README.md +300 -49
  3. runbooks/cfat/__init__.py +2 -2
  4. runbooks/finops/__init__.py +1 -1
  5. runbooks/finops/cli.py +1 -1
  6. runbooks/inventory/collectors/__init__.py +8 -0
  7. runbooks/inventory/collectors/aws_management.py +791 -0
  8. runbooks/inventory/collectors/aws_networking.py +3 -3
  9. runbooks/main.py +3389 -782
  10. runbooks/operate/__init__.py +207 -0
  11. runbooks/operate/base.py +311 -0
  12. runbooks/operate/cloudformation_operations.py +619 -0
  13. runbooks/operate/cloudwatch_operations.py +496 -0
  14. runbooks/operate/dynamodb_operations.py +812 -0
  15. runbooks/operate/ec2_operations.py +926 -0
  16. runbooks/operate/iam_operations.py +569 -0
  17. runbooks/operate/s3_operations.py +1211 -0
  18. runbooks/operate/tagging_operations.py +655 -0
  19. runbooks/remediation/CLAUDE.md +100 -0
  20. runbooks/remediation/DOME9.md +218 -0
  21. runbooks/remediation/README.md +26 -0
  22. runbooks/remediation/Tests/__init__.py +0 -0
  23. runbooks/remediation/Tests/update_policy.py +74 -0
  24. runbooks/remediation/__init__.py +95 -0
  25. runbooks/remediation/acm_cert_expired_unused.py +98 -0
  26. runbooks/remediation/acm_remediation.py +875 -0
  27. runbooks/remediation/api_gateway_list.py +167 -0
  28. runbooks/remediation/base.py +643 -0
  29. runbooks/remediation/cloudtrail_remediation.py +908 -0
  30. runbooks/remediation/cloudtrail_s3_modifications.py +296 -0
  31. runbooks/remediation/cognito_active_users.py +78 -0
  32. runbooks/remediation/cognito_remediation.py +856 -0
  33. runbooks/remediation/cognito_user_password_reset.py +163 -0
  34. runbooks/remediation/commons.py +455 -0
  35. runbooks/remediation/dynamodb_optimize.py +155 -0
  36. runbooks/remediation/dynamodb_remediation.py +744 -0
  37. runbooks/remediation/dynamodb_server_side_encryption.py +108 -0
  38. runbooks/remediation/ec2_public_ips.py +134 -0
  39. runbooks/remediation/ec2_remediation.py +892 -0
  40. runbooks/remediation/ec2_subnet_disable_auto_ip_assignment.py +72 -0
  41. runbooks/remediation/ec2_unattached_ebs_volumes.py +448 -0
  42. runbooks/remediation/ec2_unused_security_groups.py +202 -0
  43. runbooks/remediation/kms_enable_key_rotation.py +651 -0
  44. runbooks/remediation/kms_remediation.py +717 -0
  45. runbooks/remediation/lambda_list.py +243 -0
  46. runbooks/remediation/lambda_remediation.py +971 -0
  47. runbooks/remediation/multi_account.py +569 -0
  48. runbooks/remediation/rds_instance_list.py +199 -0
  49. runbooks/remediation/rds_remediation.py +873 -0
  50. runbooks/remediation/rds_snapshot_list.py +192 -0
  51. runbooks/remediation/requirements.txt +118 -0
  52. runbooks/remediation/s3_block_public_access.py +159 -0
  53. runbooks/remediation/s3_bucket_public_access.py +143 -0
  54. runbooks/remediation/s3_disable_static_website_hosting.py +74 -0
  55. runbooks/remediation/s3_downloader.py +215 -0
  56. runbooks/remediation/s3_enable_access_logging.py +562 -0
  57. runbooks/remediation/s3_encryption.py +526 -0
  58. runbooks/remediation/s3_force_ssl_secure_policy.py +143 -0
  59. runbooks/remediation/s3_list.py +141 -0
  60. runbooks/remediation/s3_object_search.py +201 -0
  61. runbooks/remediation/s3_remediation.py +816 -0
  62. runbooks/remediation/scan_for_phrase.py +425 -0
  63. runbooks/remediation/workspaces_list.py +220 -0
  64. runbooks/security/__init__.py +9 -10
  65. runbooks/security/security_baseline_tester.py +4 -2
  66. runbooks-0.7.6.dist-info/METADATA +608 -0
  67. {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/RECORD +84 -76
  68. {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/entry_points.txt +0 -1
  69. {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/top_level.txt +0 -1
  70. jupyter-agent/.env +0 -2
  71. jupyter-agent/.env.template +0 -2
  72. jupyter-agent/.gitattributes +0 -35
  73. jupyter-agent/.gradio/certificate.pem +0 -31
  74. jupyter-agent/README.md +0 -16
  75. jupyter-agent/__main__.log +0 -8
  76. jupyter-agent/app.py +0 -256
  77. jupyter-agent/cloudops-agent.png +0 -0
  78. jupyter-agent/ds-system-prompt.txt +0 -154
  79. jupyter-agent/jupyter-agent.png +0 -0
  80. jupyter-agent/llama3_template.jinja +0 -123
  81. jupyter-agent/requirements.txt +0 -9
  82. jupyter-agent/tmp/4ojbs8a02ir/jupyter-agent.ipynb +0 -68
  83. jupyter-agent/tmp/cm5iasgpm3p/jupyter-agent.ipynb +0 -91
  84. jupyter-agent/tmp/crqbsseag5/jupyter-agent.ipynb +0 -91
  85. jupyter-agent/tmp/hohanq1u097/jupyter-agent.ipynb +0 -57
  86. jupyter-agent/tmp/jns1sam29wm/jupyter-agent.ipynb +0 -53
  87. jupyter-agent/tmp/jupyter-agent.ipynb +0 -27
  88. jupyter-agent/utils.py +0 -409
  89. runbooks/aws/__init__.py +0 -58
  90. runbooks/aws/dynamodb_operations.py +0 -231
  91. runbooks/aws/ec2_copy_image_cross-region.py +0 -195
  92. runbooks/aws/ec2_describe_instances.py +0 -202
  93. runbooks/aws/ec2_ebs_snapshots_delete.py +0 -186
  94. runbooks/aws/ec2_run_instances.py +0 -213
  95. runbooks/aws/ec2_start_stop_instances.py +0 -212
  96. runbooks/aws/ec2_terminate_instances.py +0 -143
  97. runbooks/aws/ec2_unused_eips.py +0 -196
  98. runbooks/aws/ec2_unused_volumes.py +0 -188
  99. runbooks/aws/s3_create_bucket.py +0 -142
  100. runbooks/aws/s3_list_buckets.py +0 -152
  101. runbooks/aws/s3_list_objects.py +0 -156
  102. runbooks/aws/s3_object_operations.py +0 -183
  103. runbooks/aws/tagging_lambda_handler.py +0 -183
  104. runbooks/inventory/FAILED_SCRIPTS_TROUBLESHOOTING.md +0 -619
  105. runbooks/inventory/PASSED_SCRIPTS_GUIDE.md +0 -738
  106. runbooks/inventory/aws_organization.png +0 -0
  107. runbooks/inventory/cfn_move_stack_instances.py +0 -1526
  108. runbooks/inventory/delete_s3_buckets_objects.py +0 -169
  109. runbooks/inventory/lockdown_cfn_stackset_role.py +0 -224
  110. runbooks/inventory/update_aws_actions.py +0 -173
  111. runbooks/inventory/update_cfn_stacksets.py +0 -1215
  112. runbooks/inventory/update_cloudwatch_logs_retention_policy.py +0 -294
  113. runbooks/inventory/update_iam_roles_cross_accounts.py +0 -478
  114. runbooks/inventory/update_s3_public_access_block.py +0 -539
  115. runbooks/organizations/__init__.py +0 -12
  116. runbooks/organizations/manager.py +0 -374
  117. runbooks-0.7.0.dist-info/METADATA +0 -375
  118. /runbooks/inventory/{tests → Tests}/common_test_data.py +0 -0
  119. /runbooks/inventory/{tests → Tests}/common_test_functions.py +0 -0
  120. /runbooks/inventory/{tests → Tests}/script_test_data.py +0 -0
  121. /runbooks/inventory/{tests → Tests}/setup.py +0 -0
  122. /runbooks/inventory/{tests → Tests}/src.py +0 -0
  123. /runbooks/inventory/{tests/test_inventory_modules.py → Tests/test_Inventory_Modules.py} +0 -0
  124. /runbooks/inventory/{tests → Tests}/test_cfn_describe_stacks.py +0 -0
  125. /runbooks/inventory/{tests → Tests}/test_ec2_describe_instances.py +0 -0
  126. /runbooks/inventory/{tests → Tests}/test_lambda_list_functions.py +0 -0
  127. /runbooks/inventory/{tests → Tests}/test_moto_integration_example.py +0 -0
  128. /runbooks/inventory/{tests → Tests}/test_org_list_accounts.py +0 -0
  129. /runbooks/inventory/{Inventory_Modules.py → inventory_modules.py} +0 -0
  130. /runbooks/{aws → operate}/tags.json +0 -0
  131. {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/WHEEL +0 -0
  132. {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,296 @@
1
+ """
2
+ 🚨 HIGH-RISK: CloudTrail S3 Policy Modifications - Audit trail security operations.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ from datetime import datetime, timedelta
8
+
9
+ import click
10
+ from botocore.exceptions import ClientError
11
+
12
+ from .commons import display_aws_account_info, get_client
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def apply_policy(policy_json: dict, bucket_name: str, backup_enabled: bool = True):
18
+ """Apply S3 bucket policy with backup and validation."""
19
+ try:
20
+ s3 = get_client("s3")
21
+
22
+ # Backup existing policy before applying new one
23
+ backup_policy = None
24
+ if backup_enabled:
25
+ try:
26
+ backup_response = s3.get_bucket_policy(Bucket=bucket_name)
27
+ backup_policy = backup_response["Policy"]
28
+ logger.info(f"✅ Backed up existing policy for bucket: {bucket_name}")
29
+ except ClientError as e:
30
+ if e.response.get("Error", {}).get("Code") != "NoSuchBucketPolicy":
31
+ logger.warning(f"Could not backup policy: {e}")
32
+
33
+ # Apply the new policy
34
+ s3.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps(policy_json))
35
+ logger.info(f"✅ Applied new policy to bucket: {bucket_name}")
36
+
37
+ return backup_policy
38
+
39
+ except ClientError as e:
40
+ logger.error(f"❌ Failed to apply policy to bucket '{bucket_name}': {e}")
41
+ raise
42
+
43
+
44
+ def get_s3_policy_modifications(user_email, start_time=None, end_time=None):
45
+ """Search CloudTrail for S3 policy modifications by a specific user."""
46
+ try:
47
+ cloudtrail = get_client("cloudtrail")
48
+ config = get_client("config")
49
+
50
+ # Set default time range if not provided (last 30 days)
51
+ if not end_time:
52
+ end_time = datetime.utcnow()
53
+ if not start_time:
54
+ start_time = end_time - timedelta(days=30)
55
+
56
+ logger.info(f"🔍 Searching CloudTrail events from {start_time} to {end_time}")
57
+
58
+ # Define CloudTrail LookupEvent parameters
59
+ event_selector = {
60
+ "LookupAttributes": [
61
+ {"AttributeKey": "EventName", "AttributeValue": "PutBucketPolicy"},
62
+ {"AttributeKey": "ResourceType", "AttributeValue": "AWS::S3::Bucket"},
63
+ ],
64
+ "StartTime": start_time,
65
+ "EndTime": end_time,
66
+ }
67
+
68
+ response = cloudtrail.lookup_events(**event_selector)
69
+ events = response.get("Events", [])
70
+
71
+ logger.info(f"Found {len(events)} S3 policy modification events")
72
+
73
+ modifications = []
74
+
75
+ for event in events:
76
+ try:
77
+ cloudtrail_event = json.loads(event["CloudTrailEvent"])
78
+ user_identity = cloudtrail_event.get("userIdentity", {})
79
+
80
+ # Check for modifications by the specified user (multiple ways to match)
81
+ user_matches = False
82
+ principal_id = user_identity.get("principalId", "")
83
+ user_name = user_identity.get("userName", "")
84
+ arn = user_identity.get("arn", "")
85
+
86
+ if user_email in principal_id or user_email in user_name or user_email in arn:
87
+ user_matches = True
88
+
89
+ if user_matches:
90
+ # Extract bucket name and policy changes
91
+ request_params = cloudtrail_event.get("requestParameters", {})
92
+ bucket_name = request_params.get("bucketName")
93
+ new_policy = request_params.get("bucketPolicy")
94
+
95
+ if not bucket_name:
96
+ logger.warning(f"No bucket name found in event: {event.get('EventId', 'Unknown')}")
97
+ continue
98
+
99
+ logger.debug(f"Found modification to bucket: {bucket_name}")
100
+
101
+ # Try to get previous policy from AWS Config
102
+ old_policy = None
103
+ try:
104
+ config_response = config.get_resource_config_history(
105
+ resourceType="AWS::S3::Bucket",
106
+ resourceId=bucket_name,
107
+ laterTime=event["EventTime"],
108
+ limit=1,
109
+ )
110
+
111
+ if config_response.get("configurationItems"):
112
+ old_config = config_response["configurationItems"][0]
113
+ bucket_policy_config = old_config.get("supplementaryConfiguration", {}).get("BucketPolicy")
114
+
115
+ if bucket_policy_config:
116
+ policy_data = json.loads(bucket_policy_config)
117
+ if policy_data.get("policyText"):
118
+ old_policy = json.loads(policy_data["policyText"])
119
+
120
+ except ClientError as e:
121
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
122
+ if error_code == "ResourceNotDiscoveredException":
123
+ logger.debug(f"Bucket {bucket_name} not tracked in Config")
124
+ else:
125
+ logger.warning(f"Config query failed for {bucket_name}: {e}")
126
+
127
+ modification = {
128
+ "BucketName": bucket_name,
129
+ "NewPolicy": new_policy,
130
+ "OldPolicy": old_policy,
131
+ "EventTime": event["EventTime"],
132
+ "EventId": event.get("EventId"),
133
+ "UserIdentity": user_identity,
134
+ }
135
+
136
+ modifications.append(modification)
137
+
138
+ except (json.JSONDecodeError, KeyError) as e:
139
+ logger.warning(f"Failed to parse CloudTrail event: {e}")
140
+ continue
141
+
142
+ logger.info(f"Found {len(modifications)} policy modifications by user: {user_email}")
143
+ return modifications
144
+
145
+ except ClientError as e:
146
+ logger.error(f"Failed to query CloudTrail: {e}")
147
+ raise
148
+
149
+
150
+ @click.command()
151
+ @click.option("--email", required=True, help="User email to check for S3 policy modifications")
152
+ @click.option("--days", default=30, help="Number of days to look back for modifications")
153
+ @click.option("--dry-run", is_flag=True, default=True, help="Preview mode - show analysis without reverting policies")
154
+ @click.option("--confirm", is_flag=True, help="Skip confirmation prompts (dangerous!)")
155
+ def cloudtrail_s3_modifications(email, days, dry_run, confirm):
156
+ """🚨 HIGH-RISK: Analyze and potentially revert S3 policy modifications from CloudTrail."""
157
+
158
+ # HIGH-RISK OPERATION WARNING
159
+ if not dry_run and not confirm:
160
+ logger.warning("🚨 HIGH-RISK OPERATION: S3 Policy Reversion")
161
+ logger.warning("This operation can modify S3 bucket policies based on CloudTrail analysis")
162
+ if not click.confirm("Do you want to continue?"):
163
+ logger.info("Operation cancelled by user")
164
+ return
165
+
166
+ logger.info(f"🔍 CloudTrail S3 policy analysis in {display_aws_account_info()}")
167
+ logger.info(f"Analyzing modifications by user: {email}")
168
+ logger.info(f"Looking back {days} days")
169
+
170
+ try:
171
+ # Set time range
172
+ end_time = datetime.utcnow()
173
+ start_time = end_time - timedelta(days=days)
174
+
175
+ # Get policy modifications
176
+ policy_changes = get_s3_policy_modifications(email, start_time, end_time)
177
+
178
+ if not policy_changes:
179
+ logger.info(f"✅ No S3 bucket policy modifications found for user: {email}")
180
+ return
181
+
182
+ logger.warning(f"⚠ Found {len(policy_changes)} policy modifications")
183
+
184
+ # Analyze each modification
185
+ reversion_candidates = []
186
+
187
+ for i, change in enumerate(policy_changes, 1):
188
+ bucket_name = change["BucketName"]
189
+ event_time = change["EventTime"]
190
+ event_id = change.get("EventId", "Unknown")
191
+
192
+ logger.info(f"\n📋 Modification {i}/{len(policy_changes)}:")
193
+ logger.info(f" Bucket: {bucket_name}")
194
+ logger.info(f" Event Time: {event_time}")
195
+ logger.info(f" Event ID: {event_id}")
196
+
197
+ # Show user identity details
198
+ user_identity = change.get("UserIdentity", {})
199
+ logger.info(f" User Type: {user_identity.get('type', 'Unknown')}")
200
+ logger.info(f" Principal ID: {user_identity.get('principalId', 'Unknown')}")
201
+
202
+ # Analyze policy changes
203
+ new_policy = change.get("NewPolicy")
204
+ old_policy = change.get("OldPolicy")
205
+
206
+ if old_policy:
207
+ logger.info(f" ✅ Previous policy found - reversion possible")
208
+
209
+ # Check if current policy still matches the problematic one
210
+ try:
211
+ s3 = get_client("s3")
212
+ current_response = s3.get_bucket_policy(Bucket=bucket_name)
213
+ current_policy = json.loads(current_response["Policy"])
214
+
215
+ # Simple comparison - check if old policy statements are missing
216
+ old_statements = old_policy.get("Statement", [])
217
+ current_statements = current_policy.get("Statement", [])
218
+
219
+ missing_statements = []
220
+ for old_stmt in old_statements:
221
+ if old_stmt not in current_statements:
222
+ missing_statements.append(old_stmt)
223
+
224
+ if missing_statements:
225
+ logger.warning(f" ⚠ {len(missing_statements)} policy statements appear to be missing")
226
+ reversion_candidates.append(
227
+ {
228
+ "bucket": bucket_name,
229
+ "old_policy": old_policy,
230
+ "current_policy": current_policy,
231
+ "missing_statements": missing_statements,
232
+ "event_time": event_time,
233
+ }
234
+ )
235
+ else:
236
+ logger.info(f" ✓ Current policy appears to include previous statements")
237
+
238
+ except ClientError as e:
239
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
240
+ if error_code == "NoSuchBucketPolicy":
241
+ logger.warning(f" ⚠ Bucket currently has no policy")
242
+ else:
243
+ logger.error(f" ❌ Failed to get current policy: {e}")
244
+
245
+ else:
246
+ logger.warning(f" ⚠ No previous policy found - cannot revert")
247
+
248
+ # Show policy details if verbose
249
+ logger.debug(f" New Policy: {json.dumps(new_policy, indent=2) if new_policy else 'None'}")
250
+ logger.debug(f" Old Policy: {json.dumps(old_policy, indent=2) if old_policy else 'None'}")
251
+
252
+ # Summary and reversion
253
+ logger.info(f"\n=== ANALYSIS SUMMARY ===")
254
+ logger.info(f"Total modifications found: {len(policy_changes)}")
255
+ logger.info(f"Buckets eligible for reversion: {len(reversion_candidates)}")
256
+
257
+ if reversion_candidates:
258
+ logger.warning(f"⚠ {len(reversion_candidates)} buckets have potential policy issues")
259
+
260
+ if dry_run:
261
+ logger.info("DRY-RUN: Would attempt to revert the following buckets:")
262
+ for candidate in reversion_candidates:
263
+ logger.info(f" - {candidate['bucket']} (modified: {candidate['event_time']})")
264
+ logger.info("To perform actual reversion, run with --no-dry-run")
265
+ else:
266
+ # Perform reversions with confirmation
267
+ for candidate in reversion_candidates:
268
+ bucket_name = candidate["bucket"]
269
+ old_policy = candidate["old_policy"]
270
+
271
+ if not confirm:
272
+ logger.warning(f"\n🚨 REVERT BUCKET POLICY:")
273
+ logger.warning(f" Bucket: {bucket_name}")
274
+ logger.warning(f" Event Time: {candidate['event_time']}")
275
+ if not click.confirm(f"Revert policy for bucket {bucket_name}?"):
276
+ logger.info(f"Skipped reversion for {bucket_name}")
277
+ continue
278
+
279
+ logger.info(f"🔄 Reverting policy for bucket: {bucket_name}")
280
+ try:
281
+ backup_policy = apply_policy(old_policy, bucket_name, backup_enabled=True)
282
+ logger.info(f"✅ Successfully reverted policy for: {bucket_name}")
283
+
284
+ # Log the reversion for audit
285
+ logger.info(f"🔍 Audit: Policy reversion completed")
286
+ logger.info(f" Bucket: {bucket_name}")
287
+ logger.info(f" Original Event Time: {candidate['event_time']}")
288
+
289
+ except Exception as e:
290
+ logger.error(f"❌ Failed to revert policy for {bucket_name}: {e}")
291
+ else:
292
+ logger.info("✅ No policy reversions needed")
293
+
294
+ except Exception as e:
295
+ logger.error(f"❌ CloudTrail analysis failed: {e}")
296
+ raise
@@ -0,0 +1,78 @@
1
+ """
2
+ Cognito User Analysis - List active users in Cognito User Pools.
3
+ """
4
+
5
+ import logging
6
+
7
+ import click
8
+ from botocore.exceptions import ClientError
9
+
10
+ from .commons import get_client, write_to_csv
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @click.command()
16
+ @click.option("--user-pool-id", prompt="User Pool ID", help="The ID of the Cognito User Pool")
17
+ @click.option("--output-file", default="cognito_active_users.csv", help="Output CSV file path")
18
+ def get_active_users_in_cognito_pool(user_pool_id, output_file):
19
+ """Get active users in a Cognito User Pool and export to CSV."""
20
+ logger.info(f"Getting active users from Cognito User Pool: {user_pool_id}")
21
+
22
+ try:
23
+ client_cognito = get_client("cognito-idp")
24
+
25
+ # Get active users (enabled status)
26
+ response = client_cognito.list_users(UserPoolId=user_pool_id, Filter='status="Enabled"')
27
+
28
+ users = response.get("Users", [])
29
+
30
+ if not users:
31
+ logger.info("No active users found in the user pool")
32
+ return
33
+
34
+ logger.info(f"Found {len(users)} active users")
35
+
36
+ # Process user data
37
+ user_data = []
38
+ for user in users:
39
+ user_info = {
40
+ "username": user.get("Username", ""),
41
+ "status": user.get("UserStatus", ""),
42
+ "created_at": user["UserCreateDate"].isoformat() if "UserCreateDate" in user else "",
43
+ "last_modified_at": user["UserLastModifiedDate"].isoformat() if "UserLastModifiedDate" in user else "",
44
+ "enabled": user.get("Enabled", False),
45
+ "email_verified": False,
46
+ "phone_verified": False,
47
+ }
48
+
49
+ # Extract email and phone verification status from attributes
50
+ for attr in user.get("Attributes", []):
51
+ if attr.get("Name") == "email_verified":
52
+ user_info["email_verified"] = attr.get("Value") == "true"
53
+ elif attr.get("Name") == "phone_number_verified":
54
+ user_info["phone_verified"] = attr.get("Value") == "true"
55
+
56
+ user_data.append(user_info)
57
+ logger.debug(f"Processed user: {user_info['username']}")
58
+
59
+ # Export to CSV
60
+ write_to_csv(user_data, output_file)
61
+ logger.info(f"User data exported to: {output_file}")
62
+
63
+ # Summary
64
+ enabled_count = sum(1 for u in user_data if u["enabled"])
65
+ email_verified_count = sum(1 for u in user_data if u["email_verified"])
66
+
67
+ logger.info(f"Summary: {enabled_count} enabled users, {email_verified_count} with verified emails")
68
+
69
+ except ClientError as e:
70
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
71
+ if error_code == "ResourceNotFoundException":
72
+ logger.error(f"User pool not found: {user_pool_id}")
73
+ else:
74
+ logger.error(f"Failed to get Cognito users: {e}")
75
+ raise
76
+ except Exception as e:
77
+ logger.error(f"Unexpected error: {e}")
78
+ raise