From 98805073483597a615a4d5a131f1eecdcb2df27d Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Thu, 3 Jul 2025 15:09:45 -0300 Subject: [PATCH 01/16] feat/aws-auditor: initial code --- README.md | 0 __init__.py | 23 ++ cpk_lib_python_aws/__init__.py | 0 .../aws_sso_auditor/__init__.py | 18 ++ .../aws_sso_auditor/__main__.py | 7 + cpk_lib_python_aws/aws_sso_auditor/auditor.py | 299 ++++++++++++++++++ .../aws_sso_auditor/aws_client_manager.py | 84 +++++ cpk_lib_python_aws/aws_sso_auditor/cli.py | 137 ++++++++ cpk_lib_python_aws/aws_sso_auditor/config.py | 49 +++ .../aws_sso_auditor/exceptions.py | 28 ++ .../aws_sso_auditor/formatters.py | 96 ++++++ cpk_lib_python_aws/aws_sso_auditor/utils.py | 47 +++ cpk_lib_python_aws/shared/__init__.py | 14 + cpk_lib_python_aws/shared/aws_base.py | 42 +++ cpk_lib_python_aws/shared/exceptions.py | 21 ++ cpk_lib_python_aws/shared/utils.py | 36 +++ pyproject.toml | 59 ++++ 17 files changed, 960 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 cpk_lib_python_aws/__init__.py create mode 100644 cpk_lib_python_aws/aws_sso_auditor/__init__.py create mode 100644 cpk_lib_python_aws/aws_sso_auditor/__main__.py create mode 100644 cpk_lib_python_aws/aws_sso_auditor/auditor.py create mode 100644 cpk_lib_python_aws/aws_sso_auditor/aws_client_manager.py create mode 100644 cpk_lib_python_aws/aws_sso_auditor/cli.py create mode 100644 cpk_lib_python_aws/aws_sso_auditor/config.py create mode 100644 cpk_lib_python_aws/aws_sso_auditor/exceptions.py create mode 100644 cpk_lib_python_aws/aws_sso_auditor/formatters.py create mode 100644 cpk_lib_python_aws/aws_sso_auditor/utils.py create mode 100644 cpk_lib_python_aws/shared/__init__.py create mode 100644 cpk_lib_python_aws/shared/aws_base.py create mode 100644 cpk_lib_python_aws/shared/exceptions.py create mode 100644 cpk_lib_python_aws/shared/utils.py create mode 100644 pyproject.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..6b9495d --- /dev/null +++ b/__init__.py @@ -0,0 +1,23 @@ +"""CPK Python AWS Library - Collection of AWS tools and utilities.""" + +# AWS SSO Auditor Package +from .aws_sso_auditor import AWSSSOAuditor +from .aws_sso_auditor.config import Config as SSOConfig +from .aws_sso_auditor.exceptions import AWSSSOAuditorError + +# Shared utilities +from .shared import AWSBaseClient, AWSError + +__version__ = "1.0.0" + +# Package exports +__all__ = [ + # AWS SSO Auditor + "AWSSSOAuditor", + "SSOConfig", + "AWSSSOAuditorError", + + # Shared utilities + "AWSBaseClient", + "AWSError", +] \ No newline at end of file diff --git a/cpk_lib_python_aws/__init__.py b/cpk_lib_python_aws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cpk_lib_python_aws/aws_sso_auditor/__init__.py b/cpk_lib_python_aws/aws_sso_auditor/__init__.py new file mode 100644 index 0000000..92b429e --- /dev/null +++ b/cpk_lib_python_aws/aws_sso_auditor/__init__.py @@ -0,0 +1,18 @@ +"""AWS SSO Auditor - Professional AWS SSO auditing and compliance tool.""" + +from .auditor import AWSSSOAuditor +from .config import Config +from .exceptions import AWSSSOAuditorError, PermissionError, ConfigurationError +from .formatters import OutputFormatter +from .aws_client_manager import AWSClientManager + +__version__ = "1.0.0" +__all__ = [ + "AWSSSOAuditor", + "Config", + "AWSSSOAuditorError", + "PermissionError", + "ConfigurationError", + "OutputFormatter", + "AWSClientManager", +] \ No newline at end of file diff --git a/cpk_lib_python_aws/aws_sso_auditor/__main__.py b/cpk_lib_python_aws/aws_sso_auditor/__main__.py new file mode 100644 index 0000000..a86c4bb --- /dev/null +++ b/cpk_lib_python_aws/aws_sso_auditor/__main__.py @@ -0,0 +1,7 @@ +"""Entry point for running AWS SSO Auditor as a module.""" + +import sys +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/cpk_lib_python_aws/aws_sso_auditor/auditor.py b/cpk_lib_python_aws/aws_sso_auditor/auditor.py new file mode 100644 index 0000000..2419b6d --- /dev/null +++ b/cpk_lib_python_aws/aws_sso_auditor/auditor.py @@ -0,0 +1,299 @@ +"""Core AWS SSO auditing functionality.""" + +import logging +from datetime import datetime +from typing import Dict, List, Any +from .config import Config +from .aws_client_manager import AWSClientManager +from .exceptions import AWSSSOAuditorError +from .utils import clean_aws_response, safe_get_nested + +logger = logging.getLogger(__name__) + + +class AWSSSOAuditor: + """Main AWS SSO auditing class.""" + + def __init__(self, config: Config = None): + """Initialize the AWS SSO Auditor.""" + self.config = config or Config() + self.config.validate() + + # Initialize AWS clients + self.aws_manager = AWSClientManager(self.config) + + # Store frequently used references + self.sso_admin_client = self.aws_manager.sso_admin_client + self.identitystore_client = self.aws_manager.identitystore_client + self.organizations_client = self.aws_manager.organizations_client + self.identity_store_id = self.aws_manager.identity_store_id + self.instance_arn = self.aws_manager.instance_arn + + # Show client info in debug mode + if self.config.debug: + client_info = self.aws_manager.get_client_info() + logger.debug("AWS Client Info: %s", client_info) + + logger.info("AWS SSO Auditor initialized successfully") + + def get_permission_sets_for_account(self, account_id: str) -> List[str]: + """Get only permission sets that are provisioned/assigned to the specific account.""" + try: + permission_sets = [] + paginator = self.sso_admin_client.get_paginator('list_permission_sets_provisioned_to_account') + + for page in paginator.paginate( + InstanceArn=self.instance_arn, + AccountId=account_id + ): + permission_sets.extend(page['PermissionSets']) + + logger.info("Found %d permission sets provisioned to account %s", len(permission_sets), account_id) + return permission_sets + except Exception as e: + logger.error("Error getting permission sets for account %s: %s", account_id, e) + return [] + + def get_account_assignments_for_permission_set(self, permission_set_arn: str, account_id: str) -> List[Dict[str, Any]]: + """Get account assignments for a specific permission set and account.""" + try: + assignments = [] + paginator = self.sso_admin_client.get_paginator('list_account_assignments') + + for page in paginator.paginate( + InstanceArn=self.instance_arn, + AccountId=account_id, + PermissionSetArn=permission_set_arn + ): + assignments.extend(page['AccountAssignments']) + + return assignments + except Exception as e: + logger.error("Error getting account assignments for permission set %s: %s", permission_set_arn, e) + return [] + + def get_all_account_assignments(self, account_id: str) -> List[Dict[str, Any]]: + """Get all account assignments for the given account by checking only provisioned permission sets.""" + all_assignments = [] + + # Get only permission sets that are provisioned to this account + permission_sets = self.get_permission_sets_for_account(account_id) + + # Then get assignments for each provisioned permission set + for permission_set_arn in permission_sets: + assignments = self.get_account_assignments_for_permission_set(permission_set_arn, account_id) + all_assignments.extend(assignments) + + logger.info("Found %d total assignments for account %s", len(all_assignments), account_id) + return all_assignments + + def get_group_details(self, group_id: str) -> Dict[str, Any]: + """Get group details including name and description.""" + try: + response = self.identitystore_client.describe_group( + IdentityStoreId=self.identity_store_id, + GroupId=group_id + ) + return { + 'GroupId': response['GroupId'], + 'DisplayName': response['DisplayName'], + 'Description': response.get('Description', '') + } + except Exception as e: + logger.error("Error getting group details for %s: %s", group_id, e) + return {'GroupId': group_id, 'DisplayName': 'Unknown', 'Description': ''} + + def get_group_members(self, group_id: str) -> List[Dict[str, Any]]: + """Get all members of a group.""" + try: + members = [] + paginator = self.identitystore_client.get_paginator('list_group_memberships') + + for page in paginator.paginate( + IdentityStoreId=self.identity_store_id, + GroupId=group_id + ): + for membership in page['GroupMemberships']: + user_id = membership['MemberId']['UserId'] + user_details = self.get_user_details(user_id) + members.append(user_details) + + return members + except Exception as e: + logger.error("Error getting group members for %s: %s", group_id, e) + return [] + + def get_user_details(self, user_id: str) -> Dict[str, Any]: + """Get user details including username and display name.""" + try: + response = self.identitystore_client.describe_user( + IdentityStoreId=self.identity_store_id, + UserId=user_id + ) + return { + 'UserId': response['UserId'], + 'UserName': response['UserName'], + 'DisplayName': response.get('DisplayName', response['UserName']), + 'Email': safe_get_nested(response, ['Emails', 0, 'Value'], '') + } + except Exception as e: + logger.error("Error getting user details for %s: %s", user_id, e) + return {'UserId': user_id, 'UserName': 'Unknown', 'DisplayName': 'Unknown', 'Email': ''} + + def get_permission_set_details(self, permission_set_arn: str) -> Dict[str, Any]: + """Get permission set details.""" + try: + response = self.sso_admin_client.describe_permission_set( + InstanceArn=self.instance_arn, + PermissionSetArn=permission_set_arn + ) + return clean_aws_response(response['PermissionSet']) + except Exception as e: + logger.error("Error getting permission set details for %s: %s", permission_set_arn, e) + return {} + + def get_permission_set_policies(self, permission_set_arn: str) -> Dict[str, Any]: + """Get all policies attached to a permission set.""" + policies = { + 'managed_policies': [], + 'customer_managed_policies': [], + 'inline_policy': None + } + + try: + # Get AWS managed policies + managed_paginator = self.sso_admin_client.get_paginator('list_managed_policies_in_permission_set') + for page in managed_paginator.paginate( + InstanceArn=self.instance_arn, + PermissionSetArn=permission_set_arn + ): + policies['managed_policies'].extend(page['AttachedManagedPolicies']) + + # Get customer managed policies + customer_paginator = self.sso_admin_client.get_paginator( + 'list_customer_managed_policy_references_in_permission_set') + for page in customer_paginator.paginate( + InstanceArn=self.instance_arn, + PermissionSetArn=permission_set_arn + ): + for policy_ref in page['CustomerManagedPolicyReferences']: + policy_details = self.get_customer_managed_policy_details(policy_ref) + policies['customer_managed_policies'].append(policy_details) + + # Get inline policy + try: + inline_response = self.sso_admin_client.get_inline_policy_for_permission_set( + InstanceArn=self.instance_arn, + PermissionSetArn=permission_set_arn + ) + if inline_response.get('InlinePolicy'): + import json + policies['inline_policy'] = json.loads(inline_response['InlinePolicy']) + except self.sso_admin_client.exceptions.ResourceNotFoundException: + # No inline policy exists + pass + + except Exception as e: + logger.error("Error getting policies for permission set %s: %s", permission_set_arn, e) + + return policies + + def get_customer_managed_policy_details(self, policy_ref: Dict[str, Any]) -> Dict[str, Any]: + """Get details for customer managed policy.""" + try: + return { + 'Name': policy_ref['Name'], + 'Path': policy_ref.get('Path', '/'), + 'Type': 'CustomerManaged', + 'Note': 'Policy document not retrieved - requires target account access' + } + except Exception as e: + logger.error("Error getting customer managed policy details: %s", e) + return policy_ref + + def audit_account(self, account_id: str) -> Dict[str, Any]: + """Perform complete audit of SSO access for the given account.""" + logger.info("Starting AWS SSO audit for account: %s", account_id) + + try: + # Get all account assignments (only for permission sets assigned to this account) + assignments = self.get_all_account_assignments(account_id) + + # Organize data + groups_data = {} + permission_sets_data = {} + + for assignment in assignments: + principal_type = assignment['PrincipalType'] + principal_id = assignment['PrincipalId'] + permission_set_arn = assignment['PermissionSetArn'] + + if principal_type == 'GROUP': + if principal_id not in groups_data: + group_details = self.get_group_details(principal_id) + group_members = self.get_group_members(principal_id) + groups_data[principal_id] = { + **group_details, + 'Members': group_members, + 'PermissionSets': [] + } + + # Get full permission set details for this group + permission_set_details = self.get_permission_set_details(permission_set_arn) + permission_set_policies = self.get_permission_set_policies(permission_set_arn) + + permission_set_full_details = { + **permission_set_details, + 'Policies': permission_set_policies + } + + groups_data[principal_id]['PermissionSets'].append(permission_set_full_details) + + # Collect permission set data (only for those with assignments to this account) + if permission_set_arn not in permission_sets_data: + permission_set_details = self.get_permission_set_details(permission_set_arn) + permission_set_policies = self.get_permission_set_policies(permission_set_arn) + permission_sets_data[permission_set_arn] = { + **permission_set_details, + 'Policies': permission_set_policies, + 'AssignedGroups': [] + } + + if principal_type == 'GROUP': + if principal_id not in permission_sets_data[permission_set_arn]['AssignedGroups']: + permission_sets_data[permission_set_arn]['AssignedGroups'].append(principal_id) + + # Create simple lists for summary + group_names = [group['DisplayName'] for group in groups_data.values()] + permission_set_names = [ps.get('Name', 'Unknown') for ps in permission_sets_data.values()] + + # Build final result + result = { + 'metadata': { + 'generated_at': datetime.now().isoformat(), + 'account_id': account_id, + 'sso_instance_arn': self.instance_arn, + 'identity_store_id': self.identity_store_id, + 'auditor_version': '1.0.0', + 'config': { + 'aws_region': self.config.aws_region, + 'output_formats': self.config.output_formats, + } + }, + 'sso_groups_summary': group_names, + 'sso_permission_sets_summary': permission_set_names, + 'sso_groups': list(groups_data.values()), + 'permission_sets': list(permission_sets_data.values()), + 'summary': { + 'total_groups': len(groups_data), + 'total_permission_sets': len(permission_sets_data), + 'total_assignments': len(assignments) + } + } + + logger.info("Audit completed successfully for account %s", account_id) + return result + + except Exception as e: + logger.error("Audit failed for account %s: %s", account_id, e) + raise AWSSSOAuditorError(f"Failed to audit account {account_id}: {e}") from e \ No newline at end of file diff --git a/cpk_lib_python_aws/aws_sso_auditor/aws_client_manager.py b/cpk_lib_python_aws/aws_sso_auditor/aws_client_manager.py new file mode 100644 index 0000000..192d321 --- /dev/null +++ b/cpk_lib_python_aws/aws_sso_auditor/aws_client_manager.py @@ -0,0 +1,84 @@ +"""AWS client management for SSO auditing - extends shared base.""" + +import logging +from typing import Dict, Any +from ..shared import AWSBaseClient +from .config import Config +from .exceptions import AWSClientError, SSOInstanceNotFoundError +logger = logging.getLogger(__name__) + + +class AWSClientManager(AWSBaseClient): + """Manages AWS clients specific to SSO auditing.""" + + def __init__(self, config: Config): + """Initialize AWS clients with SSO-specific configuration.""" + super().__init__(region=config.aws_region, profile=config.aws_profile) + self.config = config + + # SSO-specific clients + self.sso_admin_client = None + self.identitystore_client = None + self.organizations_client = None + + # SSO instance information + self.sso_instance = None + self.identity_store_id = None + self.instance_arn = None + + self._initialize_sso_clients() + + def _initialize_sso_clients(self) -> None: + """Initialize SSO-specific AWS clients.""" + try: + # Initialize AWS clients + self.sso_admin_client = self.session.client('sso-admin') + self.identitystore_client = self.session.client('identitystore') + self.organizations_client = self.session.client('organizations') + + logger.info("SSO-specific AWS clients initialized successfully") + + # Discover SSO instance + self._discover_sso_instance() + + except Exception as e: + logger.error("Failed to initialize SSO clients: %s", e) + raise AWSClientError(f"Error initializing SSO clients: {e}") from e + + def _discover_sso_instance(self) -> None: + """Discover and validate SSO instance.""" + try: + response = self.sso_admin_client.list_instances() + if not response['Instances']: + raise SSOInstanceNotFoundError("No SSO instances found in this AWS account") + + self.sso_instance = response['Instances'][0] + self.identity_store_id = self.sso_instance['IdentityStoreId'] + self.instance_arn = self.sso_instance['InstanceArn'] + + logger.info("SSO instance discovered: %s", self.instance_arn) + + except Exception as e: + if "No SSO instances found" in str(e): + raise + logger.error("Failed to discover SSO instance: %s", e) + raise SSOInstanceNotFoundError(f"Failed to get SSO instance: {e}") from e + + + def get_client_info(self) -> Dict[str, Any]: + """Get information about configured SSO clients.""" + base_info = { + 'region': self.region, + 'profile': self.profile, + 'caller_identity': self.get_caller_identity(), + } + + sso_info = { + 'sso_instance_arn': self.instance_arn, + 'identity_store_id': self.identity_store_id, + 'has_sso_admin': self.sso_admin_client is not None, + 'has_identity_store': self.identitystore_client is not None, + 'has_organizations': self.organizations_client is not None, + } + + return {**base_info, **sso_info} \ No newline at end of file diff --git a/cpk_lib_python_aws/aws_sso_auditor/cli.py b/cpk_lib_python_aws/aws_sso_auditor/cli.py new file mode 100644 index 0000000..8e59ed7 --- /dev/null +++ b/cpk_lib_python_aws/aws_sso_auditor/cli.py @@ -0,0 +1,137 @@ +"""CLI interface for AWS SSO Auditor.""" + +import argparse +import sys +import logging +from typing import List +from .auditor import AWSSSOAuditor +from .config import Config +from .formatters import OutputFormatter +from .exceptions import AWSSSOAuditorError + + +def setup_logging(debug: bool = False) -> None: + """Setup logging configuration.""" + level = logging.DEBUG if debug else logging.INFO + logging.basicConfig( + level=level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('aws_sso_auditor.log'), + logging.StreamHandler() + ] + ) + + +def create_parser() -> argparse.ArgumentParser: + """Create CLI argument parser.""" + parser = argparse.ArgumentParser( + description='Audit AWS SSO Groups and Permission Sets for an account', + prog='aws-sso-auditor' + ) + + parser.add_argument( + 'account_id', + help='AWS Account ID to audit' + ) + + parser.add_argument( + '--output-format', + choices=['json', 'yaml', 'both'], + default='both', + help='Output format (default: both)' + ) + + parser.add_argument( + '--output-dir', + default='.', + help='Output directory (default: current directory)' + ) + + parser.add_argument( + '--aws-region', + default='us-east-1', + help='AWS region (default: us-east-1)' + ) + + parser.add_argument( + '--aws-profile', + help='AWS profile to use' + ) + + parser.add_argument( + '--quiet', '-q', + action='store_true', + help='Suppress console output, only save files' + ) + + parser.add_argument( + '--debug', + action='store_true', + help='Enable debug logging' + ) + + parser.add_argument( + '--no-timestamp', + action='store_true', + help='Don\'t include timestamp in filenames' + ) + + return parser + + +def main(args: List[str] = None) -> int: + """Main CLI entry point.""" + parser = create_parser() + parsed_args = parser.parse_args(args) + + # Setup logging + setup_logging(parsed_args.debug) + logger = logging.getLogger(__name__) + + # Create configuration + output_formats = [parsed_args.output_format] if parsed_args.output_format != 'both' else ['json', 'yaml'] + + config = Config( + aws_region=parsed_args.aws_region, + aws_profile=parsed_args.aws_profile, + output_formats=output_formats, + output_directory=parsed_args.output_dir, + include_timestamp=not parsed_args.no_timestamp, + debug=parsed_args.debug, + quiet=parsed_args.quiet + ) + + try: + # Initialize auditor and formatter + auditor = AWSSSOAuditor(config) + formatter = OutputFormatter(config) + + # Run audit + logger.info(f"Starting audit for account: {parsed_args.account_id}") + results = auditor.audit_account(parsed_args.account_id) + + # Save results + saved_files = formatter.save_results(results, parsed_args.account_id) + logger.info(f"Results saved to: {', '.join(saved_files)}") + + # Display results (unless quiet) + if not config.quiet: + formatter.display_results(results) + print(f"✅ Results saved to: {', '.join(saved_files)}") + + logger.info("Audit completed successfully") + return 0 + + except AWSSSOAuditorError as e: + logger.error(f"AWS SSO Auditor Error: {e}") + print(f"❌ Error: {e}", file=sys.stderr) + return 1 + except Exception as e: + logger.error(f"Unexpected error: {e}") + print(f"❌ Unexpected error: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/cpk_lib_python_aws/aws_sso_auditor/config.py b/cpk_lib_python_aws/aws_sso_auditor/config.py new file mode 100644 index 0000000..7565285 --- /dev/null +++ b/cpk_lib_python_aws/aws_sso_auditor/config.py @@ -0,0 +1,49 @@ +"""Configuration management for AWS SSO Auditor.""" + +import os +from dataclasses import dataclass +from typing import Optional, List +from .exceptions import ConfigurationError + + +@dataclass +class Config: + """Configuration for AWS SSO Auditor.""" + + # AWS Configuration + aws_region: str = "us-east-1" + aws_profile: Optional[str] = None + timeout: int = 30 + + # Output Configuration + output_formats: List[str] = None + output_directory: str = "." + include_timestamp: bool = True + + # Behavior Configuration + debug: bool = False + quiet: bool = False + + def __post_init__(self): + """Initialize configuration from environment variables.""" + if self.output_formats is None: + self.output_formats = ["json", "yaml"] + + # Override with environment variables + self.aws_region = os.getenv('AWS_REGION', self.aws_region) + self.aws_profile = os.getenv('AWS_PROFILE', self.aws_profile) + + if os.getenv('AWS_SSO_AUDITOR_DEBUG', '').lower() == 'true': + self.debug = True + if os.getenv('AWS_SSO_AUDITOR_QUIET', '').lower() == 'true': + self.quiet = True + + def validate(self) -> None: + """Validate configuration settings.""" + valid_formats = ["json", "yaml", "both"] + for fmt in self.output_formats: + if fmt not in valid_formats: + raise ConfigurationError(f"Invalid output format: {fmt}. Must be one of {valid_formats}") + + if self.timeout <= 0: + raise ConfigurationError("Timeout must be greater than 0") \ No newline at end of file diff --git a/cpk_lib_python_aws/aws_sso_auditor/exceptions.py b/cpk_lib_python_aws/aws_sso_auditor/exceptions.py new file mode 100644 index 0000000..3566f9b --- /dev/null +++ b/cpk_lib_python_aws/aws_sso_auditor/exceptions.py @@ -0,0 +1,28 @@ +"""Custom exceptions for AWS SSO Auditor.""" + +from ..shared.exceptions import AWSError + + +class AWSSSOAuditorError(AWSError): + """Base exception for AWS SSO Auditor.""" + pass + + +class PermissionError(AWSSSOAuditorError): + """Raised when insufficient AWS permissions.""" + pass + + +class ConfigurationError(AWSSSOAuditorError): + """Raised when configuration is invalid.""" + pass + + +class SSOInstanceNotFoundError(AWSSSOAuditorError): + """Raised when no SSO instance is found.""" + pass + + +class AWSClientError(AWSSSOAuditorError): + """Raised when AWS client initialization fails.""" + pass \ No newline at end of file diff --git a/cpk_lib_python_aws/aws_sso_auditor/formatters.py b/cpk_lib_python_aws/aws_sso_auditor/formatters.py new file mode 100644 index 0000000..599f009 --- /dev/null +++ b/cpk_lib_python_aws/aws_sso_auditor/formatters.py @@ -0,0 +1,96 @@ +"""Output formatting utilities for AWS SSO Auditor.""" + +import json +import logging +import os +from datetime import datetime +from typing import Dict, Any, List +import yaml +from .config import Config + +logger = logging.getLogger(__name__) + + +class OutputFormatter: + """Handles output formatting and file operations.""" + + def __init__(self, config: Config): + """Initialize formatter with configuration.""" + self.config = config + + def save_results(self, data: Dict[str, Any], account_id: str) -> List[str]: + """Save results to files based on configuration.""" + saved_files = [] + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") if self.config.include_timestamp else "" + + for format_type in self.config.output_formats: + if format_type in ["json", "both"]: + json_file = self._save_json(data, account_id, timestamp) + saved_files.append(json_file) + + if format_type in ["yaml", "both"]: + yaml_file = self._save_yaml(data, account_id, timestamp) + saved_files.append(yaml_file) + + return saved_files + + def _save_json(self, data: Dict[str, Any], account_id: str, timestamp: str) -> str: + """Save data as JSON file.""" + filename_parts = ["aws_sso_audit", account_id] + if timestamp: + filename_parts.append(timestamp) + filename = "_".join(filename_parts) + ".json" + + filepath = os.path.join(self.config.output_directory, filename) + + with open(filepath, 'w') as f: + json.dump(data, f, indent=2, default=str) + + if not self.config.quiet: + print(f"Results saved to: {filepath}") + logger.info("JSON results saved to: %s", filepath) + return filepath + + def _save_yaml(self, data: Dict[str, Any], account_id: str, timestamp: str) -> str: + """Save data as YAML file.""" + filename_parts = ["aws_sso_audit", account_id] + if timestamp: + filename_parts.append(timestamp) + filename = "_".join(filename_parts) + ".yaml" + + filepath = os.path.join(self.config.output_directory, filename) + + with open(filepath, 'w') as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + if not self.config.quiet: + print(f"Results saved to: {filepath}") + logger.info("YAML results saved to: %s", filepath) + return filepath + + def display_results(self, data: Dict[str, Any]) -> None: + """Display results to console.""" + if self.config.quiet: + return + + print("\n" + "=" * 80) + print("AWS SSO AUDIT RESULTS") + print("=" * 80) + print(json.dumps(data, indent=2, default=str)) + + def format_summary(self, data: Dict[str, Any]) -> str: + """Format a summary of audit results.""" + summary = data.get('summary', {}) + metadata = data.get('metadata', {}) + + lines = [ + "📊 AWS SSO Audit Summary", + f"🆔 Account: {metadata.get('account_id', 'Unknown')}", + f"📅 Generated: {metadata.get('generated_at', 'Unknown')}", + f"👥 Groups: {summary.get('total_groups', 0)}", + f"🔐 Permission Sets: {summary.get('total_permission_sets', 0)}", + f"🔗 Assignments: {summary.get('total_assignments', 0)}", + ] + + return "\n".join(lines) \ No newline at end of file diff --git a/cpk_lib_python_aws/aws_sso_auditor/utils.py b/cpk_lib_python_aws/aws_sso_auditor/utils.py new file mode 100644 index 0000000..ad09d07 --- /dev/null +++ b/cpk_lib_python_aws/aws_sso_auditor/utils.py @@ -0,0 +1,47 @@ +"""Utility functions for AWS SSO Auditor.""" + +from typing import Dict, Any, List +from ..shared.utils import validate_account_id as base_validate_account_id + + +def validate_account_id(account_id: str) -> bool: + """Validate AWS account ID format (wrapper for shared function).""" + return base_validate_account_id(account_id) + + +def format_permission_set_arn(instance_arn: str, permission_set_name: str) -> str: + """Format permission set ARN from instance ARN and name.""" + parts = instance_arn.split(':') + if len(parts) >= 6: + region = parts[3] + account = parts[4] + return f"arn:aws:sso:::{account}:permissionSet/{instance_arn.split('/')[-1]}/{permission_set_name}" + return permission_set_name + + +def safe_get_nested(data: Dict[str, Any], keys: List[str], default: Any = None) -> Any: + """Safely get nested dictionary value.""" + current = data + for key in keys: + if isinstance(current, dict) and key in current: + current = current[key] + else: + return default + return current + + +def clean_aws_response(response: Dict[str, Any]) -> Dict[str, Any]: + """Clean AWS API response by removing metadata.""" + cleaned = response.copy() + # Remove common AWS metadata keys + metadata_keys = ['ResponseMetadata', 'NextToken', 'IsTruncated'] + for key in metadata_keys: + cleaned.pop(key, None) + return cleaned + + +def format_timestamp(timestamp) -> str: + """Format AWS timestamp for display.""" + if hasattr(timestamp, 'isoformat'): + return timestamp.isoformat() + return str(timestamp) \ No newline at end of file diff --git a/cpk_lib_python_aws/shared/__init__.py b/cpk_lib_python_aws/shared/__init__.py new file mode 100644 index 0000000..be056bf --- /dev/null +++ b/cpk_lib_python_aws/shared/__init__.py @@ -0,0 +1,14 @@ +"""Shared AWS utilities and base classes.""" + +from .aws_base import AWSBaseClient +from .exceptions import AWSError, CredentialsError, RegionError +from .utils import validate_account_id, get_aws_regions + +__all__ = [ + "AWSBaseClient", + "AWSError", + "CredentialsError", + "RegionError", + "validate_account_id", + "get_aws_regions", +] \ No newline at end of file diff --git a/cpk_lib_python_aws/shared/aws_base.py b/cpk_lib_python_aws/shared/aws_base.py new file mode 100644 index 0000000..a61a4b9 --- /dev/null +++ b/cpk_lib_python_aws/shared/aws_base.py @@ -0,0 +1,42 @@ +"""Base AWS client management shared across packages.""" + +import boto3 +import logging +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional +from .exceptions import AWSError, CredentialsError + +logger = logging.getLogger(__name__) + + +class AWSBaseClient(ABC): + """Base class for AWS service clients with common functionality.""" + + def __init__(self, region: str = "us-east-1", profile: Optional[str] = None): + """Initialize base AWS client.""" + self.region = region + self.profile = profile + self.session = None + self._initialize_session() + + def _initialize_session(self) -> None: + """Initialize boto3 session with optional profile.""" + try: + session_kwargs = {'region_name': self.region} + if self.profile: + session_kwargs['profile_name'] = self.profile + + self.session = boto3.Session(**session_kwargs) + logger.info(f"AWS session initialized for region: {self.region}") + + except Exception as e: + logger.error(f"Failed to initialize AWS session: {e}") + raise CredentialsError(f"Failed to initialize AWS session: {e}") + + def get_caller_identity(self) -> Dict[str, Any]: + """Get current AWS caller identity.""" + try: + sts_client = self.session.client('sts') + return sts_client.get_caller_identity() + except Exception as e: + raise AWSError(f"Failed to get caller identity: {e}") \ No newline at end of file diff --git a/cpk_lib_python_aws/shared/exceptions.py b/cpk_lib_python_aws/shared/exceptions.py new file mode 100644 index 0000000..19e23e7 --- /dev/null +++ b/cpk_lib_python_aws/shared/exceptions.py @@ -0,0 +1,21 @@ +"""Shared AWS exceptions.""" + + +class AWSError(Exception): + """Base AWS error for all AWS-related exceptions.""" + pass + + +class CredentialsError(AWSError): + """Raised when AWS credentials are invalid or missing.""" + pass + + +class RegionError(AWSError): + """Raised when AWS region is invalid or unsupported.""" + pass + + +class PermissionsError(AWSError): + """Raised when insufficient AWS permissions.""" + pass \ No newline at end of file diff --git a/cpk_lib_python_aws/shared/utils.py b/cpk_lib_python_aws/shared/utils.py new file mode 100644 index 0000000..72f84dd --- /dev/null +++ b/cpk_lib_python_aws/shared/utils.py @@ -0,0 +1,36 @@ +"""Shared AWS utility functions.""" + +import re +from typing import List + + +def validate_account_id(account_id: str) -> bool: + """Validate AWS account ID format.""" + if not account_id: + return False + + # AWS account IDs are 12-digit numbers + pattern = r'^\d{12}$' + return bool(re.match(pattern, account_id)) + + +def get_aws_regions() -> List[str]: + """Get list of AWS regions.""" + import boto3 + + try: + ec2 = boto3.client('ec2', region_name='us-east-1') + response = ec2.describe_regions() + return [region['RegionName'] for region in response['Regions']] + except Exception: + # Fallback to common regions if API call fails + return [ + 'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', + 'eu-west-1', 'eu-west-2', 'eu-central-1', + 'ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1' + ] + + +def format_arn(service: str, region: str, account_id: str, resource: str) -> str: + """Format AWS ARN.""" + return f"arn:aws:{service}:{region}:{account_id}:{resource}" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..176aa1c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "cpk-lib-python-aws" +version = "1.0.0" +description = "CPK Python AWS Library - Collection of professional AWS tools and utilities" +authors = [ + {name = "Your Name", email = "your.email@example.com"} +] +readme = "README.md" +license = {text = "GPL-3.0"} +requires-python = ">=3.8.1" +dependencies = [ + "boto3>=1.26.0", + "botocore>=1.29.0", + "pyyaml>=6.0", + "certifi>=2023.7.22", + "urllib3>=1.25.4,<2.0.0", # Changed this line +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "black>=23.0.0", + "flake8>=6.0.0", # This should work with Python 3.8+ + "mypy>=1.0.0", + "boto3-stubs[sso-admin,identitystore,organizations]>=1.26.0", +] + +[project.scripts] +aws-sso-auditor = "cpk_lib_python_aws.aws_sso_auditor.cli:main" + +[project.urls] +Homepage = "https://github.com/opencpk/cpk-lib-python-aws" +Repository = "https://github.com/opencpk/cpk-lib-python-aws" +Documentation = "https://github.com/opencpk/cpk-lib-python-aws#readme" + +[tool.setuptools.packages.find] +where = ["."] +include = ["cpk_lib_python_aws*"] + +[tool.pytest.ini_options] +testpaths = ["cpk_lib_python_aws/tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +[tool.black] +line-length = 100 +target-version = ['py38'] + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true \ No newline at end of file From 5fdfa4c7ed4b3be7c575d2546e4c7260ce4d5578 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Mon, 7 Jul 2025 14:37:34 -0300 Subject: [PATCH 02/16] feat/aws-auditor: clean up and restructuring --- .gitignore | 265 ++++-------- .gitleaks.toml | 9 + .pre-commit-config.yaml | 111 +++++ .yamllint.yaml | 35 ++ README.md | 13 + __init__.py | 22 +- cpk_lib_python_aws/__init__.py | 20 + cpk_lib_python_aws/aws_sso_auditor/README.md | 380 ++++++++++++++++++ .../aws_sso_auditor/__init__.py | 19 +- .../aws_sso_auditor/__main__.py | 4 +- cpk_lib_python_aws/aws_sso_auditor/auditor.py | 347 +++++++++------- .../aws_sso_auditor/aws_client_manager.py | 68 ++-- cpk_lib_python_aws/aws_sso_auditor/cli.py | 156 +++---- cpk_lib_python_aws/aws_sso_auditor/config.py | 37 +- .../aws_sso_auditor/exceptions.py | 8 +- .../aws_sso_auditor/formatters.py | 96 +++-- cpk_lib_python_aws/aws_sso_auditor/utils.py | 16 +- cpk_lib_python_aws/shared/__init__.py | 12 +- cpk_lib_python_aws/shared/aws_base.py | 33 +- cpk_lib_python_aws/shared/exceptions.py | 5 +- cpk_lib_python_aws/shared/output_sink.py | 66 +++ cpk_lib_python_aws/shared/utils.py | 29 +- pyproject.toml | 47 ++- 23 files changed, 1218 insertions(+), 580 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 .pre-commit-config.yaml create mode 100644 .yamllint.yaml create mode 100644 cpk_lib_python_aws/aws_sso_auditor/README.md create mode 100644 cpk_lib_python_aws/shared/output_sink.py diff --git a/.gitignore b/.gitignore index b7faf40..b2ae735 100644 --- a/.gitignore +++ b/.gitignore @@ -1,207 +1,88 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] *$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg +*.cover *.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.egg-info/ +*.log *.manifest +*.mo +*.pot +*.py.cover +*.py[codz] +*.sage.py +*.so *.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ +.Python +.abstra/ +.cache .coverage .coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover +.cursorignore +.cursorindexingignore +.dmypy.json +.eggs/ +.env +.envrc .hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook +.installed.cfg .ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python +.mypy_cache/ +.nox/ .pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. +.pdm-python .pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings +.pybuilder/ +.pypirc +.pyre/ +.pytest_cache/ +.pytype/ +.ropeproject +.ruff_cache/ +.scrapy .spyderproject .spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation +.tox/ +.venv +.webassets-cache /site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols +ENV/ +MANIFEST +__marimo__/ +__pycache__/ +__pypackages__/ +build/ +celerybeat-schedule +celerybeat.pid +cover/ +coverage.xml cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ +db.sqlite3 +db.sqlite3-journal +develop-eggs/ +dist/ +dmypy.json +docs/_build/ +downloads/ +eggs/ +env.bak/ +env/ +htmlcov/ +instance/ +ipython_config.py +lib/ +lib64/ +local_settings.py marimo/_lsp/ -__marimo__/ +marimo/_static/ +nosetests.xml +parts/ +pip-delete-this-directory.txt +pip-log.txt +profile_default/ +sdist/ +share/python-wheels/ +target/ +var/ +venv.bak/ +venv/ +wheels/ +.vscode/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..b8bce2e --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,9 @@ +title = "Gitleaks Configuration" + +[allowlist] +description = "Allowlist for test files" +paths = [ + '''cpk_lib_python_github/.*tests/.*''', + '''.*conftest\.py''', + '''.*test_.*\.py''', +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8516fd6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,111 @@ +--- +repos: + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-merge-conflict + - id: check-added-large-files + args: [--maxkb=500] + - id: trailing-whitespace + - id: detect-private-key + - id: end-of-file-fixer + - id: fix-encoding-pragma + - id: file-contents-sorter + files: ^(requirements.*\.txt|\.gitignore)$ + - id: check-case-conflict + - id: mixed-line-ending + args: [--fix=lf] + # ----------------------------- + # Checkov is a static code analysis tool for scanning infrastructure as code (IaC) files for misconfigurations + # that may lead to security or compliance problems. + # ----------------------------- + # Checkov includes more than 750 predefined policies to check for common misconfiguration issues. + # Checkov also supports the creation and contribution of custom policies. + # ----------------------------- + # - repo: https://github.com/bridgecrewio/checkov.git + # rev: 3.2.174 + # hooks: + # - id: checkov + + # ----------------------------- + # Python Code Formatting with Black + # ----------------------------- + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + language_version: python3 + files: \.py$ + args: [--config=pyproject.toml] + + # ----------------------------- + # Python Import Sorting with isort (complements Black) + # ----------------------------- + - repo: https://github.com/pycqa/isort + rev: 6.0.1 + hooks: + - id: isort + files: \.py$ + args: [--profile=black, --line-length=88] + + # ----------------------------- + # Python Code Quality with Pylint + # ----------------------------- + - repo: https://github.com/pycqa/pylint + rev: v3.3.7 + hooks: + - id: pylint + args: [--rcfile=pyproject.toml] + files: \.py$ + additional_dependencies: [PyJWT, requests, toml, colorama, setuptools, boto3, botocore, pyyaml, certifi, urllib3] + + # ----------------------------- + # Gitleaks SAST tool for detecting and preventing hardcoded secrets like passwords, api keys, and tokens in git repos + # ----------------------------- + # If you are knowingly committing something that is not a secret and gitleaks is catching it, + # you can add an inline comment of '# gitleaks:allow' to the end of that line in your file. + # This will instructs gitleaks to ignore that secret - example: + # some_non_secret_value = a1b2c3d4e5f6g7h8i9j0 # gitleaks:allow + # ----------------------------- + - repo: https://github.com/gitleaks/gitleaks + rev: v8.27.2 + hooks: + - id: gitleaks + args: ['--config=.gitleaks.toml'] + # ----------------------------- + # # Generates Table of Contents in Markdown files + # # ----------------------------- + - repo: https://github.com/frnmst/md-toc + rev: 9.0.0 + hooks: + - id: md-toc + args: [-p, github] # CLI options + # ----------------------------- + # YAML Linting on yaml files for pre-commit and github actions + # ----------------------------- + - repo: https://github.com/adrienverge/yamllint + rev: v1.37.1 + hooks: + - id: yamllint + name: Check YAML syntax with yamllint + args: [--strict, -c=.yamllint.yaml, '.'] + always_run: true + pass_filenames: true + + # ----------------------------- + # GitHub Actions Workflow Linting on .github/workflows/*.yml files + # ----------------------------- + - repo: https://github.com/rhysd/actionlint + rev: v1.7.7 + hooks: + - id: actionlint + + - repo: local + hooks: + - id: toml build + name: test the .toml package health + entry: pip3 install . + language: python + pass_filenames: false + always_run: true diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..0ccdc36 --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,35 @@ +--- +yaml-files: + - '*.yaml' + - '*.yml' + - '.yamllint' + +rules: + anchors: enable + braces: enable + brackets: enable + colons: enable + commas: enable + comments: + level: warning + comments-indentation: + level: warning + document-end: disable + document-start: + level: warning + empty-lines: enable + empty-values: disable + float-values: disable + hyphens: enable + indentation: enable + key-duplicates: enable + key-ordering: disable + # line-length: + # max: 150 + # level: warning + new-line-at-end-of-file: enable + new-lines: enable + octal-values: disable + quoted-strings: disable + trailing-spaces: enable + truthy: disable diff --git a/README.md b/README.md index e69de29..18bb24e 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,13 @@ +# ☁️ CPK AWS Python Libraries + +A comprehensive collection of Python libraries for AWS automation, management, and integration. This package provides a suite of tools designed to simplify AWS operations for development teams, CI/CD pipelines, and automation scripts. + +## 📋 Overview + +**CPK AWS Python Libraries** is a modular collection of AWS-related utilities designed for: + +- 🔍 **SSO Auditing & Compliance** - AWS Single Sign-On analysis and reporting +- 🔐 **Identity Management** - User, group, and permission set automation +- 📊 **Security Analysis** - Comprehensive AWS access auditing +- 🚀 **CI/CD Integration** - Seamless pipeline automation for AWS governance +- 🔧 **Development Tools** - CLI utilities for daily AWS administration tasks diff --git a/__init__.py b/__init__.py index 6b9495d..6482021 100644 --- a/__init__.py +++ b/__init__.py @@ -1,23 +1,15 @@ -"""CPK Python AWS Library - Collection of AWS tools and utilities.""" +# -*- coding: utf-8 -*- +"""CPK Python AWS Library - Comprehensive AWS utilities and tools.""" -# AWS SSO Auditor Package from .aws_sso_auditor import AWSSSOAuditor -from .aws_sso_auditor.config import Config as SSOConfig -from .aws_sso_auditor.exceptions import AWSSSOAuditorError - -# Shared utilities -from .shared import AWSBaseClient, AWSError +from .aws_sso_auditor import Config as SSOConfig +from .shared import AWSBaseClient, AWSError, OutputSink __version__ = "1.0.0" - -# Package exports __all__ = [ - # AWS SSO Auditor "AWSSSOAuditor", - "SSOConfig", - "AWSSSOAuditorError", - - # Shared utilities + "SSOConfig", + "OutputSink", "AWSBaseClient", "AWSError", -] \ No newline at end of file +] diff --git a/cpk_lib_python_aws/__init__.py b/cpk_lib_python_aws/__init__.py index e69de29..53ccb31 100644 --- a/cpk_lib_python_aws/__init__.py +++ b/cpk_lib_python_aws/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +"""CPK Python AWS Library - Comprehensive AWS utilities and tools.""" + +# Import SSO Auditor components +from .aws_sso_auditor import AWSSSOAuditor +from .aws_sso_auditor import Config as SSOConfig + +# Import shared components +from .shared import AWSBaseClient, AWSError, OutputSink + +__version__ = "1.0.0" +__all__ = [ + # Shared components + "OutputSink", + "AWSBaseClient", + "AWSError", + # SSO Auditor components + "AWSSSOAuditor", + "SSOConfig", +] diff --git a/cpk_lib_python_aws/aws_sso_auditor/README.md b/cpk_lib_python_aws/aws_sso_auditor/README.md new file mode 100644 index 0000000..e972caa --- /dev/null +++ b/cpk_lib_python_aws/aws_sso_auditor/README.md @@ -0,0 +1,380 @@ +# 🔍 AWS SSO Auditor + +A powerful CLI tool for auditing AWS Single Sign-On (SSO) configurations, analyzing permission sets, groups, and assignments across AWS accounts. This tool simplifies AWS SSO compliance and security audits by providing comprehensive reporting and analysis capabilities. + +## 📋 Table of Contents + +- [Features](#-features) +- [Installation](#-installation) +- [Quick Start](#-quick-start) +- [Usage Examples](#-usage-examples) +- [Command Reference](#-command-reference) +- [Environment Variables](#-environment-variables) +- [Sample Outputs](#-sample-outputs) +- [Common Use Cases](#-common-use-cases) +- [Configuration](#-configuration) +- [Python Usage](#-python-usage) + +## ✨ Features + +- 🔍 **Comprehensive SSO Auditing**: Analyze AWS SSO groups, permission sets, and account assignments +- 👥 **Group Analysis**: Detailed group membership and permission mapping +- 🔐 **Permission Set Analysis**: In-depth analysis of AWS managed, customer managed, and inline policies +- 📊 **Multi-format Output**: Support for JSON and YAML output formats +- 🎯 **Account-specific Auditing**: Focus audits on specific AWS accounts +- 📁 **Flexible Output Management**: Configurable output directories with timestamp support +- 🌍 **Environment Variables**: Full support for environment-based configuration +- 🎨 **Rich Console Output**: Colorized, well-formatted output with progress indicators +- 📝 **Debug Mode**: Detailed logging for troubleshooting +- 🏗️ **Professional Architecture**: Modular design with proper error handling + +## 🚀 Installation + +### Prerequisites + +- AWS SSO enabled in your AWS organization +- AWS credentials with appropriate SSO permissions: + - `sso-admin:*` permissions + - `identitystore:*` permissions + - `organizations:ListAccounts` permission + +### Install from Source + +```bash +pip install git+https://github.com/opencpk/cpk-lib-python-aws.git@main +``` + +### Verify Installation + +```bash +aws-sso-auditor --help +``` + +## 🎯 Quick Start + +### 1. Set up AWS Credentials + +```bash +# Using AWS CLI profiles +export AWS_PROFILE=sso-admin + +# Or using environment variables +export AWS_REGION=us-east-1 +``` + +### 2. Run Your First Audit + +```bash +aws-sso-auditor 123456789012 --output-format json +``` + +### 3. Generate Comprehensive Reports + +```bash +aws-sso-auditor 123456789012 --output-format both --output-dir ./audit-reports +``` + +## 📖 Usage Examples + +### 🔍 Basic Auditing + +#### Audit a specific AWS account: +```bash +aws-sso-auditor 123456789012 +``` + +#### Audit with JSON output only: +```bash +aws-sso-auditor 123456789012 --output-format json +``` + +#### Audit with YAML output only: +```bash +aws-sso-auditor 123456789012 --output-format yaml +``` + +#### Audit with both formats: +```bash +aws-sso-auditor 123456789012 --output-format both +``` + +### 📁 Output Management + +#### Custom output directory: +```bash +aws-sso-auditor 123456789012 --output-dir ./my-audit-reports +``` + +#### Disable timestamps in filenames: +```bash +aws-sso-auditor 123456789012 --no-timestamp +``` + +### 🌍 Region and Profile Configuration + +#### Specify AWS region: +```bash +aws-sso-auditor 123456789012 --aws-region us-west-2 +``` + +#### Use specific AWS profile: +```bash +aws-sso-auditor 123456789012 --aws-profile sso-admin-profile +``` + +### 🔇 Quiet and Debug Modes + +#### Quiet mode (no console output): +```bash +aws-sso-auditor 123456789012 --quiet +``` + +#### Debug mode (detailed logging): +```bash +aws-sso-auditor 123456789012 --debug +``` + +### 🐛 Debug & Help + +#### Show help: +```bash +aws-sso-auditor --help +``` + +## 📚 Command Reference + +| Argument | Description | Default | +|----------|-------------|---------| +| `account_id` | AWS Account ID to audit (required) | - | +| `--output-format` | Output format: `json`, `yaml`, or `both` | `both` | +| `--output-dir` | Output directory path | `./aws-sso-audit-results` | +| `--aws-region` | AWS region | `us-east-1` | +| `--aws-profile` | AWS profile to use | None | +| `--quiet` `-q` | Suppress console output and logging, only save files | `False` | +| `--debug` | Enable debug logging | `False` | +| `--no-timestamp` | Don't include timestamp in filenames | `False` | +| `--help` | Show help message | - | + +## 🌍 Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `AWS_REGION` | AWS region | `us-east-1` | +| `AWS_PROFILE` | AWS profile to use | `sso-admin` | +| `AWS_SSO_AUDITOR_OUTPUT_DIR` | Default output directory | `./audit-reports` | +| `AWS_SSO_AUDITOR_DEBUG` | Enable debug mode | `true` | +| `AWS_SSO_AUDITOR_QUIET` | Enable quiet mode | `true` | + +### Setting Environment Variables + +```bash +# Basic configuration +export AWS_REGION=us-east-1 + +# Advanced configuration +export AWS_SSO_AUDITOR_OUTPUT_DIR=./audit-reports +export AWS_SSO_AUDITOR_DEBUG=true + +# Then use shorter commands +aws-sso-auditor 123456789012 +``` + +## 🎨 Sample Outputs + +### 📊 Successful Audit Output + +```bash +$ aws-sso-auditor 123456789012 --output-format json --debug +``` + +**Console Output:** +``` +⏳ Initializing AWS clients... +⏳ Starting audit for account: 123456789012 +⏳ Retrieving account assignments... +🔍 Found 13 assignments +⏳ Processing assignments... +⏳ Processing group: 90967fb4-d4e1-7019-c6a2-3b4d2a8c7e5f +⏳ Processing permission set: arn:aws:sso:::permissionSet/ssoins-1234567890abcdef/ps-1234567890abcdef +⏳ Finalizing audit results... +✅ Results saved to: ./aws-sso-audit-results/aws_sso_audit_123456789012_20250107_124443.json + +📊 AWS SSO Audit Summary +🆔 Account: 123456789012 +📅 Generated: 2025-01-07T12:44:43.717 +👥 Groups: 3 +🔐 Permission Sets: 5 +🔗 Assignments: 13 +``` + +### 📋 Sample JSON Output Structure + +```json +{ + "metadata": { + "generated_at": "2025-01-07T12:44:43.717", + "account_id": "123456789012", + "sso_instance_arn": "arn:aws:sso:::instance/ssoins-1234567890abcdef", + "identity_store_id": "d-1234567890", + "auditor_version": "1.0.0", + "config": { + "aws_region": "us-east-1", + "output_formats": ["json"] + } + }, + "sso_groups_summary": [ + "Developers", + "Administrators", + "ReadOnlyUsers" + ], + "sso_permission_sets_summary": [ + "AdministratorAccess", + "DeveloperAccess", + "ReadOnlyAccess", + "PowerUserAccess", + "CustomDataAccess" + ], + "sso_groups": [ + { + "GroupId": "90967fb4-d4e1-7019-c6a2-3b4d2a8c7e5f", + "DisplayName": "Developers", + "Description": "Development team members", + "Members": [ + { + "UserId": "90967fb4-b2c1-70a8-b8a2-1b2c3d4e5f6g", + "UserName": "john.doe", + "DisplayName": "John Doe", + "Email": "john.doe@company.com" + } + ], + "PermissionSets": [ + { + "Name": "DeveloperAccess", + "Description": "Developer permissions", + "Policies": { + "managed_policies": [ + { + "Name": "PowerUserAccess", + "Arn": "arn:aws:iam::aws:policy/PowerUserAccess" + } + ], + "customer_managed_policies": [], + "inline_policy": null + } + } + ] + } + ], + "permission_sets": [ + { + "Name": "DeveloperAccess", + "Description": "Developer permissions", + "CreatedDate": "2024-01-15T10:30:00Z", + "SessionDuration": "PT8H", + "Policies": { + "managed_policies": [ + { + "Name": "PowerUserAccess", + "Arn": "arn:aws:iam::aws:policy/PowerUserAccess" + } + ], + "customer_managed_policies": [], + "inline_policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::company-dev-bucket/*" + } + ] + } + }, + "AssignedGroups": [ + "90967fb4-d4e1-7019-c6a2-3b4d2a8c7e5f" + ] + } + ], + "summary": { + "total_groups": 3, + "total_permission_sets": 5, + "total_assignments": 13 + } +} +``` + +### ❌ Error Output Examples + +#### Account not found: +```bash +$ aws-sso-auditor 999999999999 +``` +**Output:** +``` +❌ AWS SSO Auditor Error: No permission sets found for account 999999999999 +``` + +#### Invalid credentials: +```bash +$ aws-sso-auditor 123456789012 +``` +**Output:** +``` +❌ Unexpected error: Unable to locate credentials +``` + +### 🔇 Quiet Mode Output + +```bash +$ aws-sso-auditor 123456789012 --quiet +``` +**Output:** (No console output, only files generated) + +### 🐛 Debug Mode Output + +```bash +$ aws-sso-auditor 123456789012 --debug +``` + + +## 🐍 Python Usage + +If you prefer to use this tool as a Python library in your scripts: + +### Programmatic Usage + +```python -c " +from cpk_lib_python_aws.aws_sso_auditor import AWSSSOAuditor, Config, OutputFormatter +from cpk_lib_python_aws.shared import OutputSink + +config = Config( + output_directory='./test-results-1', + include_timestamp=False, + quiet=False +) + +output_sink = OutputSink(quiet=True) +auditor = AWSSSOAuditor(config, output_sink) +formatter = OutputFormatter(config, output_sink) + +results = auditor.audit_account('123456789012') +formatter.save_results(results, '123456789012') +" +``` + + +### Without Timestamps + +``` +./aws-sso-audit-results/ +├── aws_sso_audit_123456789012.json +└── aws_sso_audit_123456789012.yaml +``` + +## 📄 License + +This project is licensed under the GPLv3 License. + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit issues and pull requests. diff --git a/cpk_lib_python_aws/aws_sso_auditor/__init__.py b/cpk_lib_python_aws/aws_sso_auditor/__init__.py index 92b429e..55c36c5 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/__init__.py +++ b/cpk_lib_python_aws/aws_sso_auditor/__init__.py @@ -1,18 +1,25 @@ +# -*- coding: utf-8 -*- """AWS SSO Auditor - Professional AWS SSO auditing and compliance tool.""" +from ..shared import OutputSink from .auditor import AWSSSOAuditor +from .aws_client_manager import AWSClientManager from .config import Config -from .exceptions import AWSSSOAuditorError, PermissionError, ConfigurationError +from .exceptions import ( + AWSSSOAuditorError, + ConfigurationError, + InsufficientPermissionsError, +) from .formatters import OutputFormatter -from .aws_client_manager import AWSClientManager __version__ = "1.0.0" __all__ = [ "AWSSSOAuditor", - "Config", + "Config", "AWSSSOAuditorError", - "PermissionError", - "ConfigurationError", + "InsufficientPermissionsError", # Keep this as is since it's defined in your local exceptions + "ConfigurationError", "OutputFormatter", "AWSClientManager", -] \ No newline at end of file + "OutputSink", +] diff --git a/cpk_lib_python_aws/aws_sso_auditor/__main__.py b/cpk_lib_python_aws/aws_sso_auditor/__main__.py index a86c4bb..d2719d2 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/__main__.py +++ b/cpk_lib_python_aws/aws_sso_auditor/__main__.py @@ -1,7 +1,9 @@ +# -*- coding: utf-8 -*- """Entry point for running AWS SSO Auditor as a module.""" import sys + from .cli import main if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/cpk_lib_python_aws/aws_sso_auditor/auditor.py b/cpk_lib_python_aws/aws_sso_auditor/auditor.py index 2419b6d..5a6029e 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/auditor.py +++ b/cpk_lib_python_aws/aws_sso_auditor/auditor.py @@ -1,10 +1,12 @@ +# -*- coding: utf-8 -*- """Core AWS SSO auditing functionality.""" - +import json import logging from datetime import datetime -from typing import Dict, List, Any -from .config import Config +from typing import Any, Dict, List + from .aws_client_manager import AWSClientManager +from .config import Config from .exceptions import AWSSSOAuditorError from .utils import clean_aws_response, safe_get_nested @@ -13,15 +15,20 @@ class AWSSSOAuditor: """Main AWS SSO auditing class.""" - - def __init__(self, config: Config = None): + + def __init__(self, config: Config = None, output_sink=None): """Initialize the AWS SSO Auditor.""" self.config = config or Config() self.config.validate() - + + # Initialize output sink + self.output_sink = output_sink + # Initialize AWS clients + if self.output_sink: + self.output_sink.progress("Initializing AWS clients...") self.aws_manager = AWSClientManager(self.config) - + # Store frequently used references self.sso_admin_client = self.aws_manager.sso_admin_client self.identitystore_client = self.aws_manager.identitystore_client @@ -33,47 +40,171 @@ def __init__(self, config: Config = None): if self.config.debug: client_info = self.aws_manager.get_client_info() logger.debug("AWS Client Info: %s", client_info) - + if self.output_sink: + self.output_sink.debug_info(f"Connected to SSO instance: {self.instance_arn}") + logger.info("AWS SSO Auditor initialized successfully") + # pylint: disable=too-many-branches + def audit_account(self, account_id: str) -> Dict[str, Any]: + """Perform complete audit of SSO access for the given account.""" + logger.info("Starting AWS SSO audit for account: %s", account_id) + if self.output_sink: + self.output_sink.progress(f"Starting audit for account: {account_id}") + + try: + # Get all account assignments (only for permission sets assigned to this account) + if self.output_sink: + self.output_sink.progress("Retrieving account assignments...") + assignments = self.get_all_account_assignments(account_id) + if self.output_sink: + self.output_sink.debug_info(f"Found {len(assignments)} assignments") + + # Organize data + groups_data = {} + permission_sets_data = {} + + if self.output_sink: + self.output_sink.progress("Processing assignments...") + for assignment in assignments: + principal_type = assignment["PrincipalType"] + principal_id = assignment["PrincipalId"] + permission_set_arn = assignment["PermissionSetArn"] + + if principal_type == "GROUP": + if principal_id not in groups_data: + if self.output_sink: + self.output_sink.progress(f"Processing group: {principal_id}") + group_details = self.get_group_details(principal_id) + group_members = self.get_group_members(principal_id) + groups_data[principal_id] = { + **group_details, + "Members": group_members, + "PermissionSets": [], + } + + # Get full permission set details for this group + permission_set_details = self.get_permission_set_details(permission_set_arn) + permission_set_policies = self.get_permission_set_policies(permission_set_arn) + + permission_set_full_details = { + **permission_set_details, + "Policies": permission_set_policies, + } + + groups_data[principal_id]["PermissionSets"].append(permission_set_full_details) + + # Collect permission set data (only for those with assignments to this account) + if permission_set_arn not in permission_sets_data: + if self.output_sink: + self.output_sink.progress( + f"Processing permission set: {permission_set_arn}" + ) + permission_set_details = self.get_permission_set_details(permission_set_arn) + permission_set_policies = self.get_permission_set_policies(permission_set_arn) + permission_sets_data[permission_set_arn] = { + **permission_set_details, + "Policies": permission_set_policies, + "AssignedGroups": [], + } + + if principal_type == "GROUP": + if ( + principal_id + not in permission_sets_data[permission_set_arn]["AssignedGroups"] + ): + permission_sets_data[permission_set_arn]["AssignedGroups"].append( + principal_id + ) + + # Create simple lists for summary + group_names = [group["DisplayName"] for group in groups_data.values()] + permission_set_names = [ + ps.get("Name", "Unknown") for ps in permission_sets_data.values() + ] + + if self.output_sink: + self.output_sink.progress("Finalizing audit results...") + + # Build final result + result = { + "metadata": { + "generated_at": datetime.now().isoformat(), + "account_id": account_id, + "sso_instance_arn": self.instance_arn, + "identity_store_id": self.identity_store_id, + "auditor_version": "1.0.0", + "config": { + "aws_region": self.config.aws_region, + "output_formats": self.config.output_formats, + }, + }, + "sso_groups_summary": group_names, + "sso_permission_sets_summary": permission_set_names, + "sso_groups": list(groups_data.values()), + "permission_sets": list(permission_sets_data.values()), + "summary": { + "total_groups": len(groups_data), + "total_permission_sets": len(permission_sets_data), + "total_assignments": len(assignments), + }, + } + + logger.info("Audit completed successfully for account %s", account_id) + return result + + except Exception as e: + logger.error("Audit failed for account %s: %s", account_id, e) + raise AWSSSOAuditorError(f"Failed to audit account {account_id}: {e}") from e + def get_permission_sets_for_account(self, account_id: str) -> List[str]: """Get only permission sets that are provisioned/assigned to the specific account.""" try: permission_sets = [] - paginator = self.sso_admin_client.get_paginator('list_permission_sets_provisioned_to_account') + paginator = self.sso_admin_client.get_paginator( + "list_permission_sets_provisioned_to_account" + ) - for page in paginator.paginate( - InstanceArn=self.instance_arn, - AccountId=account_id - ): - permission_sets.extend(page['PermissionSets']) + for page in paginator.paginate(InstanceArn=self.instance_arn, AccountId=account_id): + permission_sets.extend(page["PermissionSets"]) - logger.info("Found %d permission sets provisioned to account %s", len(permission_sets), account_id) + logger.info( + "Found %d permission sets provisioned to account %s", + len(permission_sets), + account_id, + ) return permission_sets except Exception as e: logger.error("Error getting permission sets for account %s: %s", account_id, e) return [] - def get_account_assignments_for_permission_set(self, permission_set_arn: str, account_id: str) -> List[Dict[str, Any]]: + def get_account_assignments_for_permission_set( + self, permission_set_arn: str, account_id: str + ) -> List[Dict[str, Any]]: """Get account assignments for a specific permission set and account.""" try: assignments = [] - paginator = self.sso_admin_client.get_paginator('list_account_assignments') + paginator = self.sso_admin_client.get_paginator("list_account_assignments") for page in paginator.paginate( - InstanceArn=self.instance_arn, - AccountId=account_id, - PermissionSetArn=permission_set_arn + InstanceArn=self.instance_arn, + AccountId=account_id, + PermissionSetArn=permission_set_arn, ): - assignments.extend(page['AccountAssignments']) + assignments.extend(page["AccountAssignments"]) return assignments except Exception as e: - logger.error("Error getting account assignments for permission set %s: %s", permission_set_arn, e) + logger.error( + "Error getting account assignments for permission set %s: %s", permission_set_arn, e + ) return [] def get_all_account_assignments(self, account_id: str) -> List[Dict[str, Any]]: - """Get all account assignments for the given account by checking only provisioned permission sets.""" + """Get all account assignments for the given account. + + Only checks permission sets that are provisioned to this account. + """ all_assignments = [] # Get only permission sets that are provisioned to this account @@ -81,7 +212,9 @@ def get_all_account_assignments(self, account_id: str) -> List[Dict[str, Any]]: # Then get assignments for each provisioned permission set for permission_set_arn in permission_sets: - assignments = self.get_account_assignments_for_permission_set(permission_set_arn, account_id) + assignments = self.get_account_assignments_for_permission_set( + permission_set_arn, account_id + ) all_assignments.extend(assignments) logger.info("Found %d total assignments for account %s", len(all_assignments), account_id) @@ -91,30 +224,28 @@ def get_group_details(self, group_id: str) -> Dict[str, Any]: """Get group details including name and description.""" try: response = self.identitystore_client.describe_group( - IdentityStoreId=self.identity_store_id, - GroupId=group_id + IdentityStoreId=self.identity_store_id, GroupId=group_id ) return { - 'GroupId': response['GroupId'], - 'DisplayName': response['DisplayName'], - 'Description': response.get('Description', '') + "GroupId": response["GroupId"], + "DisplayName": response["DisplayName"], + "Description": response.get("Description", ""), } except Exception as e: logger.error("Error getting group details for %s: %s", group_id, e) - return {'GroupId': group_id, 'DisplayName': 'Unknown', 'Description': ''} + return {"GroupId": group_id, "DisplayName": "Unknown", "Description": ""} def get_group_members(self, group_id: str) -> List[Dict[str, Any]]: """Get all members of a group.""" try: members = [] - paginator = self.identitystore_client.get_paginator('list_group_memberships') + paginator = self.identitystore_client.get_paginator("list_group_memberships") for page in paginator.paginate( - IdentityStoreId=self.identity_store_id, - GroupId=group_id + IdentityStoreId=self.identity_store_id, GroupId=group_id ): - for membership in page['GroupMemberships']: - user_id = membership['MemberId']['UserId'] + for membership in page["GroupMemberships"]: + user_id = membership["MemberId"]["UserId"] user_details = self.get_user_details(user_id) members.append(user_details) @@ -127,68 +258,61 @@ def get_user_details(self, user_id: str) -> Dict[str, Any]: """Get user details including username and display name.""" try: response = self.identitystore_client.describe_user( - IdentityStoreId=self.identity_store_id, - UserId=user_id + IdentityStoreId=self.identity_store_id, UserId=user_id ) return { - 'UserId': response['UserId'], - 'UserName': response['UserName'], - 'DisplayName': response.get('DisplayName', response['UserName']), - 'Email': safe_get_nested(response, ['Emails', 0, 'Value'], '') + "UserId": response["UserId"], + "UserName": response["UserName"], + "DisplayName": response.get("DisplayName", response["UserName"]), + "Email": safe_get_nested(response, ["Emails", 0, "Value"], ""), } except Exception as e: logger.error("Error getting user details for %s: %s", user_id, e) - return {'UserId': user_id, 'UserName': 'Unknown', 'DisplayName': 'Unknown', 'Email': ''} + return {"UserId": user_id, "UserName": "Unknown", "DisplayName": "Unknown", "Email": ""} def get_permission_set_details(self, permission_set_arn: str) -> Dict[str, Any]: """Get permission set details.""" try: response = self.sso_admin_client.describe_permission_set( - InstanceArn=self.instance_arn, - PermissionSetArn=permission_set_arn + InstanceArn=self.instance_arn, PermissionSetArn=permission_set_arn ) - return clean_aws_response(response['PermissionSet']) + return clean_aws_response(response["PermissionSet"]) except Exception as e: logger.error("Error getting permission set details for %s: %s", permission_set_arn, e) return {} def get_permission_set_policies(self, permission_set_arn: str) -> Dict[str, Any]: """Get all policies attached to a permission set.""" - policies = { - 'managed_policies': [], - 'customer_managed_policies': [], - 'inline_policy': None - } + policies = {"managed_policies": [], "customer_managed_policies": [], "inline_policy": None} try: # Get AWS managed policies - managed_paginator = self.sso_admin_client.get_paginator('list_managed_policies_in_permission_set') + managed_paginator = self.sso_admin_client.get_paginator( + "list_managed_policies_in_permission_set" + ) for page in managed_paginator.paginate( - InstanceArn=self.instance_arn, - PermissionSetArn=permission_set_arn + InstanceArn=self.instance_arn, PermissionSetArn=permission_set_arn ): - policies['managed_policies'].extend(page['AttachedManagedPolicies']) + policies["managed_policies"].extend(page["AttachedManagedPolicies"]) # Get customer managed policies customer_paginator = self.sso_admin_client.get_paginator( - 'list_customer_managed_policy_references_in_permission_set') + "list_customer_managed_policy_references_in_permission_set" + ) for page in customer_paginator.paginate( - InstanceArn=self.instance_arn, - PermissionSetArn=permission_set_arn + InstanceArn=self.instance_arn, PermissionSetArn=permission_set_arn ): - for policy_ref in page['CustomerManagedPolicyReferences']: + for policy_ref in page["CustomerManagedPolicyReferences"]: policy_details = self.get_customer_managed_policy_details(policy_ref) - policies['customer_managed_policies'].append(policy_details) + policies["customer_managed_policies"].append(policy_details) # Get inline policy try: inline_response = self.sso_admin_client.get_inline_policy_for_permission_set( - InstanceArn=self.instance_arn, - PermissionSetArn=permission_set_arn + InstanceArn=self.instance_arn, PermissionSetArn=permission_set_arn ) - if inline_response.get('InlinePolicy'): - import json - policies['inline_policy'] = json.loads(inline_response['InlinePolicy']) + if inline_response.get("InlinePolicy"): + policies["inline_policy"] = json.loads(inline_response["InlinePolicy"]) except self.sso_admin_client.exceptions.ResourceNotFoundException: # No inline policy exists pass @@ -202,98 +326,11 @@ def get_customer_managed_policy_details(self, policy_ref: Dict[str, Any]) -> Dic """Get details for customer managed policy.""" try: return { - 'Name': policy_ref['Name'], - 'Path': policy_ref.get('Path', '/'), - 'Type': 'CustomerManaged', - 'Note': 'Policy document not retrieved - requires target account access' + "Name": policy_ref["Name"], + "Path": policy_ref.get("Path", "/"), + "Type": "CustomerManaged", + "Note": "Policy document not retrieved - requires target account access", } except Exception as e: logger.error("Error getting customer managed policy details: %s", e) return policy_ref - - def audit_account(self, account_id: str) -> Dict[str, Any]: - """Perform complete audit of SSO access for the given account.""" - logger.info("Starting AWS SSO audit for account: %s", account_id) - - try: - # Get all account assignments (only for permission sets assigned to this account) - assignments = self.get_all_account_assignments(account_id) - - # Organize data - groups_data = {} - permission_sets_data = {} - - for assignment in assignments: - principal_type = assignment['PrincipalType'] - principal_id = assignment['PrincipalId'] - permission_set_arn = assignment['PermissionSetArn'] - - if principal_type == 'GROUP': - if principal_id not in groups_data: - group_details = self.get_group_details(principal_id) - group_members = self.get_group_members(principal_id) - groups_data[principal_id] = { - **group_details, - 'Members': group_members, - 'PermissionSets': [] - } - - # Get full permission set details for this group - permission_set_details = self.get_permission_set_details(permission_set_arn) - permission_set_policies = self.get_permission_set_policies(permission_set_arn) - - permission_set_full_details = { - **permission_set_details, - 'Policies': permission_set_policies - } - - groups_data[principal_id]['PermissionSets'].append(permission_set_full_details) - - # Collect permission set data (only for those with assignments to this account) - if permission_set_arn not in permission_sets_data: - permission_set_details = self.get_permission_set_details(permission_set_arn) - permission_set_policies = self.get_permission_set_policies(permission_set_arn) - permission_sets_data[permission_set_arn] = { - **permission_set_details, - 'Policies': permission_set_policies, - 'AssignedGroups': [] - } - - if principal_type == 'GROUP': - if principal_id not in permission_sets_data[permission_set_arn]['AssignedGroups']: - permission_sets_data[permission_set_arn]['AssignedGroups'].append(principal_id) - - # Create simple lists for summary - group_names = [group['DisplayName'] for group in groups_data.values()] - permission_set_names = [ps.get('Name', 'Unknown') for ps in permission_sets_data.values()] - - # Build final result - result = { - 'metadata': { - 'generated_at': datetime.now().isoformat(), - 'account_id': account_id, - 'sso_instance_arn': self.instance_arn, - 'identity_store_id': self.identity_store_id, - 'auditor_version': '1.0.0', - 'config': { - 'aws_region': self.config.aws_region, - 'output_formats': self.config.output_formats, - } - }, - 'sso_groups_summary': group_names, - 'sso_permission_sets_summary': permission_set_names, - 'sso_groups': list(groups_data.values()), - 'permission_sets': list(permission_sets_data.values()), - 'summary': { - 'total_groups': len(groups_data), - 'total_permission_sets': len(permission_sets_data), - 'total_assignments': len(assignments) - } - } - - logger.info("Audit completed successfully for account %s", account_id) - return result - - except Exception as e: - logger.error("Audit failed for account %s: %s", account_id, e) - raise AWSSSOAuditorError(f"Failed to audit account {account_id}: {e}") from e \ No newline at end of file diff --git a/cpk_lib_python_aws/aws_sso_auditor/aws_client_manager.py b/cpk_lib_python_aws/aws_sso_auditor/aws_client_manager.py index 192d321..71dd5f3 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/aws_client_manager.py +++ b/cpk_lib_python_aws/aws_sso_auditor/aws_client_manager.py @@ -1,84 +1,86 @@ +# -*- coding: utf-8 -*- """AWS client management for SSO auditing - extends shared base.""" import logging -from typing import Dict, Any +from typing import Any, Dict + from ..shared import AWSBaseClient from .config import Config from .exceptions import AWSClientError, SSOInstanceNotFoundError + logger = logging.getLogger(__name__) class AWSClientManager(AWSBaseClient): """Manages AWS clients specific to SSO auditing.""" - + def __init__(self, config: Config): """Initialize AWS clients with SSO-specific configuration.""" super().__init__(region=config.aws_region, profile=config.aws_profile) self.config = config - + # SSO-specific clients self.sso_admin_client = None self.identitystore_client = None self.organizations_client = None - + # SSO instance information self.sso_instance = None self.identity_store_id = None self.instance_arn = None - + self._initialize_sso_clients() - + def _initialize_sso_clients(self) -> None: """Initialize SSO-specific AWS clients.""" try: # Initialize AWS clients - self.sso_admin_client = self.session.client('sso-admin') - self.identitystore_client = self.session.client('identitystore') - self.organizations_client = self.session.client('organizations') - + self.sso_admin_client = self.session.client("sso-admin") + self.identitystore_client = self.session.client("identitystore") + self.organizations_client = self.session.client("organizations") + logger.info("SSO-specific AWS clients initialized successfully") - + # Discover SSO instance self._discover_sso_instance() - + except Exception as e: logger.error("Failed to initialize SSO clients: %s", e) raise AWSClientError(f"Error initializing SSO clients: {e}") from e - + def _discover_sso_instance(self) -> None: """Discover and validate SSO instance.""" try: response = self.sso_admin_client.list_instances() - if not response['Instances']: + if not response["Instances"]: raise SSOInstanceNotFoundError("No SSO instances found in this AWS account") - - self.sso_instance = response['Instances'][0] - self.identity_store_id = self.sso_instance['IdentityStoreId'] - self.instance_arn = self.sso_instance['InstanceArn'] - + + self.sso_instance = response["Instances"][0] + self.identity_store_id = self.sso_instance["IdentityStoreId"] + self.instance_arn = self.sso_instance["InstanceArn"] + logger.info("SSO instance discovered: %s", self.instance_arn) - + except Exception as e: if "No SSO instances found" in str(e): raise logger.error("Failed to discover SSO instance: %s", e) raise SSOInstanceNotFoundError(f"Failed to get SSO instance: {e}") from e - - + def get_client_info(self) -> Dict[str, Any]: """Get information about configured SSO clients.""" base_info = { - 'region': self.region, - 'profile': self.profile, - 'caller_identity': self.get_caller_identity(), + "region": self.region, + "profile": self.profile, + "caller_identity": self.get_caller_identity(), } - + sso_info = { - 'sso_instance_arn': self.instance_arn, - 'identity_store_id': self.identity_store_id, - 'has_sso_admin': self.sso_admin_client is not None, - 'has_identity_store': self.identitystore_client is not None, - 'has_organizations': self.organizations_client is not None, + "sso_instance_arn": self.instance_arn, + "identity_store_id": self.identity_store_id, + "has_sso_admin": self.sso_admin_client is not None, + "has_identity_store": self.identitystore_client is not None, + "has_organizations": self.organizations_client is not None, } - - return {**base_info, **sso_info} \ No newline at end of file + + return {**base_info, **sso_info} diff --git a/cpk_lib_python_aws/aws_sso_auditor/cli.py b/cpk_lib_python_aws/aws_sso_auditor/cli.py index 8e59ed7..852f0a8 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/cli.py +++ b/cpk_lib_python_aws/aws_sso_auditor/cli.py @@ -1,82 +1,70 @@ +# -*- coding: utf-8 -*- """CLI interface for AWS SSO Auditor.""" import argparse -import sys import logging +import sys from typing import List + +from ..shared import OutputSink # <-- CHANGED: Import from shared instead of local from .auditor import AWSSSOAuditor from .config import Config -from .formatters import OutputFormatter from .exceptions import AWSSSOAuditorError +from .formatters import OutputFormatter -def setup_logging(debug: bool = False) -> None: +def setup_logging(debug: bool = False, quiet: bool = False) -> None: """Setup logging configuration.""" - level = logging.DEBUG if debug else logging.INFO + if quiet: + level = logging.ERROR + elif debug: + level = logging.DEBUG + else: + level = logging.INFO + logging.basicConfig( level=level, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('aws_sso_auditor.log'), - logging.StreamHandler() - ] + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("aws_sso_auditor.log"), logging.StreamHandler()], ) def create_parser() -> argparse.ArgumentParser: """Create CLI argument parser.""" parser = argparse.ArgumentParser( - description='Audit AWS SSO Groups and Permission Sets for an account', - prog='aws-sso-auditor' - ) - - parser.add_argument( - 'account_id', - help='AWS Account ID to audit' - ) - - parser.add_argument( - '--output-format', - choices=['json', 'yaml', 'both'], - default='both', - help='Output format (default: both)' - ) - - parser.add_argument( - '--output-dir', - default='.', - help='Output directory (default: current directory)' - ) - - parser.add_argument( - '--aws-region', - default='us-east-1', - help='AWS region (default: us-east-1)' + description="Audit AWS SSO Groups and Permission Sets for an account", + prog="aws-sso-auditor", ) - + + parser.add_argument("account_id", help="AWS Account ID to audit") + parser.add_argument( - '--aws-profile', - help='AWS profile to use' + "--output-format", + choices=["json", "yaml", "both"], + default="both", + help="Output format (default: both)", ) - + parser.add_argument( - '--quiet', '-q', - action='store_true', - help='Suppress console output, only save files' + "--output-dir", + default="./aws-sso-audit-results", + help="Output directory (default: ./aws-sso-audit-results)", ) - + + parser.add_argument("--aws-region", default="us-east-1", help="AWS region (default: us-east-1)") + + parser.add_argument("--aws-profile", help="AWS profile to use") + parser.add_argument( - '--debug', - action='store_true', - help='Enable debug logging' + "--quiet", "-q", action="store_true", help="Suppress console output, only save files" ) - + + parser.add_argument("--debug", action="store_true", help="Enable debug logging") + parser.add_argument( - '--no-timestamp', - action='store_true', - help='Don\'t include timestamp in filenames' + "--no-timestamp", action="store_true", help="Don't include timestamp in filenames" ) - + return parser @@ -84,14 +72,16 @@ def main(args: List[str] = None) -> int: """Main CLI entry point.""" parser = create_parser() parsed_args = parser.parse_args(args) - + # Setup logging - setup_logging(parsed_args.debug) + setup_logging(parsed_args.debug, parsed_args.quiet) logger = logging.getLogger(__name__) - + # Create configuration - output_formats = [parsed_args.output_format] if parsed_args.output_format != 'both' else ['json', 'yaml'] - + output_formats = ( + [parsed_args.output_format] if parsed_args.output_format != "both" else ["json", "yaml"] + ) + config = Config( aws_region=parsed_args.aws_region, aws_profile=parsed_args.aws_profile, @@ -99,39 +89,53 @@ def main(args: List[str] = None) -> int: output_directory=parsed_args.output_dir, include_timestamp=not parsed_args.no_timestamp, debug=parsed_args.debug, - quiet=parsed_args.quiet + quiet=parsed_args.quiet, ) - + + # Create output sink for clean console management + output = OutputSink(config.quiet, config.debug) + try: # Initialize auditor and formatter - auditor = AWSSSOAuditor(config) - formatter = OutputFormatter(config) - + output.progress("Initializing AWS SSO Auditor...") + auditor = AWSSSOAuditor(config, output) + formatter = OutputFormatter(config, output) + # Run audit - logger.info(f"Starting audit for account: {parsed_args.account_id}") + output.info(f"Starting audit for account: {parsed_args.account_id}") + logger.info("Starting audit for account: %s", parsed_args.account_id) + results = auditor.audit_account(parsed_args.account_id) - + # Save results + output.progress("Saving results to files...") saved_files = formatter.save_results(results, parsed_args.account_id) - logger.info(f"Results saved to: {', '.join(saved_files)}") - - # Display results (unless quiet) - if not config.quiet: - formatter.display_results(results) - print(f"✅ Results saved to: {', '.join(saved_files)}") - + logger.info("Results saved to: %s", ", ".join(saved_files)) + + # Display results using output sink + formatter.display_results(results) + output.success(f"Results saved to: {', '.join(saved_files)}") + + # Show summary in debug mode + if config.debug: + summary = results.get("summary", {}) + output.debug_info( + f"Processed {summary.get('total_groups', 0)} groups, " + f"{summary.get('total_permission_sets', 0)} permission sets" + ) + logger.info("Audit completed successfully") return 0 - + except AWSSSOAuditorError as e: - logger.error(f"AWS SSO Auditor Error: {e}") - print(f"❌ Error: {e}", file=sys.stderr) + logger.error("AWS SSO Auditor Error: %s", e) + output.error(f"AWS SSO Auditor Error: {e}") return 1 except Exception as e: - logger.error(f"Unexpected error: {e}") - print(f"❌ Unexpected error: {e}", file=sys.stderr) + logger.error("Unexpected error: %s", e) + output.error(f"Unexpected error: {e}") return 1 if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/cpk_lib_python_aws/aws_sso_auditor/config.py b/cpk_lib_python_aws/aws_sso_auditor/config.py index 7565285..57b095f 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/config.py +++ b/cpk_lib_python_aws/aws_sso_auditor/config.py @@ -1,49 +1,54 @@ +# -*- coding: utf-8 -*- """Configuration management for AWS SSO Auditor.""" import os from dataclasses import dataclass -from typing import Optional, List +from typing import List, Optional + from .exceptions import ConfigurationError @dataclass class Config: """Configuration for AWS SSO Auditor.""" - + # AWS Configuration aws_region: str = "us-east-1" aws_profile: Optional[str] = None timeout: int = 30 - - # Output Configuration + + # Output Configuration output_formats: List[str] = None output_directory: str = "." include_timestamp: bool = True - + # Behavior Configuration debug: bool = False quiet: bool = False - + def __post_init__(self): """Initialize configuration from environment variables.""" if self.output_formats is None: self.output_formats = ["json", "yaml"] - + # Override with environment variables - self.aws_region = os.getenv('AWS_REGION', self.aws_region) - self.aws_profile = os.getenv('AWS_PROFILE', self.aws_profile) - - if os.getenv('AWS_SSO_AUDITOR_DEBUG', '').lower() == 'true': + self.aws_region = os.getenv("AWS_REGION", self.aws_region) + self.aws_profile = os.getenv("AWS_PROFILE", self.aws_profile) + + self.output_directory = os.getenv("AWS_SSO_AUDITOR_OUTPUT_DIR", self.output_directory) + if os.getenv("AWS_SSO_AUDITOR_DEBUG", "").lower() == "true": self.debug = True - if os.getenv('AWS_SSO_AUDITOR_QUIET', '').lower() == 'true': + if os.getenv("AWS_SSO_AUDITOR_QUIET", "").lower() == "true": self.quiet = True - + def validate(self) -> None: """Validate configuration settings.""" valid_formats = ["json", "yaml", "both"] for fmt in self.output_formats: if fmt not in valid_formats: - raise ConfigurationError(f"Invalid output format: {fmt}. Must be one of {valid_formats}") - + raise ConfigurationError( + f"Invalid output format: {fmt}. Must be one of {valid_formats}" + ) + if self.timeout <= 0: - raise ConfigurationError("Timeout must be greater than 0") \ No newline at end of file + raise ConfigurationError("Timeout must be greater than 0") diff --git a/cpk_lib_python_aws/aws_sso_auditor/exceptions.py b/cpk_lib_python_aws/aws_sso_auditor/exceptions.py index 3566f9b..bc098f5 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/exceptions.py +++ b/cpk_lib_python_aws/aws_sso_auditor/exceptions.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Custom exceptions for AWS SSO Auditor.""" from ..shared.exceptions import AWSError @@ -5,24 +6,19 @@ class AWSSSOAuditorError(AWSError): """Base exception for AWS SSO Auditor.""" - pass -class PermissionError(AWSSSOAuditorError): +class InsufficientPermissionsError(AWSSSOAuditorError): """Raised when insufficient AWS permissions.""" - pass class ConfigurationError(AWSSSOAuditorError): """Raised when configuration is invalid.""" - pass class SSOInstanceNotFoundError(AWSSSOAuditorError): """Raised when no SSO instance is found.""" - pass class AWSClientError(AWSSSOAuditorError): """Raised when AWS client initialization fails.""" - pass \ No newline at end of file diff --git a/cpk_lib_python_aws/aws_sso_auditor/formatters.py b/cpk_lib_python_aws/aws_sso_auditor/formatters.py index 599f009..0411cb6 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/formatters.py +++ b/cpk_lib_python_aws/aws_sso_auditor/formatters.py @@ -1,11 +1,14 @@ +# -*- coding: utf-8 -*- """Output formatting utilities for AWS SSO Auditor.""" import json import logging import os from datetime import datetime -from typing import Dict, Any, List +from typing import Any, Dict, List + import yaml + from .config import Config logger = logging.getLogger(__name__) @@ -13,77 +16,96 @@ class OutputFormatter: """Handles output formatting and file operations.""" - - def __init__(self, config: Config): - """Initialize formatter with configuration.""" + + def __init__(self, config: Config, output_sink=None): + """Initialize formatter with configuration and optional output sink.""" self.config = config - + self.output_sink = output_sink + self._ensure_output_directory() + + def _ensure_output_directory(self) -> None: + """Ensure output directory exists.""" + try: + os.makedirs(self.config.output_directory, exist_ok=True) + logger.debug("Output directory ensured: %s", self.config.output_directory) + except Exception as e: + logger.error( + "Failed to create output directory %s: %s", self.config.output_directory, e + ) + raise + def save_results(self, data: Dict[str, Any], account_id: str) -> List[str]: """Save results to files based on configuration.""" saved_files = [] - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") if self.config.include_timestamp else "" - + + timestamp = ( + datetime.now().strftime("%Y%m%d_%H%M%S") if self.config.include_timestamp else "" + ) + for format_type in self.config.output_formats: if format_type in ["json", "both"]: json_file = self._save_json(data, account_id, timestamp) saved_files.append(json_file) - + if format_type in ["yaml", "both"]: yaml_file = self._save_yaml(data, account_id, timestamp) saved_files.append(yaml_file) - + return saved_files - + def _save_json(self, data: Dict[str, Any], account_id: str, timestamp: str) -> str: """Save data as JSON file.""" filename_parts = ["aws_sso_audit", account_id] if timestamp: filename_parts.append(timestamp) filename = "_".join(filename_parts) + ".json" - + filepath = os.path.join(self.config.output_directory, filename) - - with open(filepath, 'w') as f: + + with open(filepath, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, default=str) - - if not self.config.quiet: - print(f"Results saved to: {filepath}") + logger.info("JSON results saved to: %s", filepath) return filepath - + def _save_yaml(self, data: Dict[str, Any], account_id: str, timestamp: str) -> str: """Save data as YAML file.""" filename_parts = ["aws_sso_audit", account_id] if timestamp: filename_parts.append(timestamp) filename = "_".join(filename_parts) + ".yaml" - + filepath = os.path.join(self.config.output_directory, filename) - - with open(filepath, 'w') as f: + + with open(filepath, "w", encoding="utf-8") as f: yaml.dump(data, f, default_flow_style=False, sort_keys=False) - - if not self.config.quiet: - print(f"Results saved to: {filepath}") + logger.info("YAML results saved to: %s", filepath) return filepath - + def display_results(self, data: Dict[str, Any]) -> None: - """Display results to console.""" + """Display results to console using output sink if available.""" if self.config.quiet: return - - print("\n" + "=" * 80) - print("AWS SSO AUDIT RESULTS") - print("=" * 80) - print(json.dumps(data, indent=2, default=str)) - + + if self.output_sink: + # Use output sink for clean display + self.output_sink.separator() + self.output_sink.info("AWS SSO AUDIT RESULTS") + self.output_sink.separator() + self.output_sink.print_raw(json.dumps(data, indent=2, default=str)) + else: + # Fallback to direct print (backward compatibility) + print("\n" + "=" * 80) + print("AWS SSO AUDIT RESULTS") + print("=" * 80) + print(json.dumps(data, indent=2, default=str)) + def format_summary(self, data: Dict[str, Any]) -> str: """Format a summary of audit results.""" - summary = data.get('summary', {}) - metadata = data.get('metadata', {}) - + summary = data.get("summary", {}) + metadata = data.get("metadata", {}) + lines = [ "📊 AWS SSO Audit Summary", f"🆔 Account: {metadata.get('account_id', 'Unknown')}", @@ -92,5 +114,5 @@ def format_summary(self, data: Dict[str, Any]) -> str: f"🔐 Permission Sets: {summary.get('total_permission_sets', 0)}", f"🔗 Assignments: {summary.get('total_assignments', 0)}", ] - - return "\n".join(lines) \ No newline at end of file + + return "\n".join(lines) diff --git a/cpk_lib_python_aws/aws_sso_auditor/utils.py b/cpk_lib_python_aws/aws_sso_auditor/utils.py index ad09d07..004a42a 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/utils.py +++ b/cpk_lib_python_aws/aws_sso_auditor/utils.py @@ -1,6 +1,8 @@ +# -*- coding: utf-8 -*- """Utility functions for AWS SSO Auditor.""" -from typing import Dict, Any, List +from typing import Any, Dict, List + from ..shared.utils import validate_account_id as base_validate_account_id @@ -11,11 +13,11 @@ def validate_account_id(account_id: str) -> bool: def format_permission_set_arn(instance_arn: str, permission_set_name: str) -> str: """Format permission set ARN from instance ARN and name.""" - parts = instance_arn.split(':') + parts = instance_arn.split(":") if len(parts) >= 6: - region = parts[3] account = parts[4] - return f"arn:aws:sso:::{account}:permissionSet/{instance_arn.split('/')[-1]}/{permission_set_name}" + instance_id = instance_arn.split("/")[-1] + return f"arn:aws:sso:::{account}:permissionSet/{instance_id}/{permission_set_name}" return permission_set_name @@ -34,7 +36,7 @@ def clean_aws_response(response: Dict[str, Any]) -> Dict[str, Any]: """Clean AWS API response by removing metadata.""" cleaned = response.copy() # Remove common AWS metadata keys - metadata_keys = ['ResponseMetadata', 'NextToken', 'IsTruncated'] + metadata_keys = ["ResponseMetadata", "NextToken", "IsTruncated"] for key in metadata_keys: cleaned.pop(key, None) return cleaned @@ -42,6 +44,6 @@ def clean_aws_response(response: Dict[str, Any]) -> Dict[str, Any]: def format_timestamp(timestamp) -> str: """Format AWS timestamp for display.""" - if hasattr(timestamp, 'isoformat'): + if hasattr(timestamp, "isoformat"): return timestamp.isoformat() - return str(timestamp) \ No newline at end of file + return str(timestamp) diff --git a/cpk_lib_python_aws/shared/__init__.py b/cpk_lib_python_aws/shared/__init__.py index be056bf..6c37377 100644 --- a/cpk_lib_python_aws/shared/__init__.py +++ b/cpk_lib_python_aws/shared/__init__.py @@ -1,14 +1,18 @@ +# -*- coding: utf-8 -*- """Shared AWS utilities and base classes.""" from .aws_base import AWSBaseClient -from .exceptions import AWSError, CredentialsError, RegionError -from .utils import validate_account_id, get_aws_regions +from .exceptions import AWSError, CredentialsError, PermissionsError, RegionError +from .output_sink import OutputSink +from .utils import get_aws_regions, validate_account_id __all__ = [ "AWSBaseClient", - "AWSError", + "AWSError", "CredentialsError", "RegionError", + "PermissionsError", # This matches your exceptions.py "validate_account_id", "get_aws_regions", -] \ No newline at end of file + "OutputSink", +] diff --git a/cpk_lib_python_aws/shared/aws_base.py b/cpk_lib_python_aws/shared/aws_base.py index a61a4b9..b5c7740 100644 --- a/cpk_lib_python_aws/shared/aws_base.py +++ b/cpk_lib_python_aws/shared/aws_base.py @@ -1,9 +1,12 @@ +# -*- coding: utf-8 -*- """Base AWS client management shared across packages.""" -import boto3 import logging -from abc import ABC, abstractmethod -from typing import Dict, Any, Optional +from abc import ABC +from typing import Any, Dict, Optional + +import boto3 + from .exceptions import AWSError, CredentialsError logger = logging.getLogger(__name__) @@ -11,32 +14,32 @@ class AWSBaseClient(ABC): """Base class for AWS service clients with common functionality.""" - + def __init__(self, region: str = "us-east-1", profile: Optional[str] = None): """Initialize base AWS client.""" self.region = region self.profile = profile self.session = None self._initialize_session() - + def _initialize_session(self) -> None: """Initialize boto3 session with optional profile.""" try: - session_kwargs = {'region_name': self.region} + session_kwargs = {"region_name": self.region} if self.profile: - session_kwargs['profile_name'] = self.profile - + session_kwargs["profile_name"] = self.profile + self.session = boto3.Session(**session_kwargs) - logger.info(f"AWS session initialized for region: {self.region}") - + logger.info("AWS session initialized for region: %s", self.region) + except Exception as e: - logger.error(f"Failed to initialize AWS session: {e}") - raise CredentialsError(f"Failed to initialize AWS session: {e}") - + logger.error("Failed to initialize AWS session: %s", e) + raise CredentialsError(f"Failed to initialize AWS session: {e}") from e + def get_caller_identity(self) -> Dict[str, Any]: """Get current AWS caller identity.""" try: - sts_client = self.session.client('sts') + sts_client = self.session.client("sts") return sts_client.get_caller_identity() except Exception as e: - raise AWSError(f"Failed to get caller identity: {e}") \ No newline at end of file + raise AWSError(f"Failed to get caller identity: {e}") from e diff --git a/cpk_lib_python_aws/shared/exceptions.py b/cpk_lib_python_aws/shared/exceptions.py index 19e23e7..d03b659 100644 --- a/cpk_lib_python_aws/shared/exceptions.py +++ b/cpk_lib_python_aws/shared/exceptions.py @@ -1,21 +1,18 @@ +# -*- coding: utf-8 -*- """Shared AWS exceptions.""" class AWSError(Exception): """Base AWS error for all AWS-related exceptions.""" - pass class CredentialsError(AWSError): """Raised when AWS credentials are invalid or missing.""" - pass class RegionError(AWSError): """Raised when AWS region is invalid or unsupported.""" - pass class PermissionsError(AWSError): """Raised when insufficient AWS permissions.""" - pass \ No newline at end of file diff --git a/cpk_lib_python_aws/shared/output_sink.py b/cpk_lib_python_aws/shared/output_sink.py new file mode 100644 index 0000000..3f28a34 --- /dev/null +++ b/cpk_lib_python_aws/shared/output_sink.py @@ -0,0 +1,66 @@ +"""Shared output sink for managing console output across AWS tools.""" + +import sys +from typing import Optional + + +class OutputSink: + """Manages console output with different verbosity levels across AWS tools.""" + + def __init__(self, quiet: bool = False, debug: bool = False): + """Initialize output sink with verbosity settings.""" + self.quiet = quiet + self.debug = debug + + def info(self, message: str) -> None: + """Print informational message (suppressed in quiet mode).""" + if not self.quiet: + print(message) + + def success(self, message: str) -> None: + """Print success message (suppressed in quiet mode).""" + if not self.quiet: + print(f"✅ {message}") + + def warning(self, message: str) -> None: + """Print warning message (always shown unless quiet).""" + if not self.quiet: + print(f"⚠️ {message}") + + def error(self, message: str) -> None: + """Print error message (always shown, even in quiet mode).""" + print(f"❌ {message}", file=sys.stderr) + + def debug_info(self, message: str) -> None: + """Print debug message (only in debug mode).""" + if self.debug and not self.quiet: + print(f"🔍 {message}") + + def progress(self, message: str) -> None: + """Print progress message (only in debug mode).""" + if self.debug and not self.quiet: + print(f"⏳ {message}") + + def separator(self, char: str = "=", length: int = 80) -> None: + """Print separator line (suppressed in quiet mode).""" + if not self.quiet: + print(char * length) + + def print_raw(self, message: str, file=None) -> None: + """Print raw message without formatting (respects quiet mode for stdout).""" + if file == sys.stderr: + # Always print to stderr (errors) + print(message, file=file) + elif not self.quiet: + # Print to stdout only if not quiet + print(message, file=file) + + def metric(self, name: str, value: str) -> None: + """Print metric information (debug mode only).""" + if self.debug and not self.quiet: + print(f"📊 {name}: {value}") + + def timing(self, operation: str, duration: float) -> None: + """Print timing information (debug mode only).""" + if self.debug and not self.quiet: + print(f"⏱️ {operation}: {duration:.2f}s") \ No newline at end of file diff --git a/cpk_lib_python_aws/shared/utils.py b/cpk_lib_python_aws/shared/utils.py index 72f84dd..51f3088 100644 --- a/cpk_lib_python_aws/shared/utils.py +++ b/cpk_lib_python_aws/shared/utils.py @@ -1,36 +1,43 @@ +# -*- coding: utf-8 -*- """Shared AWS utility functions.""" - import re from typing import List +import boto3 + def validate_account_id(account_id: str) -> bool: """Validate AWS account ID format.""" if not account_id: return False - + # AWS account IDs are 12-digit numbers - pattern = r'^\d{12}$' + pattern = r"^\d{12}$" return bool(re.match(pattern, account_id)) def get_aws_regions() -> List[str]: """Get list of AWS regions.""" - import boto3 - try: - ec2 = boto3.client('ec2', region_name='us-east-1') + ec2 = boto3.client("ec2", region_name="us-east-1") response = ec2.describe_regions() - return [region['RegionName'] for region in response['Regions']] + return [region["RegionName"] for region in response["Regions"]] except Exception: # Fallback to common regions if API call fails return [ - 'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', - 'eu-west-1', 'eu-west-2', 'eu-central-1', - 'ap-southeast-1', 'ap-southeast-2', 'ap-northeast-1' + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "eu-west-1", + "eu-west-2", + "eu-central-1", + "ap-southeast-1", + "ap-southeast-2", + "ap-northeast-1", ] def format_arn(service: str, region: str, account_id: str, resource: str) -> str: """Format AWS ARN.""" - return f"arn:aws:{service}:{region}:{account_id}:{resource}" \ No newline at end of file + return f"arn:aws:{service}:{region}:{account_id}:{resource}" diff --git a/pyproject.toml b/pyproject.toml index 176aa1c..5e4745b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ license = {text = "GPL-3.0"} requires-python = ">=3.8.1" dependencies = [ "boto3>=1.26.0", - "botocore>=1.29.0", + "botocore>=1.29.0", "pyyaml>=6.0", "certifi>=2023.7.22", "urllib3>=1.25.4,<2.0.0", # Changed this line @@ -27,6 +27,7 @@ dev = [ "black>=23.0.0", "flake8>=6.0.0", # This should work with Python 3.8+ "mypy>=1.0.0", + "pylint>=2.17.0", "boto3-stubs[sso-admin,identitystore,organizations]>=1.26.0", ] @@ -56,4 +57,46 @@ target-version = ['py38'] python_version = "3.8" warn_return_any = true warn_unused_configs = true -disallow_untyped_defs = true \ No newline at end of file +disallow_untyped_defs = true + +# 🎯 PYLINT CONFIGURATION - This should fix your import issues +[tool.pylint.master] +# Add current directory to Python path for imports +init-hook = 'import sys; sys.path.append(".")' +# Don't cache results to avoid stale import issues +persistent = false + +[tool.pylint.format] +max-line-length = 100 + +[tool.pylint.messages_control] +disable = [ + "broad-exception-caught", + "too-many-arguments", + "too-many-locals", + "import-error", # This should fix your import issues + "no-name-in-module", # Also helps with module import issues + "wrong-import-order", # Less strict about import order + "ungrouped-imports", # Allow flexible import grouping + "too-few-public-methods", + "invalid-name", +] + +[tool.pylint.design] +max-statements = 60 +max-module-lines = 1200 +max-attributes = 15 # Allow more class attributes +max-public-methods = 25 # Allow more public methods + +[tool.pylint.typecheck] +# Don't complain about missing members in these modules +ignored-modules = [ + "boto3", + "botocore", + "yaml", + "cpk_lib_python_aws", +] + +[tool.pylint.imports] +# Allow relative imports within the package +allow-any-import-level = true From 36cda14df107109def2fcd7a2a19f05a7b4ea71b Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Mon, 7 Jul 2025 16:50:01 -0300 Subject: [PATCH 03/16] feat/aws-auditor: add precommit --- .github/workflows/precommit.yaml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/precommit.yaml diff --git a/.github/workflows/precommit.yaml b/.github/workflows/precommit.yaml new file mode 100644 index 0000000..89a2e86 --- /dev/null +++ b/.github/workflows/precommit.yaml @@ -0,0 +1,23 @@ +--- +name: precommit + +on: + pull_request: + push: + branches: [main, feat/aws-auditor] + +permissions: + actions: read + checks: write + contents: read + pull-requests: write + +jobs: + pre-commit: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: pre-commit/action@v3.0.1 From 8cd3c8c351dc57d05274d1df4a7a003214ff1fe0 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Mon, 7 Jul 2025 17:00:23 -0300 Subject: [PATCH 04/16] feat/aws-auditor: add precommit --- .gitignore | 2 +- cpk_lib_python_aws/aws_sso_auditor/README.md | 4 +-- cpk_lib_python_aws/shared/output_sink.py | 28 ++++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index b2ae735..0e26064 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ .spyproject .tox/ .venv +.vscode/ .webassets-cache /site ENV/ @@ -85,4 +86,3 @@ var/ venv.bak/ venv/ wheels/ -.vscode/ diff --git a/cpk_lib_python_aws/aws_sso_auditor/README.md b/cpk_lib_python_aws/aws_sso_auditor/README.md index e972caa..0318ccc 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/README.md +++ b/cpk_lib_python_aws/aws_sso_auditor/README.md @@ -35,7 +35,7 @@ A powerful CLI tool for auditing AWS Single Sign-On (SSO) configurations, analyz - AWS SSO enabled in your AWS organization - AWS credentials with appropriate SSO permissions: - `sso-admin:*` permissions - - `identitystore:*` permissions + - `identitystore:*` permissions - `organizations:ListAccounts` permission ### Install from Source @@ -224,7 +224,7 @@ $ aws-sso-auditor 123456789012 --output-format json --debug }, "sso_groups_summary": [ "Developers", - "Administrators", + "Administrators", "ReadOnlyUsers" ], "sso_permission_sets_summary": [ diff --git a/cpk_lib_python_aws/shared/output_sink.py b/cpk_lib_python_aws/shared/output_sink.py index 3f28a34..7edf25c 100644 --- a/cpk_lib_python_aws/shared/output_sink.py +++ b/cpk_lib_python_aws/shared/output_sink.py @@ -1,51 +1,51 @@ +# -*- coding: utf-8 -*- """Shared output sink for managing console output across AWS tools.""" import sys -from typing import Optional class OutputSink: """Manages console output with different verbosity levels across AWS tools.""" - + def __init__(self, quiet: bool = False, debug: bool = False): """Initialize output sink with verbosity settings.""" self.quiet = quiet self.debug = debug - + def info(self, message: str) -> None: """Print informational message (suppressed in quiet mode).""" if not self.quiet: print(message) - + def success(self, message: str) -> None: """Print success message (suppressed in quiet mode).""" if not self.quiet: print(f"✅ {message}") - + def warning(self, message: str) -> None: """Print warning message (always shown unless quiet).""" if not self.quiet: print(f"⚠️ {message}") - + def error(self, message: str) -> None: """Print error message (always shown, even in quiet mode).""" print(f"❌ {message}", file=sys.stderr) - + def debug_info(self, message: str) -> None: """Print debug message (only in debug mode).""" if self.debug and not self.quiet: print(f"🔍 {message}") - + def progress(self, message: str) -> None: """Print progress message (only in debug mode).""" if self.debug and not self.quiet: print(f"⏳ {message}") - + def separator(self, char: str = "=", length: int = 80) -> None: """Print separator line (suppressed in quiet mode).""" if not self.quiet: print(char * length) - + def print_raw(self, message: str, file=None) -> None: """Print raw message without formatting (respects quiet mode for stdout).""" if file == sys.stderr: @@ -54,13 +54,13 @@ def print_raw(self, message: str, file=None) -> None: elif not self.quiet: # Print to stdout only if not quiet print(message, file=file) - + def metric(self, name: str, value: str) -> None: """Print metric information (debug mode only).""" if self.debug and not self.quiet: print(f"📊 {name}: {value}") - + def timing(self, operation: str, duration: float) -> None: - """Print timing information (debug mode only).""" + """Print timing information (debug mode only).""" if self.debug and not self.quiet: - print(f"⏱️ {operation}: {duration:.2f}s") \ No newline at end of file + print(f"⏱️ {operation}: {duration:.2f}s") From 8e6df56498193d694f32bf875eb8c379be89b290 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Mon, 7 Jul 2025 20:34:06 -0300 Subject: [PATCH 05/16] feat/aws-auditor: cleanup --- cpk_lib_python_aws/aws_sso_auditor/auditor.py | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/cpk_lib_python_aws/aws_sso_auditor/auditor.py b/cpk_lib_python_aws/aws_sso_auditor/auditor.py index 5a6029e..9194d42 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/auditor.py +++ b/cpk_lib_python_aws/aws_sso_auditor/auditor.py @@ -13,6 +13,14 @@ logger = logging.getLogger(__name__) +class NullOutputSink: + """Null object pattern for output sink.""" + def progress(self, message): pass + def debug_info(self, message): pass + def warning(self, message): pass + def info(self, message): pass + def error(self, message): pass + class AWSSSOAuditor: """Main AWS SSO auditing class.""" @@ -22,11 +30,10 @@ def __init__(self, config: Config = None, output_sink=None): self.config.validate() # Initialize output sink - self.output_sink = output_sink + self.output_sink = output_sink or NullOutputSink() # Initialize AWS clients - if self.output_sink: - self.output_sink.progress("Initializing AWS clients...") + self.output_sink.progress("Initializing AWS clients...") self.aws_manager = AWSClientManager(self.config) # Store frequently used references @@ -40,8 +47,7 @@ def __init__(self, config: Config = None, output_sink=None): if self.config.debug: client_info = self.aws_manager.get_client_info() logger.debug("AWS Client Info: %s", client_info) - if self.output_sink: - self.output_sink.debug_info(f"Connected to SSO instance: {self.instance_arn}") + self.output_sink.debug_info(f"Connected to SSO instance: {self.instance_arn}") logger.info("AWS SSO Auditor initialized successfully") @@ -49,23 +55,19 @@ def __init__(self, config: Config = None, output_sink=None): def audit_account(self, account_id: str) -> Dict[str, Any]: """Perform complete audit of SSO access for the given account.""" logger.info("Starting AWS SSO audit for account: %s", account_id) - if self.output_sink: - self.output_sink.progress(f"Starting audit for account: {account_id}") + self.output_sink.progress(f"Starting audit for account: {account_id}") try: # Get all account assignments (only for permission sets assigned to this account) - if self.output_sink: - self.output_sink.progress("Retrieving account assignments...") + self.output_sink.progress("Retrieving account assignments...") assignments = self.get_all_account_assignments(account_id) - if self.output_sink: - self.output_sink.debug_info(f"Found {len(assignments)} assignments") + self.output_sink.debug_info(f"Found {len(assignments)} assignments") # Organize data groups_data = {} permission_sets_data = {} - if self.output_sink: - self.output_sink.progress("Processing assignments...") + self.output_sink.progress("Processing assignments...") for assignment in assignments: principal_type = assignment["PrincipalType"] principal_id = assignment["PrincipalId"] @@ -73,8 +75,7 @@ def audit_account(self, account_id: str) -> Dict[str, Any]: if principal_type == "GROUP": if principal_id not in groups_data: - if self.output_sink: - self.output_sink.progress(f"Processing group: {principal_id}") + self.output_sink.progress(f"Processing group: {principal_id}") group_details = self.get_group_details(principal_id) group_members = self.get_group_members(principal_id) groups_data[principal_id] = { @@ -96,10 +97,9 @@ def audit_account(self, account_id: str) -> Dict[str, Any]: # Collect permission set data (only for those with assignments to this account) if permission_set_arn not in permission_sets_data: - if self.output_sink: - self.output_sink.progress( - f"Processing permission set: {permission_set_arn}" - ) + self.output_sink.progress( + f"Processing permission set: {permission_set_arn}" + ) permission_set_details = self.get_permission_set_details(permission_set_arn) permission_set_policies = self.get_permission_set_policies(permission_set_arn) permission_sets_data[permission_set_arn] = { @@ -123,8 +123,7 @@ def audit_account(self, account_id: str) -> Dict[str, Any]: ps.get("Name", "Unknown") for ps in permission_sets_data.values() ] - if self.output_sink: - self.output_sink.progress("Finalizing audit results...") + self.output_sink.progress("Finalizing audit results...") # Build final result result = { From 47cb2750893010e7064481e31b9bf60de8ba0c6c Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Tue, 8 Jul 2025 09:30:47 -0300 Subject: [PATCH 06/16] feat/aws-auditor: linting fix --- cpk_lib_python_aws/aws_sso_auditor/auditor.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/cpk_lib_python_aws/aws_sso_auditor/auditor.py b/cpk_lib_python_aws/aws_sso_auditor/auditor.py index 9194d42..b3b8742 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/auditor.py +++ b/cpk_lib_python_aws/aws_sso_auditor/auditor.py @@ -15,11 +15,22 @@ class NullOutputSink: """Null object pattern for output sink.""" - def progress(self, message): pass - def debug_info(self, message): pass - def warning(self, message): pass - def info(self, message): pass - def error(self, message): pass + + def progress(self, message): + """Display progress message (no-op).""" + + def debug_info(self, message): + """Display debug information (no-op).""" + + def warning(self, message): + """Display warning message (no-op).""" + + def info(self, message): + """Display info message (no-op).""" + + def error(self, message): + """Display error message (no-op).""" + class AWSSSOAuditor: """Main AWS SSO auditing class.""" @@ -97,9 +108,7 @@ def audit_account(self, account_id: str) -> Dict[str, Any]: # Collect permission set data (only for those with assignments to this account) if permission_set_arn not in permission_sets_data: - self.output_sink.progress( - f"Processing permission set: {permission_set_arn}" - ) + self.output_sink.progress(f"Processing permission set: {permission_set_arn}") permission_set_details = self.get_permission_set_details(permission_set_arn) permission_set_policies = self.get_permission_set_policies(permission_set_arn) permission_sets_data[permission_set_arn] = { From 2d29fe7410e47c0dfc37e90d2861a5ad0d4dea8a Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Tue, 8 Jul 2025 10:26:51 -0300 Subject: [PATCH 07/16] feat/aws-auditor: linting fix --- pyproject.toml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5e4745b..2a23704 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,12 +43,6 @@ Documentation = "https://github.com/opencpk/cpk-lib-python-aws#readme" where = ["."] include = ["cpk_lib_python_aws*"] -[tool.pytest.ini_options] -testpaths = ["cpk_lib_python_aws/tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] - [tool.black] line-length = 100 target-version = ['py38'] From ec3a104e846957aee6589e5c61249672b3839f89 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Tue, 8 Jul 2025 11:06:15 -0300 Subject: [PATCH 08/16] feat/aws-auditor: no relative path --- __init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index 6482021..e5e7116 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """CPK Python AWS Library - Comprehensive AWS utilities and tools.""" -from .aws_sso_auditor import AWSSSOAuditor -from .aws_sso_auditor import Config as SSOConfig -from .shared import AWSBaseClient, AWSError, OutputSink +from cpk_lib_python_aws.aws_sso_auditor import AWSSSOAuditor +from cpk_lib_python_aws.aws_sso_auditor import Config as SSOConfig +from cpk_lib_python_aws.shared import AWSBaseClient, AWSError, OutputSink __version__ = "1.0.0" __all__ = [ From 5f7a8e31e650e4044076743fd296624aa77eba4c Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Tue, 8 Jul 2025 11:19:55 -0300 Subject: [PATCH 09/16] feat/aws-auditor: run tests --- .github/workflows/tests.yaml | 53 ++++++++++++ cpk_lib_python_aws/tests/__init__.py | 0 .../tests/aws_sso_auditor/__init__.py | 0 .../tests/aws_sso_auditor/test_config.py | 80 +++++++++++++++++++ pyproject.toml | 27 +++++++ 5 files changed, 160 insertions(+) create mode 100644 .github/workflows/tests.yaml create mode 100644 cpk_lib_python_aws/tests/__init__.py create mode 100644 cpk_lib_python_aws/tests/aws_sso_auditor/__init__.py create mode 100644 cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..59d66c2 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,53 @@ +--- +name: Tests + +on: + push: + branches: [main, develop, feat/aws-auditor] + pull_request: + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[test]" + + - name: Run tests + run: | + pytest cpk_lib_python_aws/tests/ -v + + + test-installation: + name: Test Package Installation + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install package + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Test CLI command + run: | + aws-sso-auditor --help \ No newline at end of file diff --git a/cpk_lib_python_aws/tests/__init__.py b/cpk_lib_python_aws/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/__init__.py b/cpk_lib_python_aws/tests/aws_sso_auditor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py b/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py new file mode 100644 index 0000000..664d7db --- /dev/null +++ b/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py @@ -0,0 +1,80 @@ +import os +import pytest +from cpk_lib_python_aws.aws_sso_auditor.config import Config +from cpk_lib_python_aws.aws_sso_auditor.exceptions import ConfigurationError + + +def test_default_config_values(): + """Test that default configuration values are set correctly.""" + config = Config() + assert config.aws_region == "us-east-1" + assert config.output_formats == ["json", "yaml"] + assert config.output_directory == "." + assert config.include_timestamp is True + assert config.debug is False + assert config.quiet is False + assert config.timeout == 30 + assert config.aws_profile is None + + +def test_config_validation_valid_formats(): + """Test that valid output formats pass validation.""" + config = Config(output_formats=["json"]) + config.validate() # Should not raise + + config = Config(output_formats=["yaml"]) + config.validate() # Should not raise + + config = Config(output_formats=["both"]) + config.validate() # Should not raise + + +def test_config_validation_invalid_format(): + """Test that invalid output formats raise ConfigurationError.""" + config = Config(output_formats=["invalid"]) + with pytest.raises(ConfigurationError, match="Invalid output format: invalid"): + config.validate() + + +def test_config_validation_invalid_timeout(): + """Test that invalid timeout raises ConfigurationError.""" + config = Config(timeout=0) + with pytest.raises(ConfigurationError, match="Timeout must be greater than 0"): + config.validate() + + config = Config(timeout=-5) + with pytest.raises(ConfigurationError, match="Timeout must be greater than 0"): + config.validate() + + +def test_environment_variable_override(): + """Test that environment variables override default values.""" + # Set environment variables + os.environ["AWS_REGION"] = "eu-west-1" + os.environ["AWS_SSO_AUDITOR_DEBUG"] = "true" + os.environ["AWS_SSO_AUDITOR_QUIET"] = "true" + + try: + config = Config() + assert config.aws_region == "eu-west-1" + assert config.debug is True + assert config.quiet is True + finally: + # Clean up environment variables + os.environ.pop("AWS_REGION", None) + os.environ.pop("AWS_SSO_AUDITOR_DEBUG", None) + os.environ.pop("AWS_SSO_AUDITOR_QUIET", None) + + +def test_constructor_overrides(): + """Test that constructor parameters override defaults.""" + config = Config( + aws_region="ap-southeast-1", + output_directory="/tmp/test", + debug=True, + timeout=60 + ) + assert config.aws_region == "ap-southeast-1" + assert config.output_directory == "/tmp/test" + assert config.debug is True + assert config.timeout == 60 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2a23704..6dc6d99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,11 @@ dependencies = [ ] [project.optional-dependencies] +test = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "pytest-mock>=3.10.0", +] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", @@ -94,3 +99,25 @@ ignored-modules = [ [tool.pylint.imports] # Allow relative imports within the package allow-any-import-level = true + + +[tool.pytest.ini_options] +testpaths = [ + "cpk_lib_python_aws/tests" +] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "--verbose", + "--cov=cpk_lib_python_aws", + "--cov-report=term-missing", + "--cov-report=html" +] +markers = [ + "unit: Unit tests with mocked responses", + "integration: Integration tests with real AWS API calls", + "slow: Slow running tests" +] \ No newline at end of file From 3a5f646c4f2fc87456c7d720e46294409ef43ad3 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Tue, 8 Jul 2025 11:29:29 -0300 Subject: [PATCH 10/16] feat/aws-auditor: run tests --- .../tests/aws_sso_auditor/test_auditor.py | 198 ++++++++++++++++++ .../tests/aws_sso_auditor/test_config.py | 17 +- 2 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py b/cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py new file mode 100644 index 0000000..5f7b151 --- /dev/null +++ b/cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py @@ -0,0 +1,198 @@ +import pytest +from unittest.mock import Mock, MagicMock, patch +from cpk_lib_python_aws.aws_sso_auditor.auditor import AWSSSOAuditor, NullOutputSink +from cpk_lib_python_aws.aws_sso_auditor.config import Config +from cpk_lib_python_aws.aws_sso_auditor.exceptions import AWSSSOAuditorError + + +class TestNullOutputSink: + """Test the NullOutputSink class.""" + + def test_null_output_sink_methods(self): + """Test that all NullOutputSink methods can be called without error.""" + sink = NullOutputSink() + + # All methods should return None and not raise exceptions + assert sink.progress("test message") is None + assert sink.debug_info("test message") is None + assert sink.warning("test message") is None + assert sink.info("test message") is None + assert sink.error("test message") is None + + """Test the AWSSSOAuditor class.""" + + @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + def test_auditor_initialization_with_default_config(self, mock_aws_manager): + """Test auditor initialization with default configuration.""" + # Mock the AWS client manager + mock_manager_instance = Mock() + mock_manager_instance.sso_admin_client = Mock() + mock_manager_instance.identitystore_client = Mock() + mock_manager_instance.organizations_client = Mock() + mock_manager_instance.identity_store_id = "d-123456789" + mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" + mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} + mock_aws_manager.return_value = mock_manager_instance + + auditor = AWSSSOAuditor() + + # Verify initialization + assert auditor.config is not None + assert isinstance(auditor.output_sink, NullOutputSink) + assert auditor.identity_store_id == "d-123456789" + assert auditor.instance_arn == "arn:aws:sso:::instance/ssoins-123456789" + + @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + def test_auditor_initialization_with_custom_config(self, mock_aws_manager): + """Test auditor initialization with custom configuration.""" + mock_manager_instance = Mock() + mock_manager_instance.sso_admin_client = Mock() + mock_manager_instance.identitystore_client = Mock() + mock_manager_instance.organizations_client = Mock() + mock_manager_instance.identity_store_id = "d-123456789" + mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" + mock_manager_instance.get_client_info.return_value = {"region": "us-west-2"} + mock_aws_manager.return_value = mock_manager_instance + + config = Config(aws_region="us-west-2", debug=True) + output_sink = Mock() + + auditor = AWSSSOAuditor(config, output_sink) + + assert auditor.config.aws_region == "us-west-2" + assert auditor.config.debug is True + assert auditor.output_sink == output_sink + + @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + def test_get_permission_sets_for_account_success(self, mock_aws_manager): + """Test successful retrieval of permission sets for account.""" + # Setup mocks + mock_manager_instance = Mock() + mock_sso_client = Mock() + mock_paginator = Mock() + + mock_sso_client.get_paginator.return_value = mock_paginator + mock_paginator.paginate.return_value = [ + {"PermissionSets": ["arn:aws:sso:::permissionSet/ps-123", "arn:aws:sso:::permissionSet/ps-456"]} + ] + + mock_manager_instance.sso_admin_client = mock_sso_client + mock_manager_instance.identitystore_client = Mock() + mock_manager_instance.organizations_client = Mock() + mock_manager_instance.identity_store_id = "d-123456789" + mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" + mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} + mock_aws_manager.return_value = mock_manager_instance + + auditor = AWSSSOAuditor() + result = auditor.get_permission_sets_for_account("123456789012") + + assert len(result) == 2 + assert "arn:aws:sso:::permissionSet/ps-123" in result + assert "arn:aws:sso:::permissionSet/ps-456" in result + + @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + def test_get_permission_sets_for_account_failure(self, mock_aws_manager): + """Test handling of errors when retrieving permission sets.""" + # Setup mocks to raise exception + mock_manager_instance = Mock() + mock_sso_client = Mock() + mock_sso_client.get_paginator.side_effect = Exception("AWS API Error") + + mock_manager_instance.sso_admin_client = mock_sso_client + mock_manager_instance.identitystore_client = Mock() + mock_manager_instance.organizations_client = Mock() + mock_manager_instance.identity_store_id = "d-123456789" + mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" + mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} + mock_aws_manager.return_value = mock_manager_instance + + auditor = AWSSSOAuditor() + result = auditor.get_permission_sets_for_account("123456789012") + + # Should return empty list on error + assert result == [] + + @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + def test_get_group_details_success(self, mock_aws_manager): + """Test successful retrieval of group details.""" + mock_manager_instance = Mock() + mock_identity_client = Mock() + + mock_identity_client.describe_group.return_value = { + "GroupId": "group-123", + "DisplayName": "Test Group", + "Description": "A test group" + } + + mock_manager_instance.sso_admin_client = Mock() + mock_manager_instance.identitystore_client = mock_identity_client + mock_manager_instance.organizations_client = Mock() + mock_manager_instance.identity_store_id = "d-123456789" + mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" + mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} + mock_aws_manager.return_value = mock_manager_instance + + auditor = AWSSSOAuditor() + result = auditor.get_group_details("group-123") + + assert result["GroupId"] == "group-123" + assert result["DisplayName"] == "Test Group" + assert result["Description"] == "A test group" + + @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + def test_get_group_details_failure(self, mock_aws_manager): + """Test handling of errors when retrieving group details.""" + mock_manager_instance = Mock() + mock_identity_client = Mock() + mock_identity_client.describe_group.side_effect = Exception("Group not found") + + mock_manager_instance.sso_admin_client = Mock() + mock_manager_instance.identitystore_client = mock_identity_client + mock_manager_instance.organizations_client = Mock() + mock_manager_instance.identity_store_id = "d-123456789" + mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" + mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} + mock_aws_manager.return_value = mock_manager_instance + + auditor = AWSSSOAuditor() + result = auditor.get_group_details("group-123") + + # Should return default values on error + assert result["GroupId"] == "group-123" + assert result["DisplayName"] == "Unknown" + assert result["Description"] == "" + + + @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + def test_audit_account_basic_flow(self, mock_aws_manager): + """Test basic audit_account flow with minimal data.""" + mock_manager_instance = Mock() + mock_sso_client = Mock() + mock_identity_client = Mock() + + # Mock get_all_account_assignments to return empty list + mock_manager_instance.sso_admin_client = mock_sso_client + mock_manager_instance.identitystore_client = mock_identity_client + mock_manager_instance.organizations_client = Mock() + mock_manager_instance.identity_store_id = "d-123456789" + mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" + mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} + mock_aws_manager.return_value = mock_manager_instance + + auditor = AWSSSOAuditor() + + # Mock the get_permission_sets_for_account to return empty list + auditor.get_permission_sets_for_account = Mock(return_value=[]) + + result = auditor.audit_account("123456789012") + + # Verify basic structure + assert "metadata" in result + assert "sso_groups" in result + assert "permission_sets" in result + assert "summary" in result + assert result["metadata"]["account_id"] == "123456789012" + assert result["summary"]["total_groups"] == 0 + assert result["summary"]["total_permission_sets"] == 0 + assert result["summary"]["total_assignments"] == 0 \ No newline at end of file diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py b/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py index 664d7db..293c5cd 100644 --- a/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py +++ b/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py @@ -20,13 +20,13 @@ def test_default_config_values(): def test_config_validation_valid_formats(): """Test that valid output formats pass validation.""" config = Config(output_formats=["json"]) - config.validate() # Should not raise + config.validate() config = Config(output_formats=["yaml"]) - config.validate() # Should not raise + config.validate() config = Config(output_formats=["both"]) - config.validate() # Should not raise + config.validate() def test_config_validation_invalid_format(): @@ -36,17 +36,6 @@ def test_config_validation_invalid_format(): config.validate() -def test_config_validation_invalid_timeout(): - """Test that invalid timeout raises ConfigurationError.""" - config = Config(timeout=0) - with pytest.raises(ConfigurationError, match="Timeout must be greater than 0"): - config.validate() - - config = Config(timeout=-5) - with pytest.raises(ConfigurationError, match="Timeout must be greater than 0"): - config.validate() - - def test_environment_variable_override(): """Test that environment variables override default values.""" # Set environment variables From 804ed21900a17ca3c4f5f9833e8eb8f43667fad7 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Tue, 8 Jul 2025 17:51:20 -0300 Subject: [PATCH 11/16] feat/aws-auditor --- .github/workflows/tests.yaml | 2 +- .../tests/aws_sso_auditor/test_auditor.py | 97 +++--- .../tests/aws_sso_auditor/test_cli.py | 313 ++++++++++++++++++ .../tests/aws_sso_auditor/test_config.py | 16 +- pyproject.toml | 2 +- 5 files changed, 375 insertions(+), 55 deletions(-) create mode 100644 cpk_lib_python_aws/tests/aws_sso_auditor/test_cli.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 59d66c2..595dba6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -50,4 +50,4 @@ jobs: - name: Test CLI command run: | - aws-sso-auditor --help \ No newline at end of file + aws-sso-auditor --help diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py b/cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py index 5f7b151..ac00d93 100644 --- a/cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py +++ b/cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py @@ -1,5 +1,8 @@ +# -*- coding: utf-8 -*- +from unittest.mock import MagicMock, Mock, patch + import pytest -from unittest.mock import Mock, MagicMock, patch + from cpk_lib_python_aws.aws_sso_auditor.auditor import AWSSSOAuditor, NullOutputSink from cpk_lib_python_aws.aws_sso_auditor.config import Config from cpk_lib_python_aws.aws_sso_auditor.exceptions import AWSSSOAuditorError @@ -7,11 +10,11 @@ class TestNullOutputSink: """Test the NullOutputSink class.""" - + def test_null_output_sink_methods(self): """Test that all NullOutputSink methods can be called without error.""" sink = NullOutputSink() - + # All methods should return None and not raise exceptions assert sink.progress("test message") is None assert sink.debug_info("test message") is None @@ -20,8 +23,8 @@ def test_null_output_sink_methods(self): assert sink.error("test message") is None """Test the AWSSSOAuditor class.""" - - @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + + @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") def test_auditor_initialization_with_default_config(self, mock_aws_manager): """Test auditor initialization with default configuration.""" # Mock the AWS client manager @@ -33,16 +36,16 @@ def test_auditor_initialization_with_default_config(self, mock_aws_manager): mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} mock_aws_manager.return_value = mock_manager_instance - + auditor = AWSSSOAuditor() - + # Verify initialization assert auditor.config is not None assert isinstance(auditor.output_sink, NullOutputSink) assert auditor.identity_store_id == "d-123456789" assert auditor.instance_arn == "arn:aws:sso:::instance/ssoins-123456789" - - @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + + @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") def test_auditor_initialization_with_custom_config(self, mock_aws_manager): """Test auditor initialization with custom configuration.""" mock_manager_instance = Mock() @@ -53,29 +56,34 @@ def test_auditor_initialization_with_custom_config(self, mock_aws_manager): mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" mock_manager_instance.get_client_info.return_value = {"region": "us-west-2"} mock_aws_manager.return_value = mock_manager_instance - + config = Config(aws_region="us-west-2", debug=True) output_sink = Mock() - + auditor = AWSSSOAuditor(config, output_sink) - + assert auditor.config.aws_region == "us-west-2" assert auditor.config.debug is True assert auditor.output_sink == output_sink - - @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + + @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") def test_get_permission_sets_for_account_success(self, mock_aws_manager): """Test successful retrieval of permission sets for account.""" # Setup mocks mock_manager_instance = Mock() mock_sso_client = Mock() mock_paginator = Mock() - + mock_sso_client.get_paginator.return_value = mock_paginator mock_paginator.paginate.return_value = [ - {"PermissionSets": ["arn:aws:sso:::permissionSet/ps-123", "arn:aws:sso:::permissionSet/ps-456"]} + { + "PermissionSets": [ + "arn:aws:sso:::permissionSet/ps-123", + "arn:aws:sso:::permissionSet/ps-456", + ] + } ] - + mock_manager_instance.sso_admin_client = mock_sso_client mock_manager_instance.identitystore_client = Mock() mock_manager_instance.organizations_client = Mock() @@ -83,22 +91,22 @@ def test_get_permission_sets_for_account_success(self, mock_aws_manager): mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} mock_aws_manager.return_value = mock_manager_instance - + auditor = AWSSSOAuditor() result = auditor.get_permission_sets_for_account("123456789012") - + assert len(result) == 2 assert "arn:aws:sso:::permissionSet/ps-123" in result assert "arn:aws:sso:::permissionSet/ps-456" in result - - @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + + @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") def test_get_permission_sets_for_account_failure(self, mock_aws_manager): """Test handling of errors when retrieving permission sets.""" # Setup mocks to raise exception mock_manager_instance = Mock() mock_sso_client = Mock() mock_sso_client.get_paginator.side_effect = Exception("AWS API Error") - + mock_manager_instance.sso_admin_client = mock_sso_client mock_manager_instance.identitystore_client = Mock() mock_manager_instance.organizations_client = Mock() @@ -106,25 +114,25 @@ def test_get_permission_sets_for_account_failure(self, mock_aws_manager): mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} mock_aws_manager.return_value = mock_manager_instance - + auditor = AWSSSOAuditor() result = auditor.get_permission_sets_for_account("123456789012") - + # Should return empty list on error assert result == [] - - @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + + @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") def test_get_group_details_success(self, mock_aws_manager): """Test successful retrieval of group details.""" mock_manager_instance = Mock() mock_identity_client = Mock() - + mock_identity_client.describe_group.return_value = { "GroupId": "group-123", "DisplayName": "Test Group", - "Description": "A test group" + "Description": "A test group", } - + mock_manager_instance.sso_admin_client = Mock() mock_manager_instance.identitystore_client = mock_identity_client mock_manager_instance.organizations_client = Mock() @@ -132,21 +140,21 @@ def test_get_group_details_success(self, mock_aws_manager): mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} mock_aws_manager.return_value = mock_manager_instance - + auditor = AWSSSOAuditor() result = auditor.get_group_details("group-123") - + assert result["GroupId"] == "group-123" assert result["DisplayName"] == "Test Group" assert result["Description"] == "A test group" - - @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + + @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") def test_get_group_details_failure(self, mock_aws_manager): """Test handling of errors when retrieving group details.""" mock_manager_instance = Mock() mock_identity_client = Mock() mock_identity_client.describe_group.side_effect = Exception("Group not found") - + mock_manager_instance.sso_admin_client = Mock() mock_manager_instance.identitystore_client = mock_identity_client mock_manager_instance.organizations_client = Mock() @@ -154,23 +162,22 @@ def test_get_group_details_failure(self, mock_aws_manager): mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} mock_aws_manager.return_value = mock_manager_instance - + auditor = AWSSSOAuditor() result = auditor.get_group_details("group-123") - + # Should return default values on error assert result["GroupId"] == "group-123" assert result["DisplayName"] == "Unknown" assert result["Description"] == "" - - - @patch('cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager') + + @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") def test_audit_account_basic_flow(self, mock_aws_manager): """Test basic audit_account flow with minimal data.""" mock_manager_instance = Mock() mock_sso_client = Mock() mock_identity_client = Mock() - + # Mock get_all_account_assignments to return empty list mock_manager_instance.sso_admin_client = mock_sso_client mock_manager_instance.identitystore_client = mock_identity_client @@ -179,14 +186,14 @@ def test_audit_account_basic_flow(self, mock_aws_manager): mock_manager_instance.instance_arn = "arn:aws:sso:::instance/ssoins-123456789" mock_manager_instance.get_client_info.return_value = {"region": "us-east-1"} mock_aws_manager.return_value = mock_manager_instance - + auditor = AWSSSOAuditor() - + # Mock the get_permission_sets_for_account to return empty list auditor.get_permission_sets_for_account = Mock(return_value=[]) - + result = auditor.audit_account("123456789012") - + # Verify basic structure assert "metadata" in result assert "sso_groups" in result @@ -195,4 +202,4 @@ def test_audit_account_basic_flow(self, mock_aws_manager): assert result["metadata"]["account_id"] == "123456789012" assert result["summary"]["total_groups"] == 0 assert result["summary"]["total_permission_sets"] == 0 - assert result["summary"]["total_assignments"] == 0 \ No newline at end of file + assert result["summary"]["total_assignments"] == 0 diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/test_cli.py b/cpk_lib_python_aws/tests/aws_sso_auditor/test_cli.py new file mode 100644 index 0000000..81cce35 --- /dev/null +++ b/cpk_lib_python_aws/tests/aws_sso_auditor/test_cli.py @@ -0,0 +1,313 @@ +import pytest +import argparse +import logging +from unittest.mock import Mock, patch, MagicMock +from io import StringIO +import sys + +from cpk_lib_python_aws.aws_sso_auditor.cli import ( + setup_logging, + create_parser, + main +) +from cpk_lib_python_aws.aws_sso_auditor.config import Config +from cpk_lib_python_aws.aws_sso_auditor.exceptions import AWSSSOAuditorError + + +class TestSetupLogging: + """Test the setup_logging function.""" + + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.logging.basicConfig') + def test_setup_logging_default(self, mock_basic_config): + """Test setup_logging with default parameters.""" + setup_logging() + + mock_basic_config.assert_called_once() + call_args = mock_basic_config.call_args + assert call_args[1]['level'] == logging.INFO + assert "%(asctime)s - %(name)s - %(levelname)s - %(message)s" in call_args[1]['format'] + + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.logging.basicConfig') + def test_setup_logging_debug(self, mock_basic_config): + """Test setup_logging with debug enabled.""" + setup_logging(debug=True) + + call_args = mock_basic_config.call_args + assert call_args[1]['level'] == logging.DEBUG + + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.logging.basicConfig') + def test_setup_logging_quiet(self, mock_basic_config): + """Test setup_logging with quiet enabled.""" + setup_logging(quiet=True) + + call_args = mock_basic_config.call_args + assert call_args[1]['level'] == logging.ERROR + + +class TestCreateParser: + """Test the create_parser function.""" + + def test_create_parser_basic(self): + """Test that parser is created with correct structure.""" + parser = create_parser() + + assert isinstance(parser, argparse.ArgumentParser) + assert parser.prog == "aws-sso-auditor" + + def test_parser_required_arguments(self): + """Test parsing with required arguments only.""" + parser = create_parser() + args = parser.parse_args(["123456789012"]) + + assert args.account_id == "123456789012" + assert args.output_format == "both" + assert args.output_dir == "./aws-sso-audit-results" + assert args.aws_region == "us-east-1" + assert args.aws_profile is None + assert args.quiet is False + assert args.debug is False + assert args.no_timestamp is False + + def test_parser_all_arguments(self): + """Test parsing with all arguments provided.""" + parser = create_parser() + args = parser.parse_args([ + "123456789012", + "--output-format", "json", + "--output-dir", "/tmp/results", + "--aws-region", "us-west-2", + "--aws-profile", "my-profile", + "--quiet", + "--debug", + "--no-timestamp" + ]) + + assert args.account_id == "123456789012" + assert args.output_format == "json" + assert args.output_dir == "/tmp/results" + assert args.aws_region == "us-west-2" + assert args.aws_profile == "my-profile" + assert args.quiet is True + assert args.debug is True + assert args.no_timestamp is True + + def test_parser_invalid_output_format(self): + """Test parser rejects invalid output format.""" + parser = create_parser() + + with pytest.raises(SystemExit): + parser.parse_args(["123456789012", "--output-format", "invalid"]) + + def test_parser_missing_account_id(self): + """Test parser requires account_id.""" + parser = create_parser() + + with pytest.raises(SystemExit): + parser.parse_args([]) + + +class TestMain: + """Test the main function.""" + + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputFormatter') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging') + def test_main_success(self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter): + """Test successful main execution.""" + # Setup mocks + mock_output_instance = Mock() + mock_output_sink.return_value = mock_output_instance + + mock_auditor_instance = Mock() + mock_auditor_instance.audit_account.return_value = { + "metadata": {"account_id": "123456789012"}, + "summary": {"total_groups": 5, "total_permission_sets": 3} + } + mock_auditor.return_value = mock_auditor_instance + + mock_formatter_instance = Mock() + mock_formatter_instance.save_results.return_value = ["file1.json", "file2.yaml"] + mock_formatter.return_value = mock_formatter_instance + + # Run main + result = main(["123456789012"]) + + # Verify success + assert result == 0 + mock_setup_logging.assert_called_once() + mock_auditor_instance.audit_account.assert_called_once_with("123456789012") + mock_formatter_instance.save_results.assert_called_once() + mock_formatter_instance.display_results.assert_called_once() + + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputFormatter') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging') + def test_main_with_custom_args(self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter): + """Test main with custom arguments.""" + # Setup mocks + mock_output_instance = Mock() + mock_output_sink.return_value = mock_output_instance + + mock_auditor_instance = Mock() + mock_auditor_instance.audit_account.return_value = {"metadata": {"account_id": "123456789012"}, "summary": {}} + mock_auditor.return_value = mock_auditor_instance + + mock_formatter_instance = Mock() + mock_formatter_instance.save_results.return_value = ["file1.json"] + mock_formatter.return_value = mock_formatter_instance + + # Run main with custom args + result = main([ + "123456789012", + "--output-format", "json", + "--aws-region", "eu-west-1", + "--debug" + ]) + + # Verify + assert result == 0 + mock_setup_logging.assert_called_once_with(True, False) # debug=True, quiet=False + + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging') + def test_main_aws_sso_auditor_error(self, mock_setup_logging, mock_output_sink, mock_auditor): + """Test main handling AWSSSOAuditorError.""" + mock_output_instance = Mock() + mock_output_sink.return_value = mock_output_instance + + mock_auditor.side_effect = AWSSSOAuditorError("Test error") + + result = main(["123456789012"]) + + assert result == 1 + mock_output_instance.error.assert_called_with("AWS SSO Auditor Error: Test error") + + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging') + def test_main_unexpected_error(self, mock_setup_logging, mock_output_sink, mock_auditor): + """Test main handling unexpected errors.""" + mock_output_instance = Mock() + mock_output_sink.return_value = mock_output_instance + + mock_auditor.side_effect = Exception("Unexpected error") + + result = main(["123456789012"]) + + assert result == 1 + mock_output_instance.error.assert_called_with("Unexpected error: Unexpected error") + + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputFormatter') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') + @patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging') + def test_main_config_creation(self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter): + """Test that Config is created correctly from CLI args.""" + mock_output_instance = Mock() + mock_output_sink.return_value = mock_output_instance + + mock_auditor_instance = Mock() + mock_auditor_instance.audit_account.return_value = {"metadata": {"account_id": "123456789012"}, "summary": {}} + mock_auditor.return_value = mock_auditor_instance + + mock_formatter_instance = Mock() + mock_formatter_instance.save_results.return_value = ["file1.json"] + mock_formatter.return_value = mock_formatter_instance + + result = main([ + "123456789012", + "--output-format", "yaml", + "--output-dir", "/custom/dir", + "--aws-region", "ap-southeast-1", + "--aws-profile", "test-profile", + "--no-timestamp", + "--quiet" + ]) + + # Verify Config was created with correct parameters + assert result == 0 + + # Check that auditor was called with a config + call_args = mock_auditor.call_args + config = call_args[0][0] # First argument should be config + + assert isinstance(config, Config) + assert config.aws_region == "ap-southeast-1" + assert config.aws_profile == "test-profile" + assert config.output_formats == ["yaml"] + assert config.output_directory == "/custom/dir" + assert config.include_timestamp is False # no-timestamp flag + assert config.quiet is True + + def test_main_both_output_format(self): + """Test that 'both' output format expands to json and yaml.""" + with patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging'), \ + patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') as mock_output_sink, \ + patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') as mock_auditor, \ + patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputFormatter') as mock_formatter: + + mock_output_instance = Mock() + mock_output_sink.return_value = mock_output_instance + + mock_auditor_instance = Mock() + mock_auditor_instance.audit_account.return_value = {"metadata": {"account_id": "123456789012"}, "summary": {}} + mock_auditor.return_value = mock_auditor_instance + + mock_formatter_instance = Mock() + mock_formatter_instance.save_results.return_value = ["file1.json", "file2.yaml"] + mock_formatter.return_value = mock_formatter_instance + + result = main(["123456789012", "--output-format", "both"]) + + assert result == 0 + + # Verify config has both formats + call_args = mock_auditor.call_args + config = call_args[0][0] + assert set(config.output_formats) == {"json", "yaml"} + + def test_main_invalid_args(self): + """Test main with invalid arguments.""" + # This should exit due to argparse error + with pytest.raises(SystemExit): + main(["123456789012", "--invalid-arg"]) + + +# Integration-style test +class TestCLIIntegration: + """Integration-style tests for CLI components.""" + + def test_config_from_parser_args(self): + """Test creating Config from parsed arguments.""" + parser = create_parser() + args = parser.parse_args([ + "123456789012", + "--output-format", "json", + "--output-dir", "/test/dir", + "--aws-region", "us-west-2", + "--debug", + "--no-timestamp" + ]) + + # This mimics what main() does with the args + output_formats = [args.output_format] if args.output_format != "both" else ["json", "yaml"] + + config = Config( + aws_region=args.aws_region, + aws_profile=args.aws_profile, + output_formats=output_formats, + output_directory=args.output_dir, + include_timestamp=not args.no_timestamp, + debug=args.debug, + quiet=args.quiet, + ) + + assert config.aws_region == "us-west-2" + assert config.output_formats == ["json"] + assert config.output_directory == "/test/dir" + assert config.include_timestamp is False + assert config.debug is True + assert config.quiet is False \ No newline at end of file diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py b/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py index 293c5cd..1243c73 100644 --- a/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py +++ b/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py @@ -1,5 +1,8 @@ +# -*- coding: utf-8 -*- import os + import pytest + from cpk_lib_python_aws.aws_sso_auditor.config import Config from cpk_lib_python_aws.aws_sso_auditor.exceptions import ConfigurationError @@ -21,10 +24,10 @@ def test_config_validation_valid_formats(): """Test that valid output formats pass validation.""" config = Config(output_formats=["json"]) config.validate() - + config = Config(output_formats=["yaml"]) config.validate() - + config = Config(output_formats=["both"]) config.validate() @@ -42,7 +45,7 @@ def test_environment_variable_override(): os.environ["AWS_REGION"] = "eu-west-1" os.environ["AWS_SSO_AUDITOR_DEBUG"] = "true" os.environ["AWS_SSO_AUDITOR_QUIET"] = "true" - + try: config = Config() assert config.aws_region == "eu-west-1" @@ -58,12 +61,9 @@ def test_environment_variable_override(): def test_constructor_overrides(): """Test that constructor parameters override defaults.""" config = Config( - aws_region="ap-southeast-1", - output_directory="/tmp/test", - debug=True, - timeout=60 + aws_region="ap-southeast-1", output_directory="/tmp/test", debug=True, timeout=60 ) assert config.aws_region == "ap-southeast-1" assert config.output_directory == "/tmp/test" assert config.debug is True - assert config.timeout == 60 \ No newline at end of file + assert config.timeout == 60 diff --git a/pyproject.toml b/pyproject.toml index 6dc6d99..e450c2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,4 +120,4 @@ markers = [ "unit: Unit tests with mocked responses", "integration: Integration tests with real AWS API calls", "slow: Slow running tests" -] \ No newline at end of file +] From ce8b841d3743a30830fd9ab4d3edfa78d5300880 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Tue, 8 Jul 2025 17:56:20 -0300 Subject: [PATCH 12/16] feat/aws-access-auditor --- .github/workflows/tests.yaml | 2 +- __init__.py | 4 +- cpk_lib_python_aws/__init__.py | 4 +- .../README.md | 113 ++++++++++-------- .../__init__.py | 0 .../__main__.py | 0 .../auditor.py | 0 .../aws_client_manager.py | 0 .../cli.py | 4 +- .../config.py | 6 +- .../exceptions.py | 0 .../formatters.py | 0 .../utils.py | 0 .../__init__.py | 0 .../test_auditor.py | 20 ++-- .../test_cli.py | 60 +++++----- .../test_config.py | 12 +- pyproject.toml | 2 +- 18 files changed, 119 insertions(+), 108 deletions(-) rename cpk_lib_python_aws/{aws_sso_auditor => aws_access_auditor}/README.md (70%) rename cpk_lib_python_aws/{aws_sso_auditor => aws_access_auditor}/__init__.py (100%) rename cpk_lib_python_aws/{aws_sso_auditor => aws_access_auditor}/__main__.py (100%) rename cpk_lib_python_aws/{aws_sso_auditor => aws_access_auditor}/auditor.py (100%) rename cpk_lib_python_aws/{aws_sso_auditor => aws_access_auditor}/aws_client_manager.py (100%) rename cpk_lib_python_aws/{aws_sso_auditor => aws_access_auditor}/cli.py (97%) rename cpk_lib_python_aws/{aws_sso_auditor => aws_access_auditor}/config.py (86%) rename cpk_lib_python_aws/{aws_sso_auditor => aws_access_auditor}/exceptions.py (100%) rename cpk_lib_python_aws/{aws_sso_auditor => aws_access_auditor}/formatters.py (100%) rename cpk_lib_python_aws/{aws_sso_auditor => aws_access_auditor}/utils.py (100%) rename cpk_lib_python_aws/tests/{aws_sso_auditor => aws_access_auditor}/__init__.py (100%) rename cpk_lib_python_aws/tests/{aws_sso_auditor => aws_access_auditor}/test_auditor.py (91%) rename cpk_lib_python_aws/tests/{aws_sso_auditor => aws_access_auditor}/test_cli.py (82%) rename cpk_lib_python_aws/tests/{aws_sso_auditor => aws_access_auditor}/test_config.py (83%) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 595dba6..10d86d3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -50,4 +50,4 @@ jobs: - name: Test CLI command run: | - aws-sso-auditor --help + aws-access-auditor --help diff --git a/__init__.py b/__init__.py index e5e7116..4cdcfeb 100644 --- a/__init__.py +++ b/__init__.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- """CPK Python AWS Library - Comprehensive AWS utilities and tools.""" -from cpk_lib_python_aws.aws_sso_auditor import AWSSSOAuditor -from cpk_lib_python_aws.aws_sso_auditor import Config as SSOConfig +from cpk_lib_python_aws.aws_access_auditor import AWSSSOAuditor +from cpk_lib_python_aws.aws_access_auditor import Config as SSOConfig from cpk_lib_python_aws.shared import AWSBaseClient, AWSError, OutputSink __version__ = "1.0.0" diff --git a/cpk_lib_python_aws/__init__.py b/cpk_lib_python_aws/__init__.py index 53ccb31..e9f69eb 100644 --- a/cpk_lib_python_aws/__init__.py +++ b/cpk_lib_python_aws/__init__.py @@ -2,8 +2,8 @@ """CPK Python AWS Library - Comprehensive AWS utilities and tools.""" # Import SSO Auditor components -from .aws_sso_auditor import AWSSSOAuditor -from .aws_sso_auditor import Config as SSOConfig +from .aws_access_auditor import AWSSSOAuditor +from .aws_access_auditor import Config as SSOConfig # Import shared components from .shared import AWSBaseClient, AWSError, OutputSink diff --git a/cpk_lib_python_aws/aws_sso_auditor/README.md b/cpk_lib_python_aws/aws_access_auditor/README.md similarity index 70% rename from cpk_lib_python_aws/aws_sso_auditor/README.md rename to cpk_lib_python_aws/aws_access_auditor/README.md index 0318ccc..0dd484d 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/README.md +++ b/cpk_lib_python_aws/aws_access_auditor/README.md @@ -47,7 +47,7 @@ pip install git+https://github.com/opencpk/cpk-lib-python-aws.git@main ### Verify Installation ```bash -aws-sso-auditor --help +aws-access-auditor --help ``` ## 🎯 Quick Start @@ -65,13 +65,13 @@ export AWS_REGION=us-east-1 ### 2. Run Your First Audit ```bash -aws-sso-auditor 123456789012 --output-format json +aws-access-auditor 123456789012 --output-format json ``` ### 3. Generate Comprehensive Reports ```bash -aws-sso-auditor 123456789012 --output-format both --output-dir ./audit-reports +aws-access-auditor 123456789012 --output-format both --output-dir ./audit-reports ``` ## 📖 Usage Examples @@ -79,91 +79,102 @@ aws-sso-auditor 123456789012 --output-format both --output-dir ./audit-reports ### 🔍 Basic Auditing #### Audit a specific AWS account: + ```bash -aws-sso-auditor 123456789012 +aws-access-auditor 123456789012 ``` #### Audit with JSON output only: + ```bash -aws-sso-auditor 123456789012 --output-format json +aws-access-auditor 123456789012 --output-format json ``` #### Audit with YAML output only: + ```bash -aws-sso-auditor 123456789012 --output-format yaml +aws-access-auditor 123456789012 --output-format yaml ``` #### Audit with both formats: + ```bash -aws-sso-auditor 123456789012 --output-format both +aws-access-auditor 123456789012 --output-format both ``` ### 📁 Output Management #### Custom output directory: + ```bash -aws-sso-auditor 123456789012 --output-dir ./my-audit-reports +aws-access-auditor 123456789012 --output-dir ./my-audit-reports ``` #### Disable timestamps in filenames: + ```bash -aws-sso-auditor 123456789012 --no-timestamp +aws-access-auditor 123456789012 --no-timestamp ``` ### 🌍 Region and Profile Configuration #### Specify AWS region: + ```bash -aws-sso-auditor 123456789012 --aws-region us-west-2 +aws-access-auditor 123456789012 --aws-region us-west-2 ``` #### Use specific AWS profile: + ```bash -aws-sso-auditor 123456789012 --aws-profile sso-admin-profile +aws-access-auditor 123456789012 --aws-profile sso-admin-profile ``` ### 🔇 Quiet and Debug Modes #### Quiet mode (no console output): + ```bash -aws-sso-auditor 123456789012 --quiet +aws-access-auditor 123456789012 --quiet ``` #### Debug mode (detailed logging): + ```bash -aws-sso-auditor 123456789012 --debug +aws-access-auditor 123456789012 --debug ``` ### 🐛 Debug & Help #### Show help: + ```bash -aws-sso-auditor --help +aws-access-auditor --help ``` ## 📚 Command Reference -| Argument | Description | Default | -|----------|-------------|---------| -| `account_id` | AWS Account ID to audit (required) | - | -| `--output-format` | Output format: `json`, `yaml`, or `both` | `both` | -| `--output-dir` | Output directory path | `./aws-sso-audit-results` | -| `--aws-region` | AWS region | `us-east-1` | -| `--aws-profile` | AWS profile to use | None | -| `--quiet` `-q` | Suppress console output and logging, only save files | `False` | -| `--debug` | Enable debug logging | `False` | -| `--no-timestamp` | Don't include timestamp in filenames | `False` | -| `--help` | Show help message | - | +| Argument | Description | Default | +| ----------------- | ---------------------------------------------------- | ------------------------- | +| `account_id` | AWS Account ID to audit (required) | - | +| `--output-format` | Output format: `json`, `yaml`, or `both` | `both` | +| `--output-dir` | Output directory path | `./aws-sso-audit-results` | +| `--aws-region` | AWS region | `us-east-1` | +| `--aws-profile` | AWS profile to use | None | +| `--quiet` `-q` | Suppress console output and logging, only save files | `False` | +| `--debug` | Enable debug logging | `False` | +| `--no-timestamp` | Don't include timestamp in filenames | `False` | +| `--help` | Show help message | - | ## 🌍 Environment Variables -| Variable | Description | Example | -|----------|-------------|---------| -| `AWS_REGION` | AWS region | `us-east-1` | -| `AWS_PROFILE` | AWS profile to use | `sso-admin` | -| `AWS_SSO_AUDITOR_OUTPUT_DIR` | Default output directory | `./audit-reports` | -| `AWS_SSO_AUDITOR_DEBUG` | Enable debug mode | `true` | -| `AWS_SSO_AUDITOR_QUIET` | Enable quiet mode | `true` | +| Variable | Description | Example | +| ------------------------------- | ------------------------ | ----------------- | +| `AWS_REGION` | AWS region | `us-east-1` | +| `AWS_PROFILE` | AWS profile to use | `sso-admin` | +| `AWS_ACCESS_AUDITOR_OUTPUT_DIR` | Default output directory | `./audit-reports` | +| `AWS_ACCESS_AUDITOR_DEBUG` | Enable debug mode | `true` | +| `AWS_ACCESS_AUDITOR_QUIET` | Enable quiet mode | `true` | ### Setting Environment Variables @@ -172,11 +183,11 @@ aws-sso-auditor --help export AWS_REGION=us-east-1 # Advanced configuration -export AWS_SSO_AUDITOR_OUTPUT_DIR=./audit-reports -export AWS_SSO_AUDITOR_DEBUG=true +export AWS_ACCESS_AUDITOR_OUTPUT_DIR=./audit-reports +export AWS_ACCESS_AUDITOR_DEBUG=true # Then use shorter commands -aws-sso-auditor 123456789012 +aws-access-auditor 123456789012 ``` ## 🎨 Sample Outputs @@ -184,10 +195,11 @@ aws-sso-auditor 123456789012 ### 📊 Successful Audit Output ```bash -$ aws-sso-auditor 123456789012 --output-format json --debug +$ aws-access-auditor 123456789012 --output-format json --debug ``` **Console Output:** + ``` ⏳ Initializing AWS clients... ⏳ Starting audit for account: 123456789012 @@ -222,11 +234,7 @@ $ aws-sso-auditor 123456789012 --output-format json --debug "output_formats": ["json"] } }, - "sso_groups_summary": [ - "Developers", - "Administrators", - "ReadOnlyUsers" - ], + "sso_groups_summary": ["Developers", "Administrators", "ReadOnlyUsers"], "sso_permission_sets_summary": [ "AdministratorAccess", "DeveloperAccess", @@ -290,9 +298,7 @@ $ aws-sso-auditor 123456789012 --output-format json --debug ] } }, - "AssignedGroups": [ - "90967fb4-d4e1-7019-c6a2-3b4d2a8c7e5f" - ] + "AssignedGroups": ["90967fb4-d4e1-7019-c6a2-3b4d2a8c7e5f"] } ], "summary": { @@ -306,19 +312,25 @@ $ aws-sso-auditor 123456789012 --output-format json --debug ### ❌ Error Output Examples #### Account not found: + ```bash -$ aws-sso-auditor 999999999999 +$ aws-access-auditor 999999999999 ``` + **Output:** + ``` ❌ AWS SSO Auditor Error: No permission sets found for account 999999999999 ``` #### Invalid credentials: + ```bash -$ aws-sso-auditor 123456789012 +$ aws-access-auditor 123456789012 ``` + **Output:** + ``` ❌ Unexpected error: Unable to locate credentials ``` @@ -326,17 +338,17 @@ $ aws-sso-auditor 123456789012 ### 🔇 Quiet Mode Output ```bash -$ aws-sso-auditor 123456789012 --quiet +$ aws-access-auditor 123456789012 --quiet ``` + **Output:** (No console output, only files generated) ### 🐛 Debug Mode Output ```bash -$ aws-sso-auditor 123456789012 --debug +$ aws-access-auditor 123456789012 --debug ``` - ## 🐍 Python Usage If you prefer to use this tool as a Python library in your scripts: @@ -344,7 +356,7 @@ If you prefer to use this tool as a Python library in your scripts: ### Programmatic Usage ```python -c " -from cpk_lib_python_aws.aws_sso_auditor import AWSSSOAuditor, Config, OutputFormatter +from cpk_lib_python_aws.aws_access_auditor import AWSSSOAuditor, Config, OutputFormatter from cpk_lib_python_aws.shared import OutputSink config = Config( @@ -362,7 +374,6 @@ formatter.save_results(results, '123456789012') " ``` - ### Without Timestamps ``` diff --git a/cpk_lib_python_aws/aws_sso_auditor/__init__.py b/cpk_lib_python_aws/aws_access_auditor/__init__.py similarity index 100% rename from cpk_lib_python_aws/aws_sso_auditor/__init__.py rename to cpk_lib_python_aws/aws_access_auditor/__init__.py diff --git a/cpk_lib_python_aws/aws_sso_auditor/__main__.py b/cpk_lib_python_aws/aws_access_auditor/__main__.py similarity index 100% rename from cpk_lib_python_aws/aws_sso_auditor/__main__.py rename to cpk_lib_python_aws/aws_access_auditor/__main__.py diff --git a/cpk_lib_python_aws/aws_sso_auditor/auditor.py b/cpk_lib_python_aws/aws_access_auditor/auditor.py similarity index 100% rename from cpk_lib_python_aws/aws_sso_auditor/auditor.py rename to cpk_lib_python_aws/aws_access_auditor/auditor.py diff --git a/cpk_lib_python_aws/aws_sso_auditor/aws_client_manager.py b/cpk_lib_python_aws/aws_access_auditor/aws_client_manager.py similarity index 100% rename from cpk_lib_python_aws/aws_sso_auditor/aws_client_manager.py rename to cpk_lib_python_aws/aws_access_auditor/aws_client_manager.py diff --git a/cpk_lib_python_aws/aws_sso_auditor/cli.py b/cpk_lib_python_aws/aws_access_auditor/cli.py similarity index 97% rename from cpk_lib_python_aws/aws_sso_auditor/cli.py rename to cpk_lib_python_aws/aws_access_auditor/cli.py index 852f0a8..daf8a45 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/cli.py +++ b/cpk_lib_python_aws/aws_access_auditor/cli.py @@ -25,7 +25,7 @@ def setup_logging(debug: bool = False, quiet: bool = False) -> None: logging.basicConfig( level=level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - handlers=[logging.FileHandler("aws_sso_auditor.log"), logging.StreamHandler()], + handlers=[logging.FileHandler("aws_access_auditor.log"), logging.StreamHandler()], ) @@ -33,7 +33,7 @@ def create_parser() -> argparse.ArgumentParser: """Create CLI argument parser.""" parser = argparse.ArgumentParser( description="Audit AWS SSO Groups and Permission Sets for an account", - prog="aws-sso-auditor", + prog="aws-access-auditor", ) parser.add_argument("account_id", help="AWS Account ID to audit") diff --git a/cpk_lib_python_aws/aws_sso_auditor/config.py b/cpk_lib_python_aws/aws_access_auditor/config.py similarity index 86% rename from cpk_lib_python_aws/aws_sso_auditor/config.py rename to cpk_lib_python_aws/aws_access_auditor/config.py index 57b095f..aba5dbb 100644 --- a/cpk_lib_python_aws/aws_sso_auditor/config.py +++ b/cpk_lib_python_aws/aws_access_auditor/config.py @@ -35,10 +35,10 @@ def __post_init__(self): self.aws_region = os.getenv("AWS_REGION", self.aws_region) self.aws_profile = os.getenv("AWS_PROFILE", self.aws_profile) - self.output_directory = os.getenv("AWS_SSO_AUDITOR_OUTPUT_DIR", self.output_directory) - if os.getenv("AWS_SSO_AUDITOR_DEBUG", "").lower() == "true": + self.output_directory = os.getenv("AWS_ACCESS_AUDITOR_OUTPUT_DIR", self.output_directory) + if os.getenv("AWS_ACCESS_AUDITOR_DEBUG", "").lower() == "true": self.debug = True - if os.getenv("AWS_SSO_AUDITOR_QUIET", "").lower() == "true": + if os.getenv("AWS_ACCESS_AUDITOR_QUIET", "").lower() == "true": self.quiet = True def validate(self) -> None: diff --git a/cpk_lib_python_aws/aws_sso_auditor/exceptions.py b/cpk_lib_python_aws/aws_access_auditor/exceptions.py similarity index 100% rename from cpk_lib_python_aws/aws_sso_auditor/exceptions.py rename to cpk_lib_python_aws/aws_access_auditor/exceptions.py diff --git a/cpk_lib_python_aws/aws_sso_auditor/formatters.py b/cpk_lib_python_aws/aws_access_auditor/formatters.py similarity index 100% rename from cpk_lib_python_aws/aws_sso_auditor/formatters.py rename to cpk_lib_python_aws/aws_access_auditor/formatters.py diff --git a/cpk_lib_python_aws/aws_sso_auditor/utils.py b/cpk_lib_python_aws/aws_access_auditor/utils.py similarity index 100% rename from cpk_lib_python_aws/aws_sso_auditor/utils.py rename to cpk_lib_python_aws/aws_access_auditor/utils.py diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/__init__.py b/cpk_lib_python_aws/tests/aws_access_auditor/__init__.py similarity index 100% rename from cpk_lib_python_aws/tests/aws_sso_auditor/__init__.py rename to cpk_lib_python_aws/tests/aws_access_auditor/__init__.py diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py b/cpk_lib_python_aws/tests/aws_access_auditor/test_auditor.py similarity index 91% rename from cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py rename to cpk_lib_python_aws/tests/aws_access_auditor/test_auditor.py index ac00d93..2aa0e41 100644 --- a/cpk_lib_python_aws/tests/aws_sso_auditor/test_auditor.py +++ b/cpk_lib_python_aws/tests/aws_access_auditor/test_auditor.py @@ -3,9 +3,9 @@ import pytest -from cpk_lib_python_aws.aws_sso_auditor.auditor import AWSSSOAuditor, NullOutputSink -from cpk_lib_python_aws.aws_sso_auditor.config import Config -from cpk_lib_python_aws.aws_sso_auditor.exceptions import AWSSSOAuditorError +from cpk_lib_python_aws.aws_access_auditor.auditor import AWSSSOAuditor, NullOutputSink +from cpk_lib_python_aws.aws_access_auditor.config import Config +from cpk_lib_python_aws.aws_access_auditor.exceptions import AWSSSOAuditorError class TestNullOutputSink: @@ -24,7 +24,7 @@ def test_null_output_sink_methods(self): """Test the AWSSSOAuditor class.""" - @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") + @patch("cpk_lib_python_aws.aws_access_auditor.auditor.AWSClientManager") def test_auditor_initialization_with_default_config(self, mock_aws_manager): """Test auditor initialization with default configuration.""" # Mock the AWS client manager @@ -45,7 +45,7 @@ def test_auditor_initialization_with_default_config(self, mock_aws_manager): assert auditor.identity_store_id == "d-123456789" assert auditor.instance_arn == "arn:aws:sso:::instance/ssoins-123456789" - @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") + @patch("cpk_lib_python_aws.aws_access_auditor.auditor.AWSClientManager") def test_auditor_initialization_with_custom_config(self, mock_aws_manager): """Test auditor initialization with custom configuration.""" mock_manager_instance = Mock() @@ -66,7 +66,7 @@ def test_auditor_initialization_with_custom_config(self, mock_aws_manager): assert auditor.config.debug is True assert auditor.output_sink == output_sink - @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") + @patch("cpk_lib_python_aws.aws_access_auditor.auditor.AWSClientManager") def test_get_permission_sets_for_account_success(self, mock_aws_manager): """Test successful retrieval of permission sets for account.""" # Setup mocks @@ -99,7 +99,7 @@ def test_get_permission_sets_for_account_success(self, mock_aws_manager): assert "arn:aws:sso:::permissionSet/ps-123" in result assert "arn:aws:sso:::permissionSet/ps-456" in result - @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") + @patch("cpk_lib_python_aws.aws_access_auditor.auditor.AWSClientManager") def test_get_permission_sets_for_account_failure(self, mock_aws_manager): """Test handling of errors when retrieving permission sets.""" # Setup mocks to raise exception @@ -121,7 +121,7 @@ def test_get_permission_sets_for_account_failure(self, mock_aws_manager): # Should return empty list on error assert result == [] - @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") + @patch("cpk_lib_python_aws.aws_access_auditor.auditor.AWSClientManager") def test_get_group_details_success(self, mock_aws_manager): """Test successful retrieval of group details.""" mock_manager_instance = Mock() @@ -148,7 +148,7 @@ def test_get_group_details_success(self, mock_aws_manager): assert result["DisplayName"] == "Test Group" assert result["Description"] == "A test group" - @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") + @patch("cpk_lib_python_aws.aws_access_auditor.auditor.AWSClientManager") def test_get_group_details_failure(self, mock_aws_manager): """Test handling of errors when retrieving group details.""" mock_manager_instance = Mock() @@ -171,7 +171,7 @@ def test_get_group_details_failure(self, mock_aws_manager): assert result["DisplayName"] == "Unknown" assert result["Description"] == "" - @patch("cpk_lib_python_aws.aws_sso_auditor.auditor.AWSClientManager") + @patch("cpk_lib_python_aws.aws_access_auditor.auditor.AWSClientManager") def test_audit_account_basic_flow(self, mock_aws_manager): """Test basic audit_account flow with minimal data.""" mock_manager_instance = Mock() diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/test_cli.py b/cpk_lib_python_aws/tests/aws_access_auditor/test_cli.py similarity index 82% rename from cpk_lib_python_aws/tests/aws_sso_auditor/test_cli.py rename to cpk_lib_python_aws/tests/aws_access_auditor/test_cli.py index 81cce35..bad3c64 100644 --- a/cpk_lib_python_aws/tests/aws_sso_auditor/test_cli.py +++ b/cpk_lib_python_aws/tests/aws_access_auditor/test_cli.py @@ -5,19 +5,19 @@ from io import StringIO import sys -from cpk_lib_python_aws.aws_sso_auditor.cli import ( +from cpk_lib_python_aws.aws_access_auditor.cli import ( setup_logging, create_parser, main ) -from cpk_lib_python_aws.aws_sso_auditor.config import Config -from cpk_lib_python_aws.aws_sso_auditor.exceptions import AWSSSOAuditorError +from cpk_lib_python_aws.aws_access_auditor.config import Config +from cpk_lib_python_aws.aws_access_auditor.exceptions import AWSSSOAuditorError class TestSetupLogging: """Test the setup_logging function.""" - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.logging.basicConfig') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.logging.basicConfig') def test_setup_logging_default(self, mock_basic_config): """Test setup_logging with default parameters.""" setup_logging() @@ -27,7 +27,7 @@ def test_setup_logging_default(self, mock_basic_config): assert call_args[1]['level'] == logging.INFO assert "%(asctime)s - %(name)s - %(levelname)s - %(message)s" in call_args[1]['format'] - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.logging.basicConfig') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.logging.basicConfig') def test_setup_logging_debug(self, mock_basic_config): """Test setup_logging with debug enabled.""" setup_logging(debug=True) @@ -35,7 +35,7 @@ def test_setup_logging_debug(self, mock_basic_config): call_args = mock_basic_config.call_args assert call_args[1]['level'] == logging.DEBUG - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.logging.basicConfig') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.logging.basicConfig') def test_setup_logging_quiet(self, mock_basic_config): """Test setup_logging with quiet enabled.""" setup_logging(quiet=True) @@ -52,7 +52,7 @@ def test_create_parser_basic(self): parser = create_parser() assert isinstance(parser, argparse.ArgumentParser) - assert parser.prog == "aws-sso-auditor" + assert parser.prog == "aws-access-auditor" def test_parser_required_arguments(self): """Test parsing with required arguments only.""" @@ -109,10 +109,10 @@ def test_parser_missing_account_id(self): class TestMain: """Test the main function.""" - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputFormatter') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging') def test_main_success(self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter): """Test successful main execution.""" # Setup mocks @@ -140,10 +140,10 @@ def test_main_success(self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter_instance.save_results.assert_called_once() mock_formatter_instance.display_results.assert_called_once() - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputFormatter') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging') def test_main_with_custom_args(self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter): """Test main with custom arguments.""" # Setup mocks @@ -170,10 +170,10 @@ def test_main_with_custom_args(self, mock_setup_logging, mock_output_sink, mock_ assert result == 0 mock_setup_logging.assert_called_once_with(True, False) # debug=True, quiet=False - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging') - def test_main_aws_sso_auditor_error(self, mock_setup_logging, mock_output_sink, mock_auditor): + @patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging') + def test_main_aws_access_auditor_error(self, mock_setup_logging, mock_output_sink, mock_auditor): """Test main handling AWSSSOAuditorError.""" mock_output_instance = Mock() mock_output_sink.return_value = mock_output_instance @@ -185,9 +185,9 @@ def test_main_aws_sso_auditor_error(self, mock_setup_logging, mock_output_sink, assert result == 1 mock_output_instance.error.assert_called_with("AWS SSO Auditor Error: Test error") - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging') def test_main_unexpected_error(self, mock_setup_logging, mock_output_sink, mock_auditor): """Test main handling unexpected errors.""" mock_output_instance = Mock() @@ -200,10 +200,10 @@ def test_main_unexpected_error(self, mock_setup_logging, mock_output_sink, mock_ assert result == 1 mock_output_instance.error.assert_called_with("Unexpected error: Unexpected error") - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputFormatter') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') - @patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') + @patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging') def test_main_config_creation(self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter): """Test that Config is created correctly from CLI args.""" mock_output_instance = Mock() @@ -244,10 +244,10 @@ def test_main_config_creation(self, mock_setup_logging, mock_output_sink, mock_a def test_main_both_output_format(self): """Test that 'both' output format expands to json and yaml.""" - with patch('cpk_lib_python_aws.aws_sso_auditor.cli.setup_logging'), \ - patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputSink') as mock_output_sink, \ - patch('cpk_lib_python_aws.aws_sso_auditor.cli.AWSSSOAuditor') as mock_auditor, \ - patch('cpk_lib_python_aws.aws_sso_auditor.cli.OutputFormatter') as mock_formatter: + with patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging'), \ + patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') as mock_output_sink, \ + patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') as mock_auditor, \ + patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter') as mock_formatter: mock_output_instance = Mock() mock_output_sink.return_value = mock_output_instance diff --git a/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py b/cpk_lib_python_aws/tests/aws_access_auditor/test_config.py similarity index 83% rename from cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py rename to cpk_lib_python_aws/tests/aws_access_auditor/test_config.py index 1243c73..8dc4c3d 100644 --- a/cpk_lib_python_aws/tests/aws_sso_auditor/test_config.py +++ b/cpk_lib_python_aws/tests/aws_access_auditor/test_config.py @@ -3,8 +3,8 @@ import pytest -from cpk_lib_python_aws.aws_sso_auditor.config import Config -from cpk_lib_python_aws.aws_sso_auditor.exceptions import ConfigurationError +from cpk_lib_python_aws.aws_access_auditor.config import Config +from cpk_lib_python_aws.aws_access_auditor.exceptions import ConfigurationError def test_default_config_values(): @@ -43,8 +43,8 @@ def test_environment_variable_override(): """Test that environment variables override default values.""" # Set environment variables os.environ["AWS_REGION"] = "eu-west-1" - os.environ["AWS_SSO_AUDITOR_DEBUG"] = "true" - os.environ["AWS_SSO_AUDITOR_QUIET"] = "true" + os.environ["AWS_ACCESS_AUDITOR_DEBUG"] = "true" + os.environ["AWS_ACCESS_AUDITOR_QUIET"] = "true" try: config = Config() @@ -54,8 +54,8 @@ def test_environment_variable_override(): finally: # Clean up environment variables os.environ.pop("AWS_REGION", None) - os.environ.pop("AWS_SSO_AUDITOR_DEBUG", None) - os.environ.pop("AWS_SSO_AUDITOR_QUIET", None) + os.environ.pop("AWS_ACCESS_AUDITOR_DEBUG", None) + os.environ.pop("AWS_ACCESS_AUDITOR_QUIET", None) def test_constructor_overrides(): diff --git a/pyproject.toml b/pyproject.toml index e450c2d..7549f6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dev = [ ] [project.scripts] -aws-sso-auditor = "cpk_lib_python_aws.aws_sso_auditor.cli:main" +aws-access-auditor = "cpk_lib_python_aws.aws_access_auditor.cli:main" [project.urls] Homepage = "https://github.com/opencpk/cpk-lib-python-aws" From dc9a76bb70df34d98c464e4fb6e3bea4737e3b47 Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Tue, 8 Jul 2025 18:12:48 -0300 Subject: [PATCH 13/16] feat/aws-access-auditor --- .../tests/aws_access_auditor/test_auditor.py | 8 +- .../tests/aws_access_auditor/test_cli.py | 299 ++++++++++-------- .../tests/aws_access_auditor/test_config.py | 1 + 3 files changed, 168 insertions(+), 140 deletions(-) diff --git a/cpk_lib_python_aws/tests/aws_access_auditor/test_auditor.py b/cpk_lib_python_aws/tests/aws_access_auditor/test_auditor.py index 2aa0e41..82b9c5f 100644 --- a/cpk_lib_python_aws/tests/aws_access_auditor/test_auditor.py +++ b/cpk_lib_python_aws/tests/aws_access_auditor/test_auditor.py @@ -1,11 +1,9 @@ # -*- coding: utf-8 -*- -from unittest.mock import MagicMock, Mock, patch - -import pytest +"""Tests for AWS Access Auditor module.""" +from unittest.mock import Mock, patch from cpk_lib_python_aws.aws_access_auditor.auditor import AWSSSOAuditor, NullOutputSink from cpk_lib_python_aws.aws_access_auditor.config import Config -from cpk_lib_python_aws.aws_access_auditor.exceptions import AWSSSOAuditorError class TestNullOutputSink: @@ -22,8 +20,6 @@ def test_null_output_sink_methods(self): assert sink.info("test message") is None assert sink.error("test message") is None - """Test the AWSSSOAuditor class.""" - @patch("cpk_lib_python_aws.aws_access_auditor.auditor.AWSClientManager") def test_auditor_initialization_with_default_config(self, mock_aws_manager): """Test auditor initialization with default configuration.""" diff --git a/cpk_lib_python_aws/tests/aws_access_auditor/test_cli.py b/cpk_lib_python_aws/tests/aws_access_auditor/test_cli.py index bad3c64..330bc1e 100644 --- a/cpk_lib_python_aws/tests/aws_access_auditor/test_cli.py +++ b/cpk_lib_python_aws/tests/aws_access_auditor/test_cli.py @@ -1,64 +1,61 @@ -import pytest +# -*- coding: utf-8 -*- +"""Tests for AWS SSO Auditor CLI module.""" import argparse import logging -from unittest.mock import Mock, patch, MagicMock -from io import StringIO -import sys - -from cpk_lib_python_aws.aws_access_auditor.cli import ( - setup_logging, - create_parser, - main -) +from unittest.mock import Mock, patch + +import pytest + +from cpk_lib_python_aws.aws_access_auditor.cli import create_parser, main, setup_logging from cpk_lib_python_aws.aws_access_auditor.config import Config from cpk_lib_python_aws.aws_access_auditor.exceptions import AWSSSOAuditorError class TestSetupLogging: """Test the setup_logging function.""" - - @patch('cpk_lib_python_aws.aws_access_auditor.cli.logging.basicConfig') + + @patch("cpk_lib_python_aws.aws_access_auditor.cli.logging.basicConfig") def test_setup_logging_default(self, mock_basic_config): """Test setup_logging with default parameters.""" setup_logging() - + mock_basic_config.assert_called_once() call_args = mock_basic_config.call_args - assert call_args[1]['level'] == logging.INFO - assert "%(asctime)s - %(name)s - %(levelname)s - %(message)s" in call_args[1]['format'] - - @patch('cpk_lib_python_aws.aws_access_auditor.cli.logging.basicConfig') + assert call_args[1]["level"] == logging.INFO + assert "%(asctime)s - %(name)s - %(levelname)s - %(message)s" in call_args[1]["format"] + + @patch("cpk_lib_python_aws.aws_access_auditor.cli.logging.basicConfig") def test_setup_logging_debug(self, mock_basic_config): """Test setup_logging with debug enabled.""" setup_logging(debug=True) - + call_args = mock_basic_config.call_args - assert call_args[1]['level'] == logging.DEBUG - - @patch('cpk_lib_python_aws.aws_access_auditor.cli.logging.basicConfig') + assert call_args[1]["level"] == logging.DEBUG + + @patch("cpk_lib_python_aws.aws_access_auditor.cli.logging.basicConfig") def test_setup_logging_quiet(self, mock_basic_config): """Test setup_logging with quiet enabled.""" setup_logging(quiet=True) - + call_args = mock_basic_config.call_args - assert call_args[1]['level'] == logging.ERROR + assert call_args[1]["level"] == logging.ERROR class TestCreateParser: """Test the create_parser function.""" - + def test_create_parser_basic(self): """Test that parser is created with correct structure.""" parser = create_parser() - + assert isinstance(parser, argparse.ArgumentParser) assert parser.prog == "aws-access-auditor" - + def test_parser_required_arguments(self): """Test parsing with required arguments only.""" parser = create_parser() args = parser.parse_args(["123456789012"]) - + assert args.account_id == "123456789012" assert args.output_format == "both" assert args.output_dir == "./aws-sso-audit-results" @@ -67,21 +64,27 @@ def test_parser_required_arguments(self): assert args.quiet is False assert args.debug is False assert args.no_timestamp is False - + def test_parser_all_arguments(self): """Test parsing with all arguments provided.""" parser = create_parser() - args = parser.parse_args([ - "123456789012", - "--output-format", "json", - "--output-dir", "/tmp/results", - "--aws-region", "us-west-2", - "--aws-profile", "my-profile", - "--quiet", - "--debug", - "--no-timestamp" - ]) - + args = parser.parse_args( + [ + "123456789012", + "--output-format", + "json", + "--output-dir", + "/tmp/results", + "--aws-region", + "us-west-2", + "--aws-profile", + "my-profile", + "--quiet", + "--debug", + "--no-timestamp", + ] + ) + assert args.account_id == "123456789012" assert args.output_format == "json" assert args.output_dir == "/tmp/results" @@ -90,150 +93,168 @@ def test_parser_all_arguments(self): assert args.quiet is True assert args.debug is True assert args.no_timestamp is True - + def test_parser_invalid_output_format(self): """Test parser rejects invalid output format.""" parser = create_parser() - + with pytest.raises(SystemExit): parser.parse_args(["123456789012", "--output-format", "invalid"]) - + def test_parser_missing_account_id(self): """Test parser requires account_id.""" parser = create_parser() - + with pytest.raises(SystemExit): parser.parse_args([]) class TestMain: """Test the main function.""" - - @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging') + + @patch("cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.OutputSink") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.setup_logging") def test_main_success(self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter): """Test successful main execution.""" # Setup mocks mock_output_instance = Mock() mock_output_sink.return_value = mock_output_instance - + mock_auditor_instance = Mock() mock_auditor_instance.audit_account.return_value = { "metadata": {"account_id": "123456789012"}, - "summary": {"total_groups": 5, "total_permission_sets": 3} + "summary": {"total_groups": 5, "total_permission_sets": 3}, } mock_auditor.return_value = mock_auditor_instance - + mock_formatter_instance = Mock() mock_formatter_instance.save_results.return_value = ["file1.json", "file2.yaml"] mock_formatter.return_value = mock_formatter_instance - + # Run main result = main(["123456789012"]) - + # Verify success assert result == 0 mock_setup_logging.assert_called_once() mock_auditor_instance.audit_account.assert_called_once_with("123456789012") mock_formatter_instance.save_results.assert_called_once() mock_formatter_instance.display_results.assert_called_once() - - @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging') - def test_main_with_custom_args(self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter): + + @patch("cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.OutputSink") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.setup_logging") + def test_main_with_custom_args( + self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter + ): """Test main with custom arguments.""" # Setup mocks mock_output_instance = Mock() mock_output_sink.return_value = mock_output_instance - + mock_auditor_instance = Mock() - mock_auditor_instance.audit_account.return_value = {"metadata": {"account_id": "123456789012"}, "summary": {}} + mock_auditor_instance.audit_account.return_value = { + "metadata": {"account_id": "123456789012"}, + "summary": {}, + } mock_auditor.return_value = mock_auditor_instance - + mock_formatter_instance = Mock() mock_formatter_instance.save_results.return_value = ["file1.json"] mock_formatter.return_value = mock_formatter_instance - + # Run main with custom args - result = main([ - "123456789012", - "--output-format", "json", - "--aws-region", "eu-west-1", - "--debug" - ]) - + result = main( + ["123456789012", "--output-format", "json", "--aws-region", "eu-west-1", "--debug"] + ) + # Verify assert result == 0 mock_setup_logging.assert_called_once_with(True, False) # debug=True, quiet=False - - @patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging') - def test_main_aws_access_auditor_error(self, mock_setup_logging, mock_output_sink, mock_auditor): + + @patch("cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.OutputSink") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.setup_logging") + def test_main_aws_access_auditor_error( + self, mock_setup_logging, mock_output_sink, mock_auditor + ): """Test main handling AWSSSOAuditorError.""" mock_output_instance = Mock() mock_output_sink.return_value = mock_output_instance - + mock_auditor.side_effect = AWSSSOAuditorError("Test error") - + result = main(["123456789012"]) - + assert result == 1 + assert mock_setup_logging.called # Verify setup_logging was called mock_output_instance.error.assert_called_with("AWS SSO Auditor Error: Test error") - - @patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging') + + @patch("cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.OutputSink") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.setup_logging") def test_main_unexpected_error(self, mock_setup_logging, mock_output_sink, mock_auditor): """Test main handling unexpected errors.""" mock_output_instance = Mock() mock_output_sink.return_value = mock_output_instance - + mock_auditor.side_effect = Exception("Unexpected error") - + result = main(["123456789012"]) - + assert result == 1 + assert mock_setup_logging.called # Verify setup_logging was called mock_output_instance.error.assert_called_with("Unexpected error: Unexpected error") - - @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') - @patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging') - def test_main_config_creation(self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter): + + @patch("cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.OutputSink") + @patch("cpk_lib_python_aws.aws_access_auditor.cli.setup_logging") + def test_main_config_creation( + self, mock_setup_logging, mock_output_sink, mock_auditor, mock_formatter + ): """Test that Config is created correctly from CLI args.""" mock_output_instance = Mock() mock_output_sink.return_value = mock_output_instance - + mock_auditor_instance = Mock() - mock_auditor_instance.audit_account.return_value = {"metadata": {"account_id": "123456789012"}, "summary": {}} + mock_auditor_instance.audit_account.return_value = { + "metadata": {"account_id": "123456789012"}, + "summary": {}, + } mock_auditor.return_value = mock_auditor_instance - + mock_formatter_instance = Mock() mock_formatter_instance.save_results.return_value = ["file1.json"] mock_formatter.return_value = mock_formatter_instance - - result = main([ - "123456789012", - "--output-format", "yaml", - "--output-dir", "/custom/dir", - "--aws-region", "ap-southeast-1", - "--aws-profile", "test-profile", - "--no-timestamp", - "--quiet" - ]) - + + result = main( + [ + "123456789012", + "--output-format", + "yaml", + "--output-dir", + "/custom/dir", + "--aws-region", + "ap-southeast-1", + "--aws-profile", + "test-profile", + "--no-timestamp", + "--quiet", + ] + ) + # Verify Config was created with correct parameters assert result == 0 - + assert mock_setup_logging.called # Verify setup_logging was called + # Check that auditor was called with a config call_args = mock_auditor.call_args config = call_args[0][0] # First argument should be config - + assert isinstance(config, Config) assert config.aws_region == "ap-southeast-1" assert config.aws_profile == "test-profile" @@ -241,34 +262,40 @@ def test_main_config_creation(self, mock_setup_logging, mock_output_sink, mock_a assert config.output_directory == "/custom/dir" assert config.include_timestamp is False # no-timestamp flag assert config.quiet is True - + def test_main_both_output_format(self): """Test that 'both' output format expands to json and yaml.""" - with patch('cpk_lib_python_aws.aws_access_auditor.cli.setup_logging'), \ - patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputSink') as mock_output_sink, \ - patch('cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor') as mock_auditor, \ - patch('cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter') as mock_formatter: - + with patch("cpk_lib_python_aws.aws_access_auditor.cli.setup_logging"), patch( + "cpk_lib_python_aws.aws_access_auditor.cli.OutputSink" + ) as mock_output_sink, patch( + "cpk_lib_python_aws.aws_access_auditor.cli.AWSSSOAuditor" + ) as mock_auditor, patch( + "cpk_lib_python_aws.aws_access_auditor.cli.OutputFormatter" + ) as mock_formatter: + mock_output_instance = Mock() mock_output_sink.return_value = mock_output_instance - + mock_auditor_instance = Mock() - mock_auditor_instance.audit_account.return_value = {"metadata": {"account_id": "123456789012"}, "summary": {}} + mock_auditor_instance.audit_account.return_value = { + "metadata": {"account_id": "123456789012"}, + "summary": {}, + } mock_auditor.return_value = mock_auditor_instance - + mock_formatter_instance = Mock() mock_formatter_instance.save_results.return_value = ["file1.json", "file2.yaml"] mock_formatter.return_value = mock_formatter_instance - + result = main(["123456789012", "--output-format", "both"]) - + assert result == 0 - + # Verify config has both formats call_args = mock_auditor.call_args config = call_args[0][0] assert set(config.output_formats) == {"json", "yaml"} - + def test_main_invalid_args(self): """Test main with invalid arguments.""" # This should exit due to argparse error @@ -276,25 +303,29 @@ def test_main_invalid_args(self): main(["123456789012", "--invalid-arg"]) -# Integration-style test class TestCLIIntegration: """Integration-style tests for CLI components.""" - + def test_config_from_parser_args(self): """Test creating Config from parsed arguments.""" parser = create_parser() - args = parser.parse_args([ - "123456789012", - "--output-format", "json", - "--output-dir", "/test/dir", - "--aws-region", "us-west-2", - "--debug", - "--no-timestamp" - ]) - + args = parser.parse_args( + [ + "123456789012", + "--output-format", + "json", + "--output-dir", + "/test/dir", + "--aws-region", + "us-west-2", + "--debug", + "--no-timestamp", + ] + ) + # This mimics what main() does with the args output_formats = [args.output_format] if args.output_format != "both" else ["json", "yaml"] - + config = Config( aws_region=args.aws_region, aws_profile=args.aws_profile, @@ -304,10 +335,10 @@ def test_config_from_parser_args(self): debug=args.debug, quiet=args.quiet, ) - + assert config.aws_region == "us-west-2" assert config.output_formats == ["json"] assert config.output_directory == "/test/dir" assert config.include_timestamp is False assert config.debug is True - assert config.quiet is False \ No newline at end of file + assert config.quiet is False diff --git a/cpk_lib_python_aws/tests/aws_access_auditor/test_config.py b/cpk_lib_python_aws/tests/aws_access_auditor/test_config.py index 8dc4c3d..72d3c7d 100644 --- a/cpk_lib_python_aws/tests/aws_access_auditor/test_config.py +++ b/cpk_lib_python_aws/tests/aws_access_auditor/test_config.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +"""Tests for AWS Access Auditor configuration module.""" import os import pytest From 37cff5c7e26afc848531dc3446bbf5b0b492473d Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 9 Jul 2025 12:33:35 -0300 Subject: [PATCH 14/16] feat/aws-access-auditor --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 0e26064..0ab079b 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,10 @@ var/ venv.bak/ venv/ wheels/ +*.egg-info/ +*.egg-link +*.installed.cfg +*.pyc +*.pyo +*.pyd +*.swp From 3d15d07a2712d3744d728f060690090464ccf1bb Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 9 Jul 2025 12:35:47 -0300 Subject: [PATCH 15/16] feat/aws-access-auditor --- .gitignore | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 0ab079b..2825610 100644 --- a/.gitignore +++ b/.gitignore @@ -2,15 +2,22 @@ *.cover *.egg *.egg-info/ +*.egg-info/ +*.egg-link +*.installed.cfg *.log *.manifest *.mo *.pot *.py.cover *.py[codz] +*.pyc +*.pyd +*.pyo *.sage.py *.so *.spec +*.swp .Python .abstra/ .cache @@ -86,10 +93,3 @@ var/ venv.bak/ venv/ wheels/ -*.egg-info/ -*.egg-link -*.installed.cfg -*.pyc -*.pyo -*.pyd -*.swp From b71570e595800c06cc0205d6ec964b4bcd0757fe Mon Sep 17 00:00:00 2001 From: hminaee-tc Date: Wed, 9 Jul 2025 15:06:33 -0300 Subject: [PATCH 16/16] feat/aws-access-auditor --- .github/workflows/precommit.yaml | 2 +- .github/workflows/tests.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/precommit.yaml b/.github/workflows/precommit.yaml index 89a2e86..e151b79 100644 --- a/.github/workflows/precommit.yaml +++ b/.github/workflows/precommit.yaml @@ -4,7 +4,7 @@ name: precommit on: pull_request: push: - branches: [main, feat/aws-auditor] + branches: [main] permissions: actions: read diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 10d86d3..8144c65 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -3,7 +3,7 @@ name: Tests on: push: - branches: [main, develop, feat/aws-auditor] + branches: [main] pull_request: jobs: