diff --git a/README.md b/README.md index 19f81e3..b27f0b1 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ cloudiscovery aws-all --region-name xx-xxxx-xxx [--profile-name profile] [--serv cloudiscovery aws-limit --region-name xx-xxxx-xxx [--profile-name profile] [--services xxx,xxx] [--usage 0-100] [--verbose] ``` +1.6 To run AWS security controls (experimental feature): + +```sh +cloudiscovery aws-security --region-name xx-xxxx-xxx [--profile-name profile] [--commands x] [--verbose] +``` + 2. For help use: ```sh @@ -113,89 +119,10 @@ More on credentials configuration: [Configuration basics](https://docs.aws.amazo #### AWS Permissions -The configured credentials must be associated to a user or role with proper permissions to do all checks. If you want to use a role with narrowed set of permissions just to perform cloud discovery, use a role from the following CF template shown below. To further increase security, you can add a block to check `aws:MultiFactorAuthPresent` condition in `AssumeRolePolicyDocument`. More on using IAM roles in the [configuration file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html). - -```json -{ - "AWSTemplateFormatVersion": "2010-09-09", - "Description": "Setups a role for diagram builder for all resources within an account", - "Resources": { - "cloudiscoveryRole": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument" : { - "Statement" : [ - { - "Effect" : "Allow", - "Principal" : { - "AWS": { "Fn::Join" : [ "", [ - "arn:aws:iam::", { "Ref" : "AWS::AccountId" }, ":root" - ]]} - }, - "Action" : [ "sts:AssumeRole" ] - } - ] - }, - "Policies": [{ - "PolicyName": "additional-permissions", - "PolicyDocument": { - "Version": "2012-10-17", - "Statement" : [ - { - "Effect" : "Allow", - "Action" : [ - "kafka:ListClusters", - "synthetics:DescribeCanaries", - "medialive:ListInputs", - "cloudhsm:DescribeClusters", - "ssm:GetParametersByPath", - "servicequotas:Get*", - "amplify:ListApps", - "autoscaling-plans:DescribeScalingPlans", - "medialive:ListChannels", - "medialive:ListInputDevices", - "mediapackage:ListChannels", - "qldb:ListLedgers", - "transcribe:ListVocabularies", - "glue:GetDatabases", - "glue:GetUserDefinedFunctions", - "glue:GetSecurityConfigurations", - "glue:GetTriggers", - "glue:GetCrawlers", - "glue:ListWorkflows", - "glue:ListMLTransforms", - "codeguru-reviewer:ListCodeReviews", - "servicediscovery:ListNamespaces", - "apigateway:GET", - "forecast:ListPredictors", - "frauddetector:GetDetectors", - "forecast:ListDatasetImportJobs", - "frauddetector:GetModels", - "frauddetector:GetOutcomes", - "networkmanager:DescribeGlobalNetworks", - "codeartifact:ListDomains", - "ses:GetSendQuota" - ], - "Resource": [ "*" ] - } - ] - } - }], - "Path" : "/", - "ManagedPolicyArns" : [ - "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess", - "arn:aws:iam::aws:policy/SecurityAudit" - ] - } - } - }, - "Outputs" : { - "cloudiscoveryRoleArn" : { - "Value" : { "Fn::GetAtt": [ "cloudiscoveryRole", "Arn" ]} - } - } -} -``` +The configured credentials must be associated to a user or role with proper permissions to do all checks. If you want to use a role with narrowed set of permissions just to perform cloud discovery, use a role from the following the [CF template maintained by our team](docs/assets/role-template.json). + +To further increase security, you can add a block to check `aws:MultiFactorAuthPresent` condition in `AssumeRolePolicyDocument`. More on using IAM roles in the [configuration file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html). + (Optional) If you want to be able to switch between multiple AWS credentials and settings, you can configure [named profiles](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html) and later pass profile name when running the tool. @@ -379,6 +306,14 @@ An administrator can ask to increase the quota value of a certain service via ti More information: [AWS WA, REL 1 How do you manage service limits?](https://wa.aws.amazon.com/wat.question.REL_1.en.html) +### AWS Security +This features is experimental, but now you can run commands to check and analyze some security issues. The following commands are available now: + +* Access key age +* EBS Encryption enabled +* EC2 IMDSV2 Check +* DynamoDB PITR Enabled + ## Using a Docker container To build docker container using Dockerfile diff --git a/cloudiscovery/__init__.py b/cloudiscovery/__init__.py index b5b82b5..26ba67f 100644 --- a/cloudiscovery/__init__.py +++ b/cloudiscovery/__init__.py @@ -32,6 +32,7 @@ from provider.iot.command import Iot from provider.all.command import All from provider.limit.command import Limit +from provider.security.command import Security from shared.common import ( exit_critical, @@ -46,7 +47,7 @@ print("Python 3.6 or newer is required", file=sys.stderr) sys.exit(1) -__version__ = "2.2.3" +__version__ = "2.2.4" AVAILABLE_LANGUAGES = ["en_US", "pt_BR"] DEFAULT_REGION = "us-east-1" @@ -107,6 +108,20 @@ def generate_parser(): For example: --threshold 50 will report all resources with more than 50%% threshold.", ) + security_parser = subparsers.add_parser( + "aws-security", help="Analyze aws several security checks." + ) + add_default_arguments(security_parser, diagram_enabled=False, filters_enabled=False) + security_parser.add_argument( + "-c", + "--commands", + action="append", + required=False, + help='Select the security check command that you want to run. \ + To see available commands, please type "-c list". \ + If not passed, command will check all services.', + ) + return parser @@ -262,12 +277,18 @@ def main(): command = Limit( region_names=region_names, session=session, threshold=args.threshold, ) + elif args.command == "aws-security": + command = Security( + region_names=region_names, session=session, commands=args.commands, + ) else: raise NotImplementedError("Unknown command") + if "services" in args and args.services is not None: services = args.services.split(",") else: services = [] + command.run(diagram, args.verbose, services, filters) diff --git a/cloudiscovery/provider/all/command.py b/cloudiscovery/provider/all/command.py index 72c0f25..f93ac99 100644 --- a/cloudiscovery/provider/all/command.py +++ b/cloudiscovery/provider/all/command.py @@ -33,7 +33,7 @@ def run( services=services, ) - command_runner = AwsCommandRunner(filters) + command_runner = AwsCommandRunner(filters=filters) command_runner.run( provider="all", options=options, diff --git a/cloudiscovery/provider/all/data/__init__.py b/cloudiscovery/provider/all/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudiscovery/provider/all/data/omitted_resources.py b/cloudiscovery/provider/all/data/omitted_resources.py new file mode 100644 index 0000000..e256925 --- /dev/null +++ b/cloudiscovery/provider/all/data/omitted_resources.py @@ -0,0 +1,102 @@ +OMITTED_RESOURCES = [ + "aws_cloudhsm_available_zone", + "aws_cloudhsm_hapg", + "aws_cloudhsm_hsm", + "aws_cloudhsm_luna_client", + "aws_dax_default_parameter", + "aws_dax_parameter_group", + "aws_ec2_reserved_instances_offering", + "aws_ec2_snapshot", + "aws_ec2_spot_price_history", + "aws_ssm_available_patch", + "aws_ssm_document", + "aws_polly_voice", + "aws_lightsail_blueprint", + "aws_lightsail_bundle", + "aws_lightsail_region", + "aws_elastictranscoder_preset", + "aws_ec2_vpc_endpoint_service", + "aws_dms_endpoint_type", + "aws_elasticache_service_update", + "aws_elasticache_cache_parameter_group", + "aws_rds_source_region", + "aws_ssm_association", + "aws_ssm_patch_baseline", + "aws_ec2_prefix", + "aws_ec2_image", + "aws_ec2_region", + "aws_opsworks_operating_system", + "aws_rds_account_attribute", + "aws_route53_geo_location", + "aws_redshift_cluster_track", + "aws_redshift_reserved_node_offering", + "aws_directconnect_location", + "aws_dms_account_attribute", + "aws_securityhub_standard", + "aws_ram_resource_type", + "aws_ram_permission", + "aws_ec2_account_attribute", + "aws_elasticbeanstalk_available_solution_stack", + "aws_redshift_account_attribute", + "aws_opsworks_user_profile", + "aws_directconnect_direct_connect_gateway_association", # DirectConnect resources endpoint are complicated + "aws_directconnect_direct_connect_gateway_attachment", + "aws_directconnect_interconnect", + "aws_dms_replication_task_assessment_result", + "aws_ec2_fpga_image", + "aws_ec2_launch_template_version", + "aws_ec2_reserved_instancesing", + "aws_ec2_spot_datafeed_subscription", + "aws_ec2_transit_gateway_multicast_domain", + "aws_elasticbeanstalk_configuration_option", + "aws_elasticbeanstalk_platform_version", + "aws_iam_credential_report", + "aws_iam_account_password_policy", + "aws_importexport_job", + "aws_iot_o_taupdate", + "aws_iot_default_authorizer", + "aws_workspaces_account", + "aws_workspaces_account_modification", + "aws_rds_export_task", + "aws_rds_custom_availability_zone", + "aws_rds_installation_media", + "aws_rds_d_bsecurity_group", + "aws_rds_reserved_db_instances_offering", + "aws_translate_text_translation_job", + "aws_rekognition_project", + "aws_rekognition_stream_processor", + "aws_sdb_domain", + "aws_redshift_table_restore_status", + "aws_iot_v2_logging_level", + "aws_license_manager_resource_inventory", + "aws_license_manager_license_configuration", + "aws_logs_query_definition", + "aws_autoscaling_scaling_activity", + "aws_autoscaling_auto_scaling_notification_type", + "aws_autoscaling_scaling_process_type", + "aws_autoscaling_termination_policy_type", + "aws_ec2_host_reservation_offering", + "aws_ec2_availability_zone", + "aws_cloudwatch_metric", + "aws_organizations_handshakes_for_organization", + "aws_config_organization_config_rule", + "aws_organizations_root", + "aws_organizations_delegated_administrator", + "aws_organizations_create_account_status", + "aws_config_organization_conformance_pack_status", + "aws_config_organization_conformance_pack", + "aws_ec2_reserved_instances_listing", + "aws_redshift_cluster_security_group", + "aws_guardduty_organization_admin_account", + "aws_elasticache_cache_security_group", + "aws_elasticache_reserved_cache_nodes_offering", + "aws_organizations_aws_service_access_for_organization", + "aws_organizations_account", + "aws_config_organization_config_rule_status", + "aws_dynamodb_backup", + "aws_ec2_prefix_list", + "aws_route53_hosted_zones_by_name", + "aws_es_reserved_elasticsearch_instance_offering", + "aws_ssm_automation_execution", + "aws_route53_checker_ip_range", +] diff --git a/cloudiscovery/provider/all/data/on_top_policies.py b/cloudiscovery/provider/all/data/on_top_policies.py new file mode 100644 index 0000000..2dfc022 --- /dev/null +++ b/cloudiscovery/provider/all/data/on_top_policies.py @@ -0,0 +1,34 @@ +ON_TOP_POLICIES = [ + "kafka:ListClusters", + "synthetics:DescribeCanaries", + "medialive:ListInputs", + "cloudhsm:DescribeClusters", + "ssm:GetParametersByPath", + "servicequotas:Get*", + "amplify:ListApps", + "autoscaling-plans:DescribeScalingPlans", + "medialive:ListChannels", + "medialive:ListInputDevices", + "mediapackage:ListChannels", + "qldb:ListLedgers", + "transcribe:ListVocabularies", + "glue:GetDatabases", + "glue:GetUserDefinedFunctions", + "glue:GetSecurityConfigurations", + "glue:GetTriggers", + "glue:GetCrawlers", + "glue:ListWorkflows", + "glue:ListMLTransforms", + "codeguru-reviewer:ListCodeReviews", + "servicediscovery:ListNamespaces", + "apigateway:GET", + "forecast:ListPredictors", + "frauddetector:GetDetectors", + "forecast:ListDatasetImportJobs", + "frauddetector:GetModels", + "frauddetector:GetOutcomes", + "networkmanager:DescribeGlobalNetworks", + "codeartifact:ListDomains", + "ses:GetSendQuota", + "codeguru-profiler:ListProfilingGroups", +] diff --git a/cloudiscovery/provider/all/data/required_params_override.py b/cloudiscovery/provider/all/data/required_params_override.py new file mode 100644 index 0000000..ea292eb --- /dev/null +++ b/cloudiscovery/provider/all/data/required_params_override.py @@ -0,0 +1,65 @@ +# Trying to fix documentation errors or its lack made by "happy pirates" at AWS +REQUIRED_PARAMS_OVERRIDE = { + "batch": {"ListJobs": ["jobQueue"]}, + "cloudformation": { + "DescribeStackEvents": ["stackName"], + "DescribeStackResources": ["stackName"], + "GetTemplate": ["stackName"], + "ListTypeVersions": ["arn"], + }, + "codecommit": {"GetBranch": ["repositoryName"]}, + "codedeploy": { + "GetDeploymentTarget": ["deploymentId"], + "ListDeploymentTargets": ["deploymentId"], + }, + "ecs": { + "ListTasks": ["cluster"], + "ListServices": ["cluster"], + "ListContainerInstances": ["cluster"], + "DescribeTasks": ["cluster", "tasks"], + "DescribeServices": ["cluster", "services"], + "DescribeContainerInstances": ["cluster", "containerInstances"], + }, + "elasticbeanstalk": { + "DescribeEnvironmentHealth": ["environmentName"], + "DescribeEnvironmentManagedActionHistory": ["environmentName"], + "DescribeEnvironmentManagedActions": ["environmentName"], + "DescribeEnvironmentResources": ["environmentName"], + "DescribeInstancesHealth": ["environmentName"], + }, + "iam": { + "GetUser": ["userName"], + "ListAccessKeys": ["userName"], + "ListServiceSpecificCredentials": ["userName"], + "ListSigningCertificates": ["userName"], + "ListMFADevices": ["userName"], + "ListSSHPublicKeys": ["userName"], + }, + "iot": {"ListAuditFindings": ["taskId"]}, + "opsworks": { + "ListAuditFindings": ["taskId"], + "DescribeAgentVersions": ["stackId"], + "DescribeApps": ["stackId"], + "DescribeCommands": ["deploymentId"], + "DescribeDeployments": ["appId"], + "DescribeEcsClusters": ["ecsClusterArns"], + "DescribeElasticIps": ["stackId"], + "DescribeElasticLoadBalancers": ["stackId"], + "DescribeInstances": ["stackId"], + "DescribeLayers": ["stackId"], + "DescribePermissions": ["stackId"], + "DescribeRaidArrays": ["stackId"], + "DescribeVolumes": ["stackId"], + }, + "ssm": {"DescribeMaintenanceWindowSchedule": ["windowId"],}, + "shield": {"DescribeProtection": ["protectionId"],}, + "waf": { + "ListActivatedRulesInRuleGroup": ["ruleGroupId"], + "ListLoggingConfigurations": ["limit"], + }, + "waf-regional": { + "ListActivatedRulesInRuleGroup": ["ruleGroupId"], + "ListLoggingConfigurations": ["limit"], + }, + "wafv2": {"ListLoggingConfigurations": ["limit"],}, +} diff --git a/cloudiscovery/provider/all/exception/__init__.py b/cloudiscovery/provider/all/exception/__init__.py new file mode 100644 index 0000000..e1e4676 --- /dev/null +++ b/cloudiscovery/provider/all/exception/__init__.py @@ -0,0 +1,86 @@ +import functools +from shared.common import ( + message_handler, + log_critical, +) + + +def all_exception(func): + # pylint: disable=inconsistent-return-statements + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + # pylint: disable=broad-except + except Exception as e: + if func.__qualname__ == "AllResources.analyze_operation": + if not args[0].options.verbose: + return + exception_str = str(e) + if ( + "is not subscribed to AWS Security Hub" in exception_str + or "not enabled for securityhub" in exception_str + or "The subscription does not exist" in exception_str + or "calling the DescribeHub operation" in exception_str + ): + message_handler( + "Operation {} not accessible, AWS Security Hub is not configured... Skipping".format( + args[2] + ), + "WARNING", + ) + elif ( + "not connect to the endpoint URL" in exception_str + or "not available in this region" in exception_str + or "API is not available" in exception_str + ): + message_handler( + "Service {} not available in the selected region... Skipping".format( + args[5] + ), + "WARNING", + ) + elif ( + "Your account is not a member of an organization" in exception_str + or "This action can only be made by accounts in an AWS Organization" + in exception_str + or "The request failed because organization is not in use" + in exception_str + ): + message_handler( + "Service {} only available to account in an AWS Organization... Skipping".format( + args[5] + ), + "WARNING", + ) + elif "is no longer available to new customers" in exception_str: + message_handler( + "Service {} is no longer available to new customers... Skipping".format( + args[5] + ), + "WARNING", + ) + elif ( + "only available to Master account in AWS FM" in exception_str + or "not currently delegated by AWS FM" in exception_str + ): + message_handler( + "Operation {} not accessible, not master account in AWS FM... Skipping".format( + args[2] + ), + "WARNING", + ) + else: + log_critical( + "\nError running operation {}, type {}. Error message {}".format( + args[2], args[1], exception_str + ) + ) + else: + log_critical( + "\nError running method {}. Error message {}".format( + func.__qualname__, str(e) + ) + ) + + return wrapper diff --git a/cloudiscovery/provider/all/resource/all.py b/cloudiscovery/provider/all/resource/all.py index 4becbd3..64e35eb 100644 --- a/cloudiscovery/provider/all/resource/all.py +++ b/cloudiscovery/provider/all/resource/all.py @@ -1,5 +1,5 @@ import collections -import functools +import itertools import re from concurrent.futures.thread import ThreadPoolExecutor from typing import List, Optional @@ -8,185 +8,34 @@ from botocore.loaders import Loader from provider.all.command import AllOptions +from provider.all.data.omitted_resources import OMITTED_RESOURCES +from provider.all.data.on_top_policies import ON_TOP_POLICIES +from provider.all.data.required_params_override import REQUIRED_PARAMS_OVERRIDE +from provider.all.exception import all_exception from shared.common import ( ResourceProvider, Resource, ResourceDigest, message_handler, ResourceAvailable, - log_critical, ) from shared.common_aws import get_paginator, resource_tags -OMITTED_RESOURCES = [ - "aws_cloudhsm_available_zone", - "aws_cloudhsm_hapg", - "aws_cloudhsm_hsm", - "aws_cloudhsm_luna_client", - "aws_dax_default_parameter", - "aws_dax_parameter_group", - "aws_ec2_reserved_instances_offering", - "aws_ec2_snapshot", - "aws_ec2_spot_price_history", - "aws_ssm_available_patch", - "aws_ssm_document", - "aws_polly_voice", - "aws_lightsail_blueprint", - "aws_lightsail_bundle", - "aws_lightsail_region", - "aws_elastictranscoder_preset", - "aws_ec2_vpc_endpoint_service", - "aws_dms_endpoint_type", - "aws_elasticache_service_update", - "aws_elasticache_cache_parameter_group", - "aws_rds_source_region", - "aws_ssm_association", - "aws_ssm_patch_baseline", - "aws_ec2_prefix", - "aws_ec2_image", - "aws_ec2_region", - "aws_opsworks_operating_system", - "aws_rds_account_attribute", - "aws_route53_geo_location", - "aws_redshift_cluster_track", - "aws_directconnect_location", - "aws_dms_account_attribute", - "aws_securityhub_standard", - "aws_ram_resource_type", - "aws_ram_permission", - "aws_ec2_account_attribute", - "aws_elasticbeanstalk_available_solution_stack", - "aws_redshift_account_attribute", - "aws_opsworks_user_profile", - "aws_directconnect_direct_connect_gateway_association", # DirectConnect resources endpoint are complicated - "aws_directconnect_direct_connect_gateway_attachment", - "aws_directconnect_interconnect", - "aws_dms_replication_task_assessment_result", - "aws_ec2_fpga_image", - "aws_ec2_launch_template_version", - "aws_ec2_reserved_instancesing", - "aws_ec2_spot_datafeed_subscription", - "aws_ec2_transit_gateway_multicast_domain", - "aws_elasticbeanstalk_configuration_option", - "aws_elasticbeanstalk_platform_version", - "aws_iam_credential_report", - "aws_iam_account_password_policy", - "aws_importexport_job", - "aws_iot_o_taupdate", - "aws_iot_default_authorizer", - "aws_workspaces_account", - "aws_workspaces_account_modification", - "aws_rds_export_task", - "aws_rds_custom_availability_zone", - "aws_rds_installation_media", - "aws_rds_d_bsecurity_group", - "aws_translate_text_translation_job", - "aws_rekognition_project", - "aws_rekognition_stream_processor", - "aws_sdb_domain", - "aws_redshift_table_restore_status", - "aws_iot_v2_logging_level", - "aws_license_manager_resource_inventory", - "aws_license_manager_license_configuration", - "aws_logs_query_definition", - "aws_autoscaling_scaling_activity", - "aws_cloudwatch_metric", - "aws_organizations_handshakes_for_organization", - "aws_config_organization_config_rule", - "aws_organizations_root", - "aws_organizations_delegated_administrator", - "aws_organizations_create_account_status", - "aws_config_organization_conformance_pack_status", - "aws_config_organization_conformance_pack", - "aws_ec2_reserved_instances_listing", - "aws_redshift_cluster_security_group", - "aws_guardduty_organization_admin_account", - "aws_elasticache_cache_security_group", - "aws_organizations_aws_service_access_for_organization", - "aws_organizations_account", - "aws_config_organization_config_rule_status", - "aws_dynamodb_backup", - "aws_ec2_prefix_list", - "aws_route53_hosted_zones_by_name", -] - -# Trying to fix documentation errors or its lack made by "happy pirates" at AWS -REQUIRED_PARAMS_OVERRIDE = { - "batch": {"ListJobs": ["jobQueue"]}, - "cloudformation": { - "DescribeStackEvents": ["stackName"], - "DescribeStackResources": ["stackName"], - "GetTemplate": ["stackName"], - "ListTypeVersions": ["arn"], - }, - "codecommit": {"GetBranch": ["repositoryName"]}, - "codedeploy": { - "GetDeploymentTarget": ["deploymentId"], - "ListDeploymentTargets": ["deploymentId"], - }, - "ecs": { - "ListTasks": ["cluster"], - "ListServices": ["cluster"], - "ListContainerInstances": ["cluster"], - }, - "elasticbeanstalk": { - "DescribeEnvironmentHealth": ["environmentName"], - "DescribeEnvironmentManagedActionHistory": ["environmentName"], - "DescribeEnvironmentManagedActions": ["environmentName"], - "DescribeEnvironmentResources": ["environmentName"], - "DescribeInstancesHealth": ["environmentName"], - }, - "iam": { - "GetUser": ["userName"], - "ListAccessKeys": ["userName"], - "ListServiceSpecificCredentials": ["userName"], - "ListSigningCertificates": ["userName"], - "ListMFADevices": ["userName"], - "ListSSHPublicKeys": ["userName"], - }, - "iot": {"ListAuditFindings": ["taskId"]}, - "opsworks": { - "ListAuditFindings": ["taskId"], - "DescribeAgentVersions": ["stackId"], - "DescribeApps": ["stackId"], - "DescribeCommands": ["deploymentId"], - "DescribeDeployments": ["appId"], - "DescribeEcsClusters": ["ecsClusterArns"], - "DescribeElasticIps": ["stackId"], - "DescribeElasticLoadBalancers": ["stackId"], - "DescribeInstances": ["stackId"], - "DescribeLayers": ["stackId"], - "DescribePermissions": ["stackId"], - "DescribeRaidArrays": ["stackId"], - "DescribeVolumes": ["stackId"], - }, - "ssm": {"DescribeMaintenanceWindowSchedule": ["windowId"],}, - "shield": {"DescribeProtection": ["protectionId"],}, - "waf": { - "ListActivatedRulesInRuleGroup": ["ruleGroupId"], - "ListLoggingConfigurations": ["limit"], - }, - "waf-regional": { - "ListActivatedRulesInRuleGroup": ["ruleGroupId"], - "ListLoggingConfigurations": ["limit"], - }, - "wafv2": {"ListLoggingConfigurations": ["limit"],}, -} - -ON_TOP_POLICIES = [ - "kafka:ListClusters", - "synthetics:DescribeCanaries", - "medialive:ListInputs", - "cloudhsm:DescribeClusters", - "ssm:GetParametersByPath", -] - SKIPPED_SERVICES = [ "sagemaker" ] # those services have too unreliable API to make use of it PARALLEL_SERVICE_CALLS = 80 +PLURAL_TO_SINGULAR = { + "ies": "y", + "status": "status", + "ches": "ch", + "ses": "s", +} + +LISTING_PREFIXES = ["List", "Get", "Describe"] + def _to_snake_case(camel_case): return ( @@ -216,14 +65,6 @@ def _to_snake_case(camel_case): ) -PLURAL_TO_SINGULAR = { - "ies": "y", - "status": "status", - "ches": "ch", - "ses": "s", -} - - def singular_from_plural(name: str) -> str: if name.endswith("s"): for plural_suffix, singular_suffix in PLURAL_TO_SINGULAR.items(): @@ -235,6 +76,14 @@ def singular_from_plural(name: str) -> str: return name +def is_listing_operation(operation_name: str) -> bool: + return ( + operation_name.startswith("List") + or operation_name.startswith("Get") + or operation_name.startswith("Describe") + ) + + def last_singular_name_element(operation_name): last_name = re.findall("[A-Z][^A-Z]*", operation_name)[-1] return singular_from_plural(last_name) @@ -243,7 +92,9 @@ def last_singular_name_element(operation_name): def retrieve_resource_name(resource, operation_name): resource_name = None last_name = last_singular_name_element(operation_name) - if "name" in resource: + if isinstance(resource, str): + resource_name = resource + elif "name" in resource: resource_name = resource["name"] elif "Name" in resource: resource_name = resource["Name"] @@ -260,8 +111,8 @@ def only_one_suffix(resource, suffix): id_keys = [] last_id_val = None for key, val in resource.items(): - if key.lower().endswith(suffix) and not key.lower().endswith( - "display" + suffix + if key.lower().endswith(suffix.lower()) and not key.lower().endswith( + "display" + suffix.lower() ): id_keys.append(key) last_id_val = val @@ -273,7 +124,9 @@ def only_one_suffix(resource, suffix): def retrieve_resource_id(resource, operation_name, resource_name): resource_id = resource_name last_name = last_singular_name_element(operation_name) - if "id" in resource: + if isinstance(resource, str): + resource_id = resource + elif "id" in resource: resource_id = resource["id"] elif last_name + "Id" in resource: resource_id = resource[last_name + "Id"] @@ -281,8 +134,8 @@ def retrieve_resource_id(resource, operation_name, resource_name): resource_id = only_one_suffix(resource, "id") elif "arn" in resource: resource_id = resource["arn"] - elif last_name + "Arn" in resource: - resource_id = resource[last_name + "Arn"] + elif only_one_suffix(resource, last_name + "arn"): + resource_id = only_one_suffix(resource, last_name + "arn") elif only_one_suffix(resource, "arn"): resource_id = only_one_suffix(resource, "arn") @@ -315,11 +168,12 @@ def operation_allowed( def build_resource( base_resource, operation_name, resource_type, group ) -> Optional[Resource]: - if isinstance(base_resource, str): - return None resource_name = retrieve_resource_name(base_resource, operation_name) resource_id = retrieve_resource_id(base_resource, operation_name, resource_name) + if resource_name is None and resource_id is not None: + resource_name = resource_id + if resource_id is None or resource_name is None: return None attributes = flatten(base_resource) @@ -332,86 +186,6 @@ def build_resource( ) -def all_exception(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - # pylint: disable=broad-except - except Exception as e: - if func.__qualname__ == "AllResources.analyze_operation": - if not args[0].options.verbose: - return - exception_str = str(e) - if ( - "is not subscribed to AWS Security Hub" in exception_str - or "not enabled for securityhub" in exception_str - or "The subscription does not exist" in exception_str - or "calling the DescribeHub operation" in exception_str - ): - message_handler( - "Operation {} not accessible, AWS Security Hub is not configured... Skipping".format( - args[2] - ), - "WARNING", - ) - elif ( - "not connect to the endpoint URL" in exception_str - or "not available in this region" in exception_str - or "API is not available" in exception_str - ): - message_handler( - "Service {} not available in the selected region... Skipping".format( - args[5] - ), - "WARNING", - ) - elif ( - "Your account is not a member of an organization" in exception_str - or "This action can only be made by accounts in an AWS Organization" - in exception_str - or "The request failed because organization is not in use" - in exception_str - ): - message_handler( - "Service {} only available to account in an AWS Organization... Skipping".format( - args[5] - ), - "WARNING", - ) - elif "is no longer available to new customers" in exception_str: - message_handler( - "Service {} is no longer available to new customers... Skipping".format( - args[5] - ), - "WARNING", - ) - elif ( - "only available to Master account in AWS FM" in exception_str - or "not currently delegated by AWS FM" in exception_str - ): - message_handler( - "Operation {} not accessible, not master account in AWS FM... Skipping".format( - args[2] - ), - "WARNING", - ) - else: - log_critical( - "\nError running operation {}, type {}. Error message {}".format( - args[2], args[1], exception_str - ) - ) - else: - log_critical( - "\nError running method {}. Error message {}".format( - func.__qualname__, str(e) - ) - ) - - return wrapper - - def build_resource_type(aws_service, name): resource_name = re.sub(r"^List", "", name) resource_name = re.sub(r"^Get", "", resource_name) @@ -425,6 +199,8 @@ def build_resource_type(aws_service, name): def flatten(d, parent_key="", sep="."): items = [] + if isinstance(d, str): + return {} for k, v in d.items(): new_key = parent_key + sep + k if parent_key else k if isinstance(v, collections.MutableMapping): @@ -434,6 +210,42 @@ def flatten(d, parent_key="", sep="."): return dict(items) +def operation_required_fields(aws_service, input_model, operation): + required_fields = [] + if "required" in input_model and input_model["required"]: + required_fields = input_model["required"] + if ( + aws_service in REQUIRED_PARAMS_OVERRIDE + and operation["name"] in REQUIRED_PARAMS_OVERRIDE[aws_service] + ): + required_fields = REQUIRED_PARAMS_OVERRIDE[aws_service][operation["name"]] + return required_fields + + +def get_policy_allowed_calls(iam_client, policy_arn): + policy_version_id = iam_client.get_policy(PolicyArn=policy_arn)["Policy"][ + "DefaultVersionId" + ] + policy_document = iam_client.get_policy_version( + PolicyArn=policy_arn, VersionId=policy_version_id + )["PolicyVersion"]["Document"] + + return policy_document + + +def permutate_parameters(operation_parameters): + if not operation_parameters: + return [{}] + operation_parameter_keys, operation_parameter_values = zip( + *operation_parameters.items() + ) + parameters_permutation = [ + dict(zip(operation_parameter_keys, v)) + for v in itertools.product(*operation_parameter_values) + ] + return parameters_permutation + + class AllResources(ResourceProvider): def __init__(self, options: AllOptions): """ @@ -475,6 +287,7 @@ def get_resources(self) -> List[Resource]: return resources + # pylint: disable=too-many-locals @all_exception def analyze_service(self, aws_service, boto_loader, allowed_actions): resources = [] @@ -505,41 +318,24 @@ def analyze_service(self, aws_service, boto_loader, allowed_actions): ) return None for name, operation in service_model["operations"].items(): - if ( - name.startswith("List") - or name.startswith("Get") - or name.startswith("Describe") - ): - has_paginator = name in paginators_model["pagination"] - if "input" in operation: - input_model = service_model["shapes"][operation["input"]["shape"]] - if "required" in input_model and input_model["required"]: - continue - if ( - aws_service in REQUIRED_PARAMS_OVERRIDE - and operation["name"] in REQUIRED_PARAMS_OVERRIDE[aws_service] - ): - continue - resource_type = build_resource_type(aws_service, name) - if resource_type in OMITTED_RESOURCES: - continue - if not operation_allowed(allowed_actions, aws_service, name): - continue - analyze_operation = self.analyze_operation( - resource_type, + if is_listing_operation(name): + operation_resources = self.analyze_listing_operation( name, - has_paginator, + paginators_model, + aws_service, + allowed_actions, + operation, + service_model, client, service_full_name, - aws_service, ) - if analyze_operation is not None: - resources.extend(analyze_operation) + resources.extend(operation_resources) + return resources @all_exception # pylint: disable=too-many-locals,too-many-arguments - def analyze_operation( + def retrieve_operation_resources( self, resource_type, operation_name, @@ -547,7 +343,10 @@ def analyze_operation( client, service_full_name, aws_service, + operation_parameters=None, ) -> List[Resource]: + if operation_parameters is None: + operation_parameters = {} resources = [] snake_operation_name = _to_snake_case(operation_name) # pylint: disable=too-many-nested-blocks @@ -556,7 +355,7 @@ def analyze_operation( client=client, operation_name=snake_operation_name, resource_type=resource_type, - filters=None, + filters=operation_parameters, ) list_metadata = pages.result_keys[0].parsed result_key = None @@ -598,9 +397,8 @@ def analyze_operation( if resource is not None: resources.append(resource) else: - - response = getattr(client, snake_operation_name)() - for response_elem in response.values(): + response = getattr(client, snake_operation_name)(**operation_parameters) + for response_field, response_elem in response.items(): if isinstance(response_elem, list): for response_resource in response_elem: resource = build_resource( @@ -611,16 +409,22 @@ def analyze_operation( ) if resource is not None: resources.append(resource) + elif response_field != "ResponseMetadata": + resource = build_resource( + response_elem, operation_name, resource_type, aws_service, + ) + if resource is not None: + resources.append(resource) return resources def get_policies_allowed_actions(self): if self.options.verbose: message_handler("Fetching allowed actions...", "HEADER") iam_client = self.options.client("iam") - view_only_document = self.get_policy_allowed_calls( + view_only_document = get_policy_allowed_calls( iam_client, "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess" ) - sec_audit_document = self.get_policy_allowed_calls( + sec_audit_document = get_policy_allowed_calls( iam_client, "arn:aws:iam::aws:policy/SecurityAudit" ) @@ -638,12 +442,111 @@ def get_policies_allowed_actions(self): return allowed_actions.keys() - def get_policy_allowed_calls(self, iam_client, policy_arn): - policy_version_id = iam_client.get_policy(PolicyArn=policy_arn)["Policy"][ - "DefaultVersionId" - ] - policy_document = iam_client.get_policy_version( - PolicyArn=policy_arn, VersionId=policy_version_id - )["PolicyVersion"]["Document"] + # pylint: disable=too-many-arguments + def check_required_field( + self, + required_field, + service_model, + pagination_model, + aws_service, + allowed_actions, + client, + service_full_name, + ): + method = None + operation = None + for operation_name, service_operation in service_model["operations"].items(): + if operation_name.lower().startswith("list" + required_field[:-1].lower()): + method = operation_name + operation = service_operation + if method is None: + return [] + + has_paginator = method in pagination_model + resource_type = build_resource_type(aws_service, method) + if resource_type in OMITTED_RESOURCES: + return [] + if not operation_allowed(allowed_actions, aws_service, method): + return [] + + if "input" in operation: + input_model = service_model["shapes"][operation["input"]["shape"]] + required_fields = operation_required_fields( + aws_service, input_model, operation + ) + if required_fields: + return [] - return policy_document + operation_resources = self.retrieve_operation_resources( + resource_type, + method, + has_paginator, + client, + service_full_name, + aws_service, + ) + required_field_ids = [] + + for resource in operation_resources: + required_field_ids.append(resource.digest.id) + + return required_field_ids + + # pylint: disable=too-many-locals,too-many-arguments + def analyze_listing_operation( + self, + name, + paginators_model, + aws_service, + allowed_actions, + operation, + service_model, + client, + service_full_name, + ) -> List[Resource]: + operation_resources: List[Resource] = [] + + has_paginator = name in paginators_model["pagination"] + resource_type = build_resource_type(aws_service, name) + if resource_type in OMITTED_RESOURCES: + return operation_resources + if not operation_allowed(allowed_actions, aws_service, name): + return operation_resources + + operation_parameters = dict() + if "input" in operation: + input_model = service_model["shapes"][operation["input"]["shape"]] + required_fields = operation_required_fields( + aws_service, input_model, operation + ) + + if len(required_fields) == 1: + required_field = required_fields[0] + required_field_values = self.check_required_field( + required_field, + service_model, + paginators_model["pagination"], + aws_service, + allowed_actions, + client, + service_full_name, + ) + operation_parameters[required_field] = required_field_values + elif len(required_fields) != 0: + return operation_resources + + parameters_permutation = permutate_parameters(operation_parameters) + + for parameter_permutation in parameters_permutation: + permutation_resources = self.retrieve_operation_resources( + resource_type, + name, + has_paginator, + client, + service_full_name, + aws_service, + parameter_permutation, + ) + if permutation_resources is not None: + operation_resources.extend(permutation_resources) + return operation_resources diff --git a/cloudiscovery/provider/security/__init__.py b/cloudiscovery/provider/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudiscovery/provider/security/command.py b/cloudiscovery/provider/security/command.py new file mode 100644 index 0000000..82226ea --- /dev/null +++ b/cloudiscovery/provider/security/command.py @@ -0,0 +1,70 @@ +from typing import List + +from shared.common import ( + ResourceCache, + Filterable, + BaseOptions, +) +from shared.common_aws import BaseAwsOptions, BaseAwsCommand, AwsCommandRunner +from shared.diagram import NoDiagram + + +class SecurityOptions(BaseAwsOptions, BaseOptions): + commands: List[str] + + # pylint: disable=too-many-arguments + def __init__( + self, verbose: bool, filters: List[Filterable], session, region_name, commands, + ): + BaseAwsOptions.__init__(self, session, region_name) + BaseOptions.__init__(self, verbose, filters) + self.commands = commands + + +class SecurityParameters: + def __init__(self, session, region: str, commands, options: SecurityOptions): + self.region = region + self.cache = ResourceCache() + self.session = session + self.options = options + self.commands = commands + + +class Security(BaseAwsCommand): + def __init__(self, region_names, session, commands): + """ + All AWS resources + + :param region_names: + :param session: + :param commands: + """ + super().__init__(region_names, session) + self.commands = commands + + def run( + self, + diagram: bool, + verbose: bool, + services: List[str], + filters: List[Filterable], + ): + + for region in self.region_names: + security_options = SecurityOptions( + verbose=verbose, + filters=filters, + session=self.session, + region_name=region, + commands=self.commands, + ) + + command_runner = AwsCommandRunner() + command_runner.run( + provider="security", + options=security_options, + diagram_builder=NoDiagram(), + title="AWS Security - Region {}".format(region), + # pylint: disable=no-member + filename=security_options.resulting_file_name("security"), + ) diff --git a/cloudiscovery/provider/security/data/__init__.py b/cloudiscovery/provider/security/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudiscovery/provider/security/data/commands_enabled.py b/cloudiscovery/provider/security/data/commands_enabled.py new file mode 100644 index 0000000..169d0cf --- /dev/null +++ b/cloudiscovery/provider/security/data/commands_enabled.py @@ -0,0 +1,28 @@ +COMMANDS_ENABLED = { + "access-keys-rotated": { + "parameters": [{"name": "max_age", "default_value": "90", "type": "int"}], + "class": "IAM", + "method": "access_keys_rotated", + "short_description": "Checks whether the active access keys are rotated within the number of days.", + }, + "ebs-encryption": { + "parameters": [ + {"name": "ebs_encryption", "default_value": "no", "type": "bool"} + ], + "class": "EC2", + "method": "ebs_encryption", + "short_description": "Check that Amazon Elastic Block Store (EBS) encryption is enabled by default.", + }, + "imdsv2-check": { + "parameters": [{"name": "imdsv2_check", "default_value": "no", "type": "bool"}], + "class": "EC2", + "method": "imdsv2_check", + "short_description": "Checks Amazon EC2 instance metadata is configured with IMDSv2.", + }, + "pitr-enabled": { + "parameters": [{"name": "pitr_enabled", "default_value": "no", "type": "bool"}], + "class": "DYNAMODB", + "method": "pitr_enabled", + "short_description": "Checks that point in time recovery is enabled for Amazon DynamoDB tables.", + }, +} diff --git a/cloudiscovery/provider/security/resource/__init__.py b/cloudiscovery/provider/security/resource/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloudiscovery/provider/security/resource/all.py b/cloudiscovery/provider/security/resource/all.py new file mode 100644 index 0000000..cdecf46 --- /dev/null +++ b/cloudiscovery/provider/security/resource/all.py @@ -0,0 +1,92 @@ +from typing import List + +import importlib + +from provider.security.command import SecurityOptions +from provider.security.data.commands_enabled import COMMANDS_ENABLED +from shared.common import ( + ResourceProvider, + Resource, + message_handler, +) +from shared.error_handler import exception + + +def build_formatted_commands(): + formatted_commands = [] + for detail_command in COMMANDS_ENABLED: + parameters = COMMANDS_ENABLED[detail_command]["parameters"][0]["name"] + default_value = COMMANDS_ENABLED[detail_command]["parameters"][0][ + "default_value" + ] + formated_command = '{}="{}={}"'.format( + detail_command, parameters, default_value + ) + formatted_commands.append(formated_command) + return formatted_commands + + +class SecuritytResources(ResourceProvider): + def __init__(self, options: SecurityOptions): + """ + All resources + + :param options: + """ + super().__init__() + self.options = options + + @exception + # pylint: disable=too-many-locals + def get_resources(self) -> List[Resource]: + + commands = self.options.commands + + result = [] + + # commands informed, checking for specific commands + if not commands: + commands = build_formatted_commands() + # show all commands to check + if commands[0] == "list": + message_handler("\nFollowing commands are enabled\n", "HEADER") + for detail_command in COMMANDS_ENABLED: + parameters = COMMANDS_ENABLED[detail_command]["parameters"][0]["name"] + default_value = COMMANDS_ENABLED[detail_command]["parameters"][0][ + "default_value" + ] + description = COMMANDS_ENABLED[detail_command]["short_description"] + + formated_command = 'cloudiscovery aws-security -c {}="{}={}"'.format( + detail_command, parameters, default_value + ) + message_handler( + "{} - {} \nExample: {}\n".format( + detail_command, description, formated_command + ), + "OKGREEN", + ) + else: + for command in commands: + command = command.split("=") + + # First position always is command + if command[0] not in COMMANDS_ENABLED: + message_handler( + "Command {} doesn't exists.".format(command[0]), "WARNING" + ) + else: + # Second and thrid parameters are class and method + _class = COMMANDS_ENABLED[command[0]]["class"] + _method = COMMANDS_ENABLED[command[0]]["method"] + _parameter = { + command[1].replace('"', ""): command[2].replace('"', "") + } + + module = importlib.import_module( + "provider.security.resource.commands." + _class + ) + instance = getattr(module, _class)(self.options) + result = result + getattr(instance, _method)(**_parameter) + + return result diff --git a/cloudiscovery/provider/security/resource/commands/DYNAMODB.py b/cloudiscovery/provider/security/resource/commands/DYNAMODB.py new file mode 100644 index 0000000..ed008c1 --- /dev/null +++ b/cloudiscovery/provider/security/resource/commands/DYNAMODB.py @@ -0,0 +1,73 @@ +from provider.security.command import SecurityOptions + +from shared.common import ( + Resource, + ResourceDigest, + SecurityValues, +) + + +class DYNAMODB: + def __init__(self, options: SecurityOptions): + self.options = options + + def pitr_enabled(self, pitr_enabled): + + client = self.options.client("dynamodb") + + tables = client.list_tables()["TableNames"] + + resources_found = [] + + for table in tables: + if ( + client.describe_continuous_backups(TableName=table)[ + "ContinuousBackupsDescription" + ]["PointInTimeRecoveryDescription"]["PointInTimeRecoveryStatus"] + == "DISABLED" + ): + resources_found.append( + Resource( + digest=ResourceDigest(id=table, type="pitr_enabled"), + details="PITR disabled", + name=table, + group="ddb_security", + security=SecurityValues( + status="CRITICAL", parameter="pitr_enabled", value="False", + ), + ) + ) + + return resources_found + + def imdsv2_check(self, imdsv2_check): + + client = self.options.client("ec2") + + instances = client.describe_instances()["Reservations"] + + resources_found = [] + + for instance in instances: + for instance_detail in instance["Instances"]: + if ( + instance_detail["MetadataOptions"]["HttpEndpoint"] == "enabled" + and instance_detail["MetadataOptions"]["HttpTokens"] == "optional" + ): + resources_found.append( + Resource( + digest=ResourceDigest( + id=instance_detail["InstanceId"], type="imdsv2_check" + ), + details="IMDSv2 tokens not enforced", + name=instance_detail["InstanceId"], + group="ddb_security", + security=SecurityValues( + status="CRITICAL", + parameter="imdsv2_check", + value="False", + ), + ) + ) + + return resources_found diff --git a/cloudiscovery/provider/security/resource/commands/EC2.py b/cloudiscovery/provider/security/resource/commands/EC2.py new file mode 100644 index 0000000..807ce36 --- /dev/null +++ b/cloudiscovery/provider/security/resource/commands/EC2.py @@ -0,0 +1,72 @@ +from provider.security.command import SecurityOptions + +from shared.common import ( + Resource, + ResourceDigest, + SecurityValues, +) + + +class EC2: + def __init__(self, options: SecurityOptions): + self.options = options + + def ebs_encryption(self, ebs_encryption): + + client = self.options.client("ec2") + + volumes = client.describe_volumes()["Volumes"] + + resources_found = [] + + for volume in volumes: + if volume["Encrypted"] is False: + resources_found.append( + Resource( + digest=ResourceDigest( + id=volume["VolumeId"], type="ebs_encryption" + ), + details="This volume is not encypted.", + name=volume["VolumeId"], + group="ec2_security", + security=SecurityValues( + status="CRITICAL", + parameter="ebs_encryption", + value="False", + ), + ) + ) + + return resources_found + + def imdsv2_check(self, imdsv2_check): + + client = self.options.client("ec2") + + instances = client.describe_instances()["Reservations"] + + resources_found = [] + + for instance in instances: + for instance_detail in instance["Instances"]: + if ( + instance_detail["MetadataOptions"]["HttpEndpoint"] == "enabled" + and instance_detail["MetadataOptions"]["HttpTokens"] == "optional" + ): + resources_found.append( + Resource( + digest=ResourceDigest( + id=instance_detail["InstanceId"], type="imdsv2_check" + ), + details="IMDSv2 tokens not enforced", + name=instance_detail["InstanceId"], + group="ec2_security", + security=SecurityValues( + status="CRITICAL", + parameter="imdsv2_check", + value="False", + ), + ) + ) + + return resources_found diff --git a/cloudiscovery/provider/security/resource/commands/IAM.py b/cloudiscovery/provider/security/resource/commands/IAM.py new file mode 100644 index 0000000..a18b266 --- /dev/null +++ b/cloudiscovery/provider/security/resource/commands/IAM.py @@ -0,0 +1,51 @@ +from datetime import datetime, timedelta +import pytz + +from provider.security.command import SecurityOptions + +from shared.common import ( + Resource, + ResourceDigest, + SecurityValues, +) + + +class IAM: + def __init__(self, options: SecurityOptions): + self.options = options + + def access_keys_rotated(self, max_age): + + client = self.options.client("iam") + + users = client.list_users() + + resources_found = [] + + for user in users["Users"]: + paginator = client.get_paginator("list_access_keys") + for keys in paginator.paginate(UserName=user["UserName"]): + for key in keys["AccessKeyMetadata"]: + + date_compare = datetime.utcnow() - timedelta(days=int(max_age)) + date_compare = date_compare.replace(tzinfo=pytz.utc) + last_rotate = key["CreateDate"] + + if last_rotate < date_compare: + resources_found.append( + Resource( + digest=ResourceDigest( + id=key["AccessKeyId"], type="access_keys_rotated" + ), + details="You must rotate your keys", + name=key["UserName"], + group="iam_security", + security=SecurityValues( + status="CRITICAL", + parameter="max_age", + value=str(max_age), + ), + ) + ) + + return resources_found diff --git a/cloudiscovery/provider/vpc/resource/compute.py b/cloudiscovery/provider/vpc/resource/compute.py index 92e0450..def22ea 100644 --- a/cloudiscovery/provider/vpc/resource/compute.py +++ b/cloudiscovery/provider/vpc/resource/compute.py @@ -290,13 +290,18 @@ def get_resources(self) -> List[Resource]: digest = ResourceDigest( id=asg_name, type="aws_autoscaling_group" ) + if "LaunchConfigurationName" in data: + details = "Using LaunchConfigurationName {0}".format( + data["LaunchConfigurationName"] + ) + else: + details = "Using Launch Template" + resources_found.append( Resource( digest=digest, name=asg_name, - details="Using LaunchConfigurationName {0}".format( - data["LaunchConfigurationName"] - ), + details=details, group="compute", tags=resource_tags(data), ) diff --git a/cloudiscovery/shared/common.py b/cloudiscovery/shared/common.py index 216eb61..acb01a6 100644 --- a/cloudiscovery/shared/common.py +++ b/cloudiscovery/shared/common.py @@ -58,6 +58,12 @@ class LimitsValues(NamedTuple): percent: float +class SecurityValues(NamedTuple): + status: str + parameter: str + value: str + + class ResourceTag(NamedTuple, Filterable): key: str value: str @@ -74,6 +80,7 @@ class Resource(NamedTuple): group: str = "" tags: List[ResourceTag] = [] limits: LimitsValues = None + security: SecurityValues = None attributes: Dict[str, object] = {} diff --git a/cloudiscovery/shared/common_aws.py b/cloudiscovery/shared/common_aws.py index 6719c3b..f467622 100644 --- a/cloudiscovery/shared/common_aws.py +++ b/cloudiscovery/shared/common_aws.py @@ -156,6 +156,9 @@ def init_region_cache(self, region): def resource_tags(resource_data: dict) -> List[ResourceTag]: + if isinstance(resource_data, str): + return [] + if "Tags" in resource_data: tags_input = resource_data["Tags"] elif "tags" in resource_data: diff --git a/docs/assets/role-template.json b/docs/assets/role-template.json new file mode 100644 index 0000000..f3c7c6c --- /dev/null +++ b/docs/assets/role-template.json @@ -0,0 +1,80 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Setups a role for diagram builder for all resources within an account", + "Resources": { + "cloudiscoveryRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument" : { + "Statement" : [ + { + "Effect" : "Allow", + "Principal" : { + "AWS": { "Fn::Join" : [ "", [ + "arn:aws:iam::", { "Ref" : "AWS::AccountId" }, ":root" + ]]} + }, + "Action" : [ "sts:AssumeRole" ] + } + ] + }, + "Policies": [{ + "PolicyName": "additional-permissions", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "kafka:ListClusters", + "synthetics:DescribeCanaries", + "medialive:ListInputs", + "cloudhsm:DescribeClusters", + "ssm:GetParametersByPath", + "servicequotas:Get*", + "amplify:ListApps", + "autoscaling-plans:DescribeScalingPlans", + "medialive:ListChannels", + "medialive:ListInputDevices", + "mediapackage:ListChannels", + "qldb:ListLedgers", + "transcribe:ListVocabularies", + "glue:GetDatabases", + "glue:GetUserDefinedFunctions", + "glue:GetSecurityConfigurations", + "glue:GetTriggers", + "glue:GetCrawlers", + "glue:ListWorkflows", + "glue:ListMLTransforms", + "codeguru-reviewer:ListCodeReviews", + "servicediscovery:ListNamespaces", + "apigateway:GET", + "forecast:ListPredictors", + "frauddetector:GetDetectors", + "forecast:ListDatasetImportJobs", + "frauddetector:GetModels", + "frauddetector:GetOutcomes", + "networkmanager:DescribeGlobalNetworks", + "codeartifact:ListDomains", + "ses:GetSendQuota", + "codeguru-profiler:ListProfilingGroups" + ], + "Resource": [ "*" ] + } + ] + } + }], + "Path" : "/", + "ManagedPolicyArns" : [ + "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess", + "arn:aws:iam::aws:policy/SecurityAudit" + ] + } + } + }, + "Outputs" : { + "cloudiscoveryRoleArn" : { + "Value" : { "Fn::GetAtt": [ "cloudiscoveryRole", "Arn" ]} + } + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f1d57be..a97354b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ ipaddress jinja2<3.0 diagrams>=0.14 cachetools -diskcache \ No newline at end of file +diskcache +pytz \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 425a093..453c05c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,4 +10,5 @@ requires-dist = ipaddress>=1.0.23 diagrams>=0.13 jinja2<3.0 - cachetools>=4.1.0 \ No newline at end of file + cachetools>=4.1.0 + pytz \ No newline at end of file diff --git a/setup.py b/setup.py index cc79766..f4826f4 100755 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ "jinja2<3.0", "cachetools", "diskcache", + "pytz", ]