diff --git a/prowler/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/__init__.py b/prowler/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/prowler/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/servicecatalog_portfolio_shared_within_organization_only.metadata.json b/prowler/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/servicecatalog_portfolio_shared_within_organization_only.metadata.json new file mode 100644 index 00000000000..be93d476e32 --- /dev/null +++ b/prowler/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/servicecatalog_portfolio_shared_within_organization_only.metadata.json @@ -0,0 +1,34 @@ +{ + "Provider": "aws", + "CheckID": "servicecatalog_portfolio_shared_within_organization_only", + "CheckTitle": "Service Catalog portfolios should be shared within an AWS organization only", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "servicecatalog", + "SubServiceName": "", + "ResourceIdTemplate": "arn:aws:servicecatalog:{region}:{account-id}:portfolio/{portfolio-id}", + "Severity": "high", + "ResourceType": "AwsServiceCatalogPortfolio", + "Description": "This control checks whether AWS Service Catalog shares portfolios within an organization when the integration with AWS Organizations is enabled. The control fails if portfolios aren't shared within an organization.", + "Risk": "Sharing Service Catalog portfolios outside of an organization may result in access granted to unintended AWS accounts, potentially exposing sensitive resources.", + "RelatedUrl": "https://docs.aws.amazon.com/servicecatalog/latest/adminguide/catalogs_portfolios_sharing.html", + "Remediation": { + "Code": { + "CLI": "aws servicecatalog create-portfolio-share --portfolio-id --organization-ids ", + "NativeIaC": "", + "Other": "https://docs.aws.amazon.com/servicecatalog/latest/adminguide/catalogs_portfolios_sharing.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure AWS Service Catalog to share portfolios only within your AWS Organization for more secure access management.", + "Url": "https://docs.aws.amazon.com/servicecatalog/latest/adminguide/catalogs_portfolios_sharing.html" + } + }, + "Categories": [ + "trustboundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/servicecatalog_portfolio_shared_within_organization_only.py b/prowler/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/servicecatalog_portfolio_shared_within_organization_only.py new file mode 100644 index 00000000000..b6390bacb2d --- /dev/null +++ b/prowler/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/servicecatalog_portfolio_shared_within_organization_only.py @@ -0,0 +1,32 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.organizations.organizations_client import ( + organizations_client, +) +from prowler.providers.aws.services.servicecatalog.servicecatalog_client import ( + servicecatalog_client, +) + + +class servicecatalog_portfolio_shared_within_organization_only(Check): + def execute(self): + findings = [] + for org in organizations_client.organizations: + if org.status == "ACTIVE": + for portfolio in servicecatalog_client.portfolios.values(): + if portfolio.shares is not None: + report = Check_Report_AWS(self.metadata()) + report.region = portfolio.region + report.resource_id = portfolio.id + report.resource_arn = portfolio.arn + report.resource_tags = portfolio.tags + report.status = "PASS" + report.status_extended = f"ServiceCatalog Portfolio {portfolio.name} is shared within your AWS Organization." + for portfolio_share in portfolio.shares: + if portfolio_share.type == "ACCOUNT": + report.status = "FAIL" + report.status_extended = f"ServiceCatalog Portfolio {portfolio.name} is shared with an account." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/servicecatalog/servicecatalog_service.py b/prowler/providers/aws/services/servicecatalog/servicecatalog_service.py index 31a1f798fed..2950d234b2c 100644 --- a/prowler/providers/aws/services/servicecatalog/servicecatalog_service.py +++ b/prowler/providers/aws/services/servicecatalog/servicecatalog_service.py @@ -9,7 +9,7 @@ PORTFOLIO_SHARE_TYPES = [ "ACCOUNT", "ORGANIZATION", - "ORGANIZATION_UNIT", + "ORGANIZATIONAL_UNIT", "ORGANIZATION_MEMBER_ACCOUNT", ] @@ -48,9 +48,9 @@ def _list_portfolios(self, regional_client): def _describe_portfolio_shares(self, portfolio): try: logger.info("ServiceCatalog - describing portfolios shares...") - try: - regional_client = self.regional_clients[portfolio.region] - for portfolio_type in PORTFOLIO_SHARE_TYPES: + regional_client = self.regional_clients[portfolio.region] + for portfolio_type in PORTFOLIO_SHARE_TYPES: + try: for share in regional_client.describe_portfolio_shares( PortfolioId=portfolio.id, Type=portfolio_type, @@ -60,10 +60,16 @@ def _describe_portfolio_shares(self, portfolio): accepted=share["Accepted"], ) portfolio.shares.append(portfolio_share) - except Exception as error: - logger.error( - f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) + except Exception as error: + if error.response["Error"]["Code"] == "AccessDeniedException": + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + portfolio.shares = None + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -75,7 +81,7 @@ def _describe_portfolio(self, portfolio): try: regional_client = self.regional_clients[portfolio.region] portfolio.tags = regional_client.describe_portfolio( - PortfolioId=portfolio.id, + Id=portfolio.id, )["Tags"] except Exception as error: logger.error( diff --git a/tests/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/servicecatalog_portfolio_shared_within_organization_only_test.py b/tests/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/servicecatalog_portfolio_shared_within_organization_only_test.py new file mode 100644 index 00000000000..4b47b5d7927 --- /dev/null +++ b/tests/providers/aws/services/servicecatalog/servicecatalog_portfolio_shared_within_organization_only/servicecatalog_portfolio_shared_within_organization_only_test.py @@ -0,0 +1,187 @@ +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.organizations.organizations_service import ( + Organizations, +) +from prowler.providers.aws.services.servicecatalog.servicecatalog_service import ( + ServiceCatalog, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListPortfolios": + return { + "PortfolioDetails": [ + { + "Id": "portfolio-account-test", + "ARN": "arn:aws:servicecatalog:eu-west-1:123456789012:portfolio/portfolio-account-test", + "DisplayName": "portfolio-account", + } + ], + } + elif operation_name == "DescribePortfolioShares": + return { + "PortfolioShareDetails": [ + { + "Type": "ACCOUNT", + "Accepted": True, + } + ], + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_v2(self, operation_name, kwarg): + if operation_name == "ListPortfolios": + return { + "PortfolioDetails": [ + { + "Id": "portfolio-org-test", + "ARN": "arn:aws:servicecatalog:eu-west-1:123456789012:portfolio/portfolio-org-test", + "DisplayName": "portfolio-org", + } + ], + } + elif operation_name == "DescribePortfolioShares": + if kwarg["type"] == "ACCOUNT": + return { + "PortfolioShareDetails": [ + { + "Type": "ORGANIZATION", + "Accepted": True, + } + ], + } + return make_api_call(self, operation_name, kwarg) + + +class Test_servicecatalog_portfolio_shared_within_organization_only: + def test_no_portfolios(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), mock.patch( + "prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_client", + new=ServiceCatalog(aws_provider), + ): + from prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only import ( + servicecatalog_portfolio_shared_within_organization_only, + ) + + check = servicecatalog_portfolio_shared_within_organization_only() + result = check.execute() + assert len(result) == 0 + + @mock_aws + @mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + def test_organizations_not_active(self): + client("servicecatalog", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1], create_default_organization=False + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), mock.patch( + "prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_client", + new=ServiceCatalog(aws_provider), + ), mock.patch( + "prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only.organizations_client", + new=Organizations(aws_provider), + ): + from prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only import ( + servicecatalog_portfolio_shared_within_organization_only, + ) + + check = servicecatalog_portfolio_shared_within_organization_only() + result = check.execute() + assert len(result) == 0 + + @mock_aws + @mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + def test_portfolio_share_account(self): + client("servicecatalog", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + conn = client("organizations") + conn.create_organization() + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), mock.patch( + "prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_client", + new=ServiceCatalog(aws_provider), + ), mock.patch( + "prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only.organizations_client", + new=Organizations(aws_provider), + ): + from prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only import ( + servicecatalog_portfolio_shared_within_organization_only, + ) + + check = servicecatalog_portfolio_shared_within_organization_only() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "ServiceCatalog Portfolio portfolio-account is shared with an account." + ) + assert result[0].resource_id == "portfolio-account-test" + assert ( + result[0].resource_arn + == f"arn:aws:servicecatalog:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:portfolio/portfolio-account-test" + ) + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + @mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call_v2) + def test_portfolio_share_organization(self): + client("servicecatalog", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + conn = client("organizations") + conn.create_organization() + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), mock.patch( + "prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_client", + new=ServiceCatalog(aws_provider), + ), mock.patch( + "prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only.organizations_client", + new=Organizations(aws_provider), + ): + from prowler.providers.aws.services.servicecatalog.servicecatalog_portfolio_shared_within_organization_only.servicecatalog_portfolio_shared_within_organization_only import ( + servicecatalog_portfolio_shared_within_organization_only, + ) + + check = servicecatalog_portfolio_shared_within_organization_only() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "ServiceCatalog Portfolio portfolio-org is shared within your AWS Organization." + ) + assert result[0].resource_id == "portfolio-org-test" + assert ( + result[0].resource_arn + == f"arn:aws:servicecatalog:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:portfolio/portfolio-org-test" + ) + assert result[0].region == AWS_REGION_EU_WEST_1