runbooks 0.9.8__py3-none-any.whl → 0.9.9__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.
@@ -49,7 +49,7 @@ from ..common.rich_utils import (
49
49
  )
50
50
  from .embedded_mcp_validator import EmbeddedMCPValidator
51
51
  from ..common.profile_utils import get_profile_for_operation
52
- from ..enterprise.security import EnterpriseSecurityModule
52
+ from ..security.enterprise_security_framework import EnterpriseSecurityFramework
53
53
 
54
54
  logger = logging.getLogger(__name__)
55
55
 
@@ -87,6 +87,28 @@ class VPCCleanupCandidate(BaseModel):
87
87
  risk_assessment: str = "medium" # low, medium, high
88
88
  business_impact: str = "minimal" # minimal, moderate, significant
89
89
  tags: Dict[str, str] = Field(default_factory=dict)
90
+
91
+ # Enhanced fields for advanced filtering
92
+ account_id: Optional[str] = None
93
+ flow_logs_enabled: bool = False
94
+ load_balancers: List[str] = Field(default_factory=list)
95
+ iac_detected: bool = False
96
+ owners_approvals: List[str] = Field(default_factory=list)
97
+
98
+ @property
99
+ def is_no_eni_vpc(self) -> bool:
100
+ """Check if VPC has zero ENI attachments (safe for cleanup)."""
101
+ return self.dependency_analysis.eni_count == 0
102
+
103
+ @property
104
+ def is_nil_vpc(self) -> bool:
105
+ """Check if VPC has no resources (empty VPC)."""
106
+ return (
107
+ self.dependency_analysis.eni_count == 0 and
108
+ len(self.dependency_analysis.route_tables) <= 1 and # Only default route table
109
+ len(self.dependency_analysis.nat_gateways) == 0 and
110
+ len(self.dependency_analysis.vpc_endpoints) == 0
111
+ )
90
112
 
91
113
 
92
114
  class VPCCleanupResults(BaseModel):
@@ -102,6 +124,7 @@ class VPCCleanupResults(BaseModel):
102
124
  evidence_hash: Optional[str] = None
103
125
  safety_assessment: str = "graduated_risk_approach"
104
126
  security_assessment: Optional[Dict[str, Any]] = None
127
+ multi_account_context: Optional[Dict[str, Any]] = Field(default_factory=dict)
105
128
 
106
129
 
107
130
  class VPCCleanupOptimizer:
@@ -129,7 +152,55 @@ class VPCCleanupOptimizer:
129
152
 
130
153
  print_info(f"VPC Cleanup Optimizer initialized with profile: {self.profile}")
131
154
 
132
- def analyze_vpc_cleanup_opportunities(self) -> VPCCleanupResults:
155
+ def filter_vpcs_by_criteria(self, candidates: List[VPCCleanupCandidate],
156
+ no_eni_only: bool = False,
157
+ filter_type: str = "all") -> List[VPCCleanupCandidate]:
158
+ """
159
+ Filter VPC candidates based on specified criteria.
160
+
161
+ Args:
162
+ candidates: List of VPC cleanup candidates
163
+ no_eni_only: If True, show only VPCs with zero ENI attachments
164
+ filter_type: Filter type - 'none', 'default', or 'all'
165
+
166
+ Returns:
167
+ Filtered list of VPC candidates
168
+ """
169
+ filtered_candidates = candidates.copy()
170
+
171
+ # Apply no-ENI-only filter
172
+ if no_eni_only:
173
+ filtered_candidates = [
174
+ candidate for candidate in filtered_candidates
175
+ if candidate.is_no_eni_vpc
176
+ ]
177
+ print_info(f"🔍 No-ENI filter applied - {len(filtered_candidates)} VPCs with zero ENI attachments")
178
+
179
+ # Apply type-based filters
180
+ if filter_type == "none":
181
+ # Show only VPCs with no resources (nil VPCs)
182
+ filtered_candidates = [
183
+ candidate for candidate in filtered_candidates
184
+ if candidate.is_nil_vpc
185
+ ]
186
+ print_info(f"📋 'None' filter applied - {len(filtered_candidates)} VPCs with no resources")
187
+
188
+ elif filter_type == "default":
189
+ # Show only default VPCs
190
+ filtered_candidates = [
191
+ candidate for candidate in filtered_candidates
192
+ if candidate.is_default
193
+ ]
194
+ print_info(f"🏠 'Default' filter applied - {len(filtered_candidates)} default VPCs")
195
+
196
+ elif filter_type == "all":
197
+ # Show all VPCs (no additional filtering)
198
+ print_info(f"📊 'All' filter applied - {len(filtered_candidates)} total VPCs")
199
+
200
+ return filtered_candidates
201
+
202
+ def analyze_vpc_cleanup_opportunities(self, no_eni_only: bool = False,
203
+ filter_type: str = "all") -> VPCCleanupResults:
133
204
  """
134
205
  Comprehensive VPC cleanup analysis with three-bucket strategy.
135
206
 
@@ -143,7 +214,7 @@ class VPCCleanupOptimizer:
143
214
 
144
215
  Returns: Comprehensive cleanup analysis with validated savings
145
216
  """
146
- print_header("VPC Cleanup Cost Optimization Engine", "v0.9.1")
217
+ print_header("VPC Cleanup Cost Optimization Engine", "v0.9.9")
147
218
  print_info("AWSO-05 Implementation - Three-Bucket Strategy")
148
219
 
149
220
  # Initialize MCP validator for accuracy validation
@@ -155,12 +226,22 @@ class VPCCleanupOptimizer:
155
226
  # Step 2: Dependency analysis for each VPC
156
227
  analyzed_candidates = self._analyze_vpc_dependencies(vpc_candidates)
157
228
 
229
+ # Step 2.5: Apply filtering based on criteria
230
+ filtered_candidates = self.filter_vpcs_by_criteria(
231
+ analyzed_candidates,
232
+ no_eni_only=no_eni_only,
233
+ filter_type=filter_type
234
+ )
235
+
158
236
  # Step 3: Three-bucket classification
159
- bucket_classification = self._classify_three_bucket_strategy(analyzed_candidates)
237
+ bucket_classification = self._classify_three_bucket_strategy(filtered_candidates)
160
238
 
161
239
  # Step 4: Enhanced VPC security assessment integration
162
240
  security_assessment = self._perform_vpc_security_assessment(analyzed_candidates)
163
241
 
242
+ # Step 4.5: Re-classify buckets after security assessment (ensure NO-ENI VPCs stay in Bucket 1)
243
+ bucket_classification = self._ensure_no_eni_bucket_1_classification(bucket_classification)
244
+
164
245
  # Step 5: Cost calculation and savings estimation
165
246
  cost_analysis = self._calculate_vpc_cleanup_costs(bucket_classification)
166
247
 
@@ -175,6 +256,190 @@ class VPCCleanupOptimizer:
175
256
 
176
257
  return results
177
258
 
259
+ def analyze_vpc_cleanup_opportunities_multi_account(self,
260
+ account_ids: List[str],
261
+ accounts_info: Dict[str, Any],
262
+ no_eni_only: bool = False,
263
+ filter_type: str = "all",
264
+ progress_callback=None) -> 'VPCCleanupResults':
265
+ """
266
+ ENHANCED: Multi-account VPC cleanup analysis with Organizations integration.
267
+
268
+ Critical Fix: Instead of assuming cross-account access, this method aggregates
269
+ VPC discovery from the current profile's accessible scope, which may span
270
+ multiple accounts if the profile has appropriate permissions.
271
+
272
+ Args:
273
+ account_ids: List of account IDs from Organizations discovery
274
+ accounts_info: Account metadata from Organizations API
275
+ no_eni_only: Filter to only VPCs with zero ENI attachments
276
+ filter_type: Filter criteria ("all", "no-eni", "default", "tagged")
277
+ progress_callback: Optional callback for progress updates
278
+
279
+ Returns: Aggregated VPC cleanup analysis across accessible accounts
280
+ """
281
+ print_header("Multi-Account VPC Cleanup Analysis", "v0.9.9")
282
+ print_info(f"🏢 Analyzing VPCs across {len(account_ids)} organization accounts")
283
+ print_info(f"🔐 Using profile: {self.profile} (scope: accessible accounts only)")
284
+
285
+ # Initialize analysis timing
286
+ self.analysis_start_time = time.time()
287
+
288
+ # Initialize MCP validator for accuracy validation
289
+ self.mcp_validator = EmbeddedMCPValidator([self.profile])
290
+
291
+ if progress_callback:
292
+ progress_callback("Initializing multi-account discovery...")
293
+
294
+ # Enhanced VPC discovery with organization context
295
+ vpc_candidates = self._discover_vpc_candidates_multi_account(account_ids, accounts_info, progress_callback)
296
+
297
+ if progress_callback:
298
+ progress_callback("Analyzing VPC dependencies...")
299
+
300
+ # Follow standard analysis pipeline
301
+ analyzed_candidates = self._analyze_vpc_dependencies(vpc_candidates)
302
+ filtered_candidates = self.filter_vpcs_by_criteria(
303
+ analyzed_candidates,
304
+ no_eni_only=no_eni_only,
305
+ filter_type=filter_type
306
+ )
307
+
308
+ if progress_callback:
309
+ progress_callback("Performing three-bucket classification...")
310
+
311
+ bucket_classification = self._classify_three_bucket_strategy(filtered_candidates)
312
+ security_assessment = self._perform_vpc_security_assessment(analyzed_candidates)
313
+ bucket_classification = self._ensure_no_eni_bucket_1_classification(bucket_classification)
314
+
315
+ if progress_callback:
316
+ progress_callback("Calculating cost analysis...")
317
+
318
+ cost_analysis = self._calculate_vpc_cleanup_costs(bucket_classification)
319
+ validation_results = self._validate_analysis_with_mcp(cost_analysis)
320
+
321
+ if progress_callback:
322
+ progress_callback("Generating comprehensive results...")
323
+
324
+ # Generate results with multi-account context
325
+ results = self._generate_comprehensive_results(cost_analysis, validation_results, security_assessment)
326
+
327
+ # Add multi-account metadata
328
+ results.multi_account_context = {
329
+ 'total_accounts_analyzed': len(account_ids),
330
+ 'accounts_with_vpcs': len(set(candidate.account_id for candidate in vpc_candidates if candidate.account_id)),
331
+ 'organization_id': accounts_info.get('organization_id', 'unknown'),
332
+ 'accounts': [
333
+ {
334
+ 'account_id': account_id,
335
+ 'account_name': accounts_info.get('accounts', {}).get(account_id, {}).get('Name', 'unknown'),
336
+ 'status': accounts_info.get('accounts', {}).get(account_id, {}).get('Status', 'unknown')
337
+ }
338
+ for account_id in account_ids
339
+ ],
340
+ 'vpc_count_by_account': self._calculate_vpc_count_by_account(vpc_candidates),
341
+ 'analysis_scope': 'organization',
342
+ 'profile_access_scope': self.profile
343
+ }
344
+
345
+ print_success(f"✅ Multi-account analysis complete: {results.total_vpcs_analyzed} VPCs across organization")
346
+ return results
347
+
348
+ def _calculate_vpc_count_by_account(self, vpc_candidates: List[VPCCleanupCandidate]) -> Dict[str, int]:
349
+ """Calculate VPC count by account from the candidate list."""
350
+ vpc_count_by_account = {}
351
+
352
+ for candidate in vpc_candidates:
353
+ account_id = candidate.account_id or 'unknown'
354
+ if account_id in vpc_count_by_account:
355
+ vpc_count_by_account[account_id] += 1
356
+ else:
357
+ vpc_count_by_account[account_id] = 1
358
+
359
+ return vpc_count_by_account
360
+
361
+ def _discover_vpc_candidates_multi_account(self, account_ids: List[str],
362
+ accounts_info: Dict[str, Any],
363
+ progress_callback=None) -> List[VPCCleanupCandidate]:
364
+ """
365
+ Enhanced VPC discovery with organization account context.
366
+
367
+ CRITICAL INSIGHT: This method discovers VPCs that the current profile can access,
368
+ which may include VPCs from multiple accounts if the profile has cross-account permissions
369
+ (e.g., via AWS SSO or cross-account roles).
370
+ """
371
+ vpc_candidates = []
372
+
373
+ if progress_callback:
374
+ progress_callback("Discovering VPCs across regions...")
375
+
376
+ # Get list of all regions
377
+ ec2_client = self.session.client('ec2', region_name='us-east-1')
378
+ regions = [region['RegionName'] for region in ec2_client.describe_regions()['Regions']]
379
+
380
+ print_info(f"🌍 Scanning {len(regions)} AWS regions for accessible VPCs...")
381
+
382
+ regions_with_vpcs = 0
383
+ for region in regions:
384
+ try:
385
+ regional_ec2 = self.session.client('ec2', region_name=region)
386
+ response = regional_ec2.describe_vpcs()
387
+
388
+ region_vpc_count = 0
389
+ for vpc in response['Vpcs']:
390
+ # Enhanced VPC candidate with account detection
391
+ candidate = VPCCleanupCandidate(
392
+ vpc_id=vpc['VpcId'],
393
+ region=region,
394
+ state=vpc['State'],
395
+ cidr_block=vpc['CidrBlock'],
396
+ is_default=vpc.get('IsDefault', False),
397
+ account_id=self._detect_vpc_account_id(vpc), # Enhanced account detection
398
+ dependency_analysis=VPCDependencyAnalysis(
399
+ vpc_id=vpc['VpcId'],
400
+ region=region,
401
+ is_default_vpc=vpc.get('IsDefault', False)
402
+ ),
403
+ tags={tag['Key']: tag['Value'] for tag in vpc.get('Tags', [])}
404
+ )
405
+ vpc_candidates.append(candidate)
406
+ region_vpc_count += 1
407
+
408
+ if region_vpc_count > 0:
409
+ regions_with_vpcs += 1
410
+
411
+ except ClientError as e:
412
+ if "UnauthorizedOperation" in str(e):
413
+ print_warning(f"No access to region {region} (expected for cross-account)")
414
+ else:
415
+ print_warning(f"Could not access region {region}: {e}")
416
+
417
+ print_success(f"✅ Discovered {len(vpc_candidates)} VPCs across {regions_with_vpcs} accessible regions")
418
+ print_info(f"📊 Organization context: {len(account_ids)} accounts in scope")
419
+
420
+ return vpc_candidates
421
+
422
+ def _detect_vpc_account_id(self, vpc_data: Dict[str, Any]) -> Optional[str]:
423
+ """
424
+ Detect account ID for VPC (enhanced for multi-account context).
425
+
426
+ In multi-account scenarios, VPC ARN or tags may contain account information.
427
+ """
428
+ # Try to extract account ID from VPC ARN if available
429
+ if 'VpcArn' in vpc_data:
430
+ # VPC ARN format: arn:aws:ec2:region:account-id:vpc/vpc-id
431
+ arn_parts = vpc_data['VpcArn'].split(':')
432
+ if len(arn_parts) >= 5:
433
+ return arn_parts[4]
434
+
435
+ # Fallback: Try to get from current session context
436
+ try:
437
+ sts_client = self.session.client('sts')
438
+ response = sts_client.get_caller_identity()
439
+ return response.get('Account')
440
+ except Exception:
441
+ return None
442
+
178
443
  def _discover_vpc_candidates(self) -> List[VPCCleanupCandidate]:
179
444
  """Discover VPC candidates across all AWS regions."""
180
445
  vpc_candidates = []
@@ -295,6 +560,9 @@ class VPCCleanupOptimizer:
295
560
  candidate.dependency_analysis
296
561
  )
297
562
 
563
+ # Enhanced data collection for new fields
564
+ self._collect_enhanced_vpc_data(candidate, ec2_client)
565
+
298
566
  analyzed_candidates.append(candidate)
299
567
 
300
568
  except ClientError as e:
@@ -307,20 +575,140 @@ class VPCCleanupOptimizer:
307
575
  return analyzed_candidates
308
576
 
309
577
  def _calculate_dependency_risk(self, dependency_analysis: VPCDependencyAnalysis) -> str:
310
- """Calculate dependency risk level based on VPC resource analysis."""
311
- # Bucket 1: Internal data plane (Low Risk)
312
- if (dependency_analysis.eni_count == 0 and
313
- len(dependency_analysis.nat_gateways) == 0 and
314
- len(dependency_analysis.vpc_endpoints) == 0 and
315
- len(dependency_analysis.peering_connections) == 0):
316
- return "low"
317
-
318
- # Bucket 3: Control plane (High Risk - Default VPC)
578
+ """
579
+ Calculate dependency risk level based on VPC resource analysis.
580
+
581
+ CRITICAL FIX: Prioritize ENI count = 0 for safe Bucket 1 classification.
582
+ NO-ENI VPCs are inherently safe regardless of other infrastructure present.
583
+ """
584
+ # PRIORITY 1: NO-ENI VPCs are inherently low risk (safe for immediate deletion)
585
+ # This overrides all other factors - if no ENI attachments, no active workloads depend on it
586
+ if dependency_analysis.eni_count == 0:
587
+ return "low" # Safe for Bucket 1 - Ready for deletion
588
+
589
+ # PRIORITY 2: Default VPCs always require careful handling
319
590
  if dependency_analysis.is_default_vpc:
320
- return "high"
591
+ return "high" # Bucket 3 - Manual review required
592
+
593
+ # PRIORITY 3: VPCs with ENI attachments require dependency analysis
594
+ # These have active workloads and need investigation
595
+ return "medium" # Bucket 2 - Requires analysis
596
+
597
+ def _collect_enhanced_vpc_data(self, candidate: VPCCleanupCandidate, ec2_client) -> None:
598
+ """Collect enhanced VPC data for new fields."""
599
+ try:
600
+ # Get account ID from session
601
+ sts_client = self.session.client('sts', region_name=candidate.region)
602
+ account_info = sts_client.get_caller_identity()
603
+ candidate.account_id = account_info.get('Account')
604
+
605
+ # Detect flow logs
606
+ candidate.flow_logs_enabled = self._detect_flow_logs(candidate.vpc_id, ec2_client)
607
+
608
+ # Detect load balancers
609
+ candidate.load_balancers = self._detect_load_balancers(candidate.vpc_id, candidate.region)
610
+
611
+ # Analyze IaC indicators in tags
612
+ candidate.iac_detected = self._analyze_iac_tags(candidate.tags)
613
+
614
+ # Extract owner information from tags
615
+ candidate.owners_approvals = self._extract_owners_from_tags(candidate.tags)
616
+
617
+ except Exception as e:
618
+ print_warning(f"Enhanced data collection failed for VPC {candidate.vpc_id}: {e}")
619
+
620
+ def _detect_flow_logs(self, vpc_id: str, ec2_client) -> bool:
621
+ """Detect if VPC Flow Logs are enabled."""
622
+ try:
623
+ response = ec2_client.describe_flow_logs(
624
+ Filters=[
625
+ {'Name': 'resource-id', 'Values': [vpc_id]},
626
+ {'Name': 'resource-type', 'Values': ['VPC']}
627
+ ]
628
+ )
629
+ return len(response['FlowLogs']) > 0
630
+ except Exception as e:
631
+ print_warning(f"Flow logs detection failed for VPC {vpc_id}: {e}")
632
+ return False
633
+
634
+ def _detect_load_balancers(self, vpc_id: str, region: str) -> List[str]:
635
+ """Detect load balancers associated with the VPC."""
636
+ load_balancers = []
637
+
638
+ try:
639
+ # Check Application/Network Load Balancers (ELBv2)
640
+ elbv2_client = self.session.client('elbv2', region_name=region)
641
+ response = elbv2_client.describe_load_balancers()
642
+
643
+ for lb in response['LoadBalancers']:
644
+ if lb.get('VpcId') == vpc_id:
645
+ load_balancers.append(lb['LoadBalancerArn'])
646
+
647
+ except Exception as e:
648
+ print_warning(f"ELBv2 load balancer detection failed for VPC {vpc_id}: {e}")
649
+
650
+ try:
651
+ # Check Classic Load Balancers (ELB)
652
+ elb_client = self.session.client('elb', region_name=region)
653
+ response = elb_client.describe_load_balancers()
654
+
655
+ for lb in response['LoadBalancerDescriptions']:
656
+ if lb.get('VPCId') == vpc_id:
657
+ load_balancers.append(lb['LoadBalancerName'])
658
+
659
+ except Exception as e:
660
+ print_warning(f"Classic load balancer detection failed for VPC {vpc_id}: {e}")
661
+
662
+ return load_balancers
663
+
664
+ def _analyze_iac_tags(self, tags: Dict[str, str]) -> bool:
665
+ """Analyze tags for Infrastructure as Code indicators."""
666
+ iac_indicators = [
667
+ 'terraform', 'cloudformation', 'cdk', 'pulumi', 'ansible',
668
+ 'created-by', 'managed-by', 'provisioned-by', 'stack-name',
669
+ 'aws:cloudformation:', 'terraform:', 'cdk:'
670
+ ]
671
+
672
+ # Check tag keys and values for IaC indicators
673
+ all_tag_text = ' '.join([
674
+ f"{key} {value}" for key, value in tags.items()
675
+ ]).lower()
321
676
 
322
- # Bucket 2: External interconnects (Medium Risk)
323
- return "medium"
677
+ return any(indicator in all_tag_text for indicator in iac_indicators)
678
+
679
+ def _extract_owners_from_tags(self, tags: Dict[str, str]) -> List[str]:
680
+ """Extract owner/approval information from tags with enhanced patterns."""
681
+ owners = []
682
+
683
+ # Enhanced owner key patterns - Common AWS tagging patterns
684
+ owner_keys = [
685
+ 'Owner', 'owner', 'OWNER', # Direct owner tags
686
+ 'CreatedBy', 'createdby', 'Created-By', 'created-by', # Creator tags
687
+ 'ManagedBy', 'managedby', 'Managed-By', 'managed-by', # Management tags
688
+ 'Team', 'team', 'TEAM', # Team tags
689
+ 'Contact', 'contact', 'CONTACT', # Contact tags
690
+ 'BusinessOwner', 'business-owner', 'business_owner', # Business owner
691
+ 'TechnicalOwner', 'technical-owner', 'technical_owner', # Technical owner
692
+ 'Approver', 'approver', 'APPROVER' # Approval tags
693
+ ]
694
+
695
+ for key in owner_keys:
696
+ if key in tags and tags[key]:
697
+ # Split multiple owners if comma-separated
698
+ owner_values = [owner.strip() for owner in tags[key].split(',')]
699
+ owners.extend(owner_values)
700
+
701
+ # Format owners with role context if identifiable
702
+ formatted_owners = []
703
+ for owner in owners:
704
+ if any(business_key in owner.lower() for business_key in ['business', 'manager', 'finance']):
705
+ formatted_owners.append(f"{owner} (Business)")
706
+ elif any(tech_key in owner.lower() for tech_key in ['ops', 'devops', 'engineering', 'tech']):
707
+ formatted_owners.append(f"{owner} (Technical)")
708
+ else:
709
+ formatted_owners.append(owner)
710
+
711
+ return list(set(formatted_owners)) # Remove duplicates
324
712
 
325
713
  def _classify_three_bucket_strategy(self, candidates: List[VPCCleanupCandidate]) -> Dict[str, List[VPCCleanupCandidate]]:
326
714
  """Classify VPCs using AWSO-05 three-bucket strategy."""
@@ -333,25 +721,24 @@ class VPCCleanupOptimizer:
333
721
  for candidate in candidates:
334
722
  dependency = candidate.dependency_analysis
335
723
 
336
- # Bucket 1: Internal data plane (High Safety)
337
- if (dependency.eni_count == 0 and
338
- dependency.dependency_risk_level == "low" and
339
- not dependency.is_default_vpc):
724
+ # PRIORITY 1: ENI count = 0 takes precedence (safety-first approach)
725
+ # NO-ENI VPCs are inherently safe regardless of default status
726
+ if dependency.eni_count == 0 and dependency.dependency_risk_level == "low":
340
727
  candidate.cleanup_bucket = "internal"
341
728
  candidate.cleanup_recommendation = "ready"
342
729
  candidate.risk_assessment = "low"
343
730
  candidate.business_impact = "minimal"
344
731
  bucket_1_internal.append(candidate)
345
732
 
346
- # Bucket 3: Control plane (Requires careful handling)
347
- elif dependency.is_default_vpc:
733
+ # PRIORITY 2: Default VPCs with ENI attachments need careful handling
734
+ elif dependency.is_default_vpc and dependency.eni_count > 0:
348
735
  candidate.cleanup_bucket = "control"
349
736
  candidate.cleanup_recommendation = "manual_review"
350
737
  candidate.risk_assessment = "high"
351
738
  candidate.business_impact = "significant"
352
739
  bucket_3_control.append(candidate)
353
740
 
354
- # Bucket 2: External interconnects (Medium safety)
741
+ # PRIORITY 3: Non-default VPCs with ENI attachments require analysis
355
742
  else:
356
743
  candidate.cleanup_bucket = "external"
357
744
  candidate.cleanup_recommendation = "investigate"
@@ -372,13 +759,82 @@ class VPCCleanupOptimizer:
372
759
 
373
760
  return classification_results
374
761
 
762
+ def _ensure_no_eni_bucket_1_classification(self, bucket_classification: Dict[str, List[VPCCleanupCandidate]]) -> Dict[str, List[VPCCleanupCandidate]]:
763
+ """
764
+ Ensure NO-ENI VPCs remain in Bucket 1 after security assessment.
765
+
766
+ CRITICAL FIX: Security assessment may have modified VPC properties, but
767
+ NO-ENI VPCs (ENI count = 0) should ALWAYS remain in Bucket 1 regardless
768
+ of default status or security findings. They are inherently safe.
769
+ """
770
+ print_info("🔧 Ensuring NO-ENI VPCs remain in Bucket 1 (safety-first approach)...")
771
+
772
+ # Create new bucket structure
773
+ new_bucket_1 = []
774
+ new_bucket_2 = []
775
+ new_bucket_3 = []
776
+
777
+ # Collect all VPCs from all buckets
778
+ all_vpcs = []
779
+ for bucket_vpcs in bucket_classification.values():
780
+ all_vpcs.extend(bucket_vpcs)
781
+
782
+ # Re-classify with NO-ENI priority
783
+ for candidate in all_vpcs:
784
+ # PRIORITY 1: NO-ENI VPCs ALWAYS go to Bucket 1 (overrides all other factors)
785
+ if candidate.dependency_analysis.eni_count == 0:
786
+ # Ensure NO-ENI VPCs maintain Bucket 1 properties
787
+ candidate.cleanup_bucket = "internal"
788
+ candidate.cleanup_recommendation = "ready"
789
+ candidate.risk_assessment = "low"
790
+ candidate.business_impact = "minimal"
791
+ new_bucket_1.append(candidate)
792
+
793
+ # PRIORITY 2: Default VPCs with ENI attachments go to Bucket 3
794
+ elif candidate.is_default and candidate.dependency_analysis.eni_count > 0:
795
+ candidate.cleanup_bucket = "control"
796
+ candidate.cleanup_recommendation = "manual_review"
797
+ candidate.risk_assessment = "high"
798
+ candidate.business_impact = "significant"
799
+ new_bucket_3.append(candidate)
800
+
801
+ # PRIORITY 3: All other VPCs go to Bucket 2
802
+ else:
803
+ candidate.cleanup_bucket = "external"
804
+ candidate.cleanup_recommendation = "investigate"
805
+ candidate.risk_assessment = "medium"
806
+ candidate.business_impact = "moderate"
807
+ new_bucket_2.append(candidate)
808
+
809
+ corrected_classification = {
810
+ "bucket_1_internal": new_bucket_1,
811
+ "bucket_2_external": new_bucket_2,
812
+ "bucket_3_control": new_bucket_3
813
+ }
814
+
815
+ # Log corrections if any VPCs were moved
816
+ original_b1_count = len(bucket_classification["bucket_1_internal"])
817
+ original_b3_count = len(bucket_classification["bucket_3_control"])
818
+ new_b1_count = len(new_bucket_1)
819
+ new_b3_count = len(new_bucket_3)
820
+
821
+ if original_b1_count != new_b1_count or original_b3_count != new_b3_count:
822
+ print_warning(f"🔧 Bucket re-classification applied:")
823
+ print_info(f" • Bucket 1: {original_b1_count} → {new_b1_count} VPCs")
824
+ print_info(f" • Bucket 3: {original_b3_count} → {new_b3_count} VPCs")
825
+ print_success("✅ NO-ENI VPCs prioritized for Bucket 1 (safety-first)")
826
+ else:
827
+ print_success("✅ NO-ENI VPC classification already correct")
828
+
829
+ return corrected_classification
830
+
375
831
  def _perform_vpc_security_assessment(self, candidates: List[VPCCleanupCandidate]) -> Dict[str, Any]:
376
832
  """Perform comprehensive VPC security assessment using enterprise security module."""
377
833
  print_info("🔒 Performing comprehensive VPC security assessment...")
378
834
 
379
835
  try:
380
- # Initialize enterprise security module
381
- security_module = EnterpriseSecurityModule(profile=self.profile)
836
+ # Initialize enterprise security framework
837
+ security_framework = EnterpriseSecurityFramework(profile=self.profile)
382
838
 
383
839
  security_results = {
384
840
  "assessed_vpcs": 0,
@@ -401,19 +857,30 @@ class VPCCleanupOptimizer:
401
857
  for candidate in candidates:
402
858
  try:
403
859
  # Enhanced security assessment for each VPC
404
- vpc_security = self._assess_individual_vpc_security(candidate, security_module)
860
+ vpc_security = self._assess_individual_vpc_security(candidate, security_framework)
405
861
 
406
862
  # Classify security risk level
863
+ # CRITICAL FIX: Don't override NO-ENI VPC classifications (they're inherently safe)
864
+ # NO-ENI VPCs should remain in Bucket 1 regardless of default status
407
865
  if candidate.is_default or vpc_security["high_risk_findings"] > 2:
408
866
  security_results["security_risks"]["high_risk"].append(candidate.vpc_id)
409
- candidate.risk_assessment = "high"
410
- candidate.cleanup_recommendation = "manual_review"
867
+
868
+ # Only override classification if VPC has ENI attachments
869
+ # NO-ENI VPCs (ENI count = 0) remain safe for Bucket 1 regardless of default status
870
+ if candidate.dependency_analysis.eni_count > 0:
871
+ candidate.risk_assessment = "high"
872
+ candidate.cleanup_recommendation = "manual_review"
873
+ # NO-ENI VPCs keep their original Bucket 1 classification
411
874
  elif vpc_security["medium_risk_findings"] > 1:
412
875
  security_results["security_risks"]["medium_risk"].append(candidate.vpc_id)
413
- candidate.risk_assessment = "medium"
876
+ # Only override classification if VPC has ENI attachments
877
+ if candidate.dependency_analysis.eni_count > 0:
878
+ candidate.risk_assessment = "medium"
414
879
  else:
415
880
  security_results["security_risks"]["low_risk"].append(candidate.vpc_id)
416
- candidate.risk_assessment = "low"
881
+ # Only override classification if VPC has ENI attachments
882
+ if candidate.dependency_analysis.eni_count > 0:
883
+ candidate.risk_assessment = "low"
417
884
 
418
885
  # Track compliance issues
419
886
  if candidate.is_default:
@@ -447,7 +914,7 @@ class VPCCleanupOptimizer:
447
914
  print_error(f"Security assessment failed: {e}")
448
915
  return {"error": str(e), "assessed_vpcs": 0}
449
916
 
450
- def _assess_individual_vpc_security(self, candidate: VPCCleanupCandidate, security_module) -> Dict[str, Any]:
917
+ def _assess_individual_vpc_security(self, candidate: VPCCleanupCandidate, security_framework) -> Dict[str, Any]:
451
918
  """Assess individual VPC security posture."""
452
919
  security_findings = {
453
920
  "high_risk_findings": 0,
@@ -613,7 +1080,16 @@ class VPCCleanupOptimizer:
613
1080
  total_annual_savings=cost_analysis["total_annual_savings"],
614
1081
  mcp_validation_accuracy=validation_results["overall_accuracy"],
615
1082
  analysis_timestamp=datetime.now(),
616
- security_assessment=security_assessment
1083
+ security_assessment=security_assessment,
1084
+ multi_account_context={
1085
+ 'total_accounts_analyzed': 1,
1086
+ 'accounts_with_vpcs': 1 if all_candidates else 0,
1087
+ 'organization_id': 'single_account_analysis',
1088
+ 'accounts': [{'account_id': 'current', 'account_name': 'current', 'status': 'active'}],
1089
+ 'vpc_count_by_account': {'current': len(all_candidates)},
1090
+ 'analysis_scope': 'single_account',
1091
+ 'profile_access_scope': self.profile
1092
+ }
617
1093
  )
618
1094
 
619
1095
  # Generate SHA256 evidence hash for audit compliance
@@ -760,6 +1236,9 @@ class VPCCleanupOptimizer:
760
1236
  ready_table.add_column("Region", style="blue", width=12)
761
1237
  ready_table.add_column("CIDR Block", style="yellow", width=18)
762
1238
  ready_table.add_column("ENI Count", justify="right", style="green")
1239
+ ready_table.add_column("Flow Logs", justify="center", style="magenta")
1240
+ ready_table.add_column("Load Balancers", justify="right", style="red")
1241
+ ready_table.add_column("IaC", justify="center", style="cyan")
763
1242
  ready_table.add_column("Annual Savings", justify="right", style="green")
764
1243
 
765
1244
  for candidate in results.bucket_1_internal[:10]: # Show first 10
@@ -768,6 +1247,9 @@ class VPCCleanupOptimizer:
768
1247
  candidate.region,
769
1248
  candidate.cidr_block,
770
1249
  str(candidate.dependency_analysis.eni_count),
1250
+ "✅" if candidate.flow_logs_enabled else "❌",
1251
+ str(len(candidate.load_balancers)),
1252
+ "✅" if candidate.iac_detected else "❌",
771
1253
  format_cost(candidate.annual_savings)
772
1254
  )
773
1255
 
@@ -784,7 +1266,11 @@ class VPCCleanupOptimizer:
784
1266
  @click.option('--export', default='json', help='Export format: json, csv, pdf')
785
1267
  @click.option('--evidence-bundle', is_flag=True, help='Generate SHA256 evidence bundle')
786
1268
  @click.option('--dry-run', is_flag=True, default=True, help='Perform analysis only (default: true)')
787
- def vpc_cleanup_command(profile: str, export: str, evidence_bundle: bool, dry_run: bool):
1269
+ @click.option('--no-eni-only', is_flag=True, help='Show only VPCs with zero ENI attachments')
1270
+ @click.option('--filter', type=click.Choice(['none', 'default', 'all']), default='all',
1271
+ help='Filter VPCs: none=no resources, default=default VPCs only, all=show all')
1272
+ def vpc_cleanup_command(profile: str, export: str, evidence_bundle: bool, dry_run: bool,
1273
+ no_eni_only: bool, filter: str):
788
1274
  """
789
1275
  AWSO-05 VPC Cleanup Cost Optimization Engine
790
1276
 
@@ -798,13 +1284,28 @@ def vpc_cleanup_command(profile: str, export: str, evidence_bundle: bool, dry_ru
798
1284
 
799
1285
  try:
800
1286
  optimizer = VPCCleanupOptimizer(profile=profile)
801
- results = optimizer.analyze_vpc_cleanup_opportunities()
1287
+ results = optimizer.analyze_vpc_cleanup_opportunities(
1288
+ no_eni_only=no_eni_only,
1289
+ filter_type=filter
1290
+ )
802
1291
 
803
1292
  if evidence_bundle:
804
1293
  print_info(f"📁 Evidence bundle generated: SHA256 {results.evidence_hash}")
805
1294
 
806
1295
  if export:
807
- print_info(f"📊 Export format: {export} (implementation pending)")
1296
+ from .vpc_cleanup_exporter import export_vpc_cleanup_results
1297
+ export_formats = [format.strip() for format in export.split(',')]
1298
+ export_results = export_vpc_cleanup_results(
1299
+ results,
1300
+ export_formats=export_formats,
1301
+ output_dir="./tmp"
1302
+ )
1303
+
1304
+ for format_type, filename in export_results.items():
1305
+ if filename:
1306
+ print_success(f"📄 {format_type.upper()} export: {filename}")
1307
+ else:
1308
+ print_warning(f"⚠️ {format_type.upper()} export failed")
808
1309
 
809
1310
  print_success("🎯 AWSO-05 VPC cleanup analysis completed successfully")
810
1311