Skip to content

Commit

Permalink
feat(servicecatalog): Add new check `servicecatalog_portfolio_shared_…
Browse files Browse the repository at this point in the history
…within_organization_only` (#5632)

Co-authored-by: Sergio Garcia <38561120+sergargar@users.noreply.github.com>
  • Loading branch information
MarioRgzLpz and MrCloudSec authored Nov 8, 2024
1 parent 23929b3 commit 716558f
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -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 <portfolio-id> --organization-ids <org-id>",
"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": ""
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
PORTFOLIO_SHARE_TYPES = [
"ACCOUNT",
"ORGANIZATION",
"ORGANIZATION_UNIT",
"ORGANIZATIONAL_UNIT",
"ORGANIZATION_MEMBER_ACCOUNT",
]

Expand Down Expand Up @@ -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,
Expand All @@ -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}"
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 716558f

Please # to comment.