feat(sagemaker): add sagemaker_domain_sso_configured check (#11094)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
June
2026-05-14 02:42:30 -07:00
committed by GitHub
parent fb0ef391f2
commit 1f39b01fb2
7 changed files with 414 additions and 2 deletions
+1
View File
@@ -10,6 +10,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `iam_user_access_not_stale_to_sagemaker` check for AWS provider with configurable `max_unused_sagemaker_access_days` (default 90) [(#11000)](https://github.com/prowler-cloud/prowler/pull/11000)
- `cloudtrail_bedrock_logging_enabled` check for AWS provider [(#10858)](https://github.com/prowler-cloud/prowler/pull/10858)
- Okta provider with OAuth 2.0 authentication and `signon_global_session_idle_timeout_15min` check [(#11079)](https://github.com/prowler-cloud/prowler/pull/11079)
- `sagemaker_domain_sso_configured` check for AWS provider [(#11094)](https://github.com/prowler-cloud/prowler/pull/11094)
### 🔄 Changed
@@ -0,0 +1,39 @@
{
"Provider": "aws",
"CheckID": "sagemaker_domain_sso_configured",
"CheckTitle": "SageMaker domains use SSO authentication instead of IAM mode",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "sagemaker",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "**SageMaker Domain** configured with **IAM Identity Center (SSO) authentication**. The check validates that each SageMaker Domain uses SSO mode (`AuthMode: SSO`) and is associated with an IAM Identity Center instance (`SingleSignOnManagedApplicationInstanceId` or `SingleSignOnApplicationArn` present), ensuring user access is centrally managed through AWS IAM Identity Center.",
"Risk": "IAM-mode domains create per-user IAM users or roles managed locally to SageMaker, drifting from the organization's identity provider and weakening lifecycle controls such as offboarding, MFA enforcement, and session policies. SSO-mode domains without an IAM Identity Center association leave authentication in an inconsistent state and bypass centralized access governance.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/sagemaker/latest/dg/onboard-sso-users.html"
],
"Remediation": {
"Code": {
"CLI": "aws sagemaker describe-domain --domain-id <domain_id> --query '{AuthMode:AuthMode,SingleSignOnManagedApplicationInstanceId:SingleSignOnManagedApplicationInstanceId,SingleSignOnApplicationArn:SingleSignOnApplicationArn}'",
"NativeIaC": "```yaml\n# CloudFormation: Create a SageMaker Domain with SSO authentication\nResources:\n SageMakerDomain:\n Type: AWS::SageMaker::Domain\n Properties:\n DomainName: <example_domain_name>\n AuthMode: SSO # Critical: enables IAM Identity Center authentication\n DefaultUserSettings:\n ExecutionRole: <example_role_arn>\n VpcId: <example_vpc_id>\n SubnetIds:\n - <example_subnet_id>\n```",
"Other": "SageMaker Domains cannot be switched from IAM to SSO mode after creation. To remediate, create a new Domain with AuthMode set to SSO and migrate user profiles.",
"Terraform": "```hcl\n# Terraform: Create a SageMaker Domain with SSO authentication\nresource \"aws_sagemaker_domain\" \"example\" {\n domain_name = \"<example_domain_name>\"\n auth_mode = \"SSO\" # Critical: enables IAM Identity Center authentication\n vpc_id = \"<example_vpc_id>\"\n subnet_ids = [\"<example_subnet_id>\"]\n\n default_user_settings {\n execution_role = \"<example_role_arn>\"\n }\n}\n```"
},
"Recommendation": {
"Text": "Configure SageMaker Domains with SSO authentication mode to anchor user access to AWS IAM Identity Center. This enforces centralized identity lifecycle management, MFA policies, and session controls. Domains created with IAM mode must be recreated with SSO mode since the auth mode cannot be changed after creation.",
"Url": "https://hub.prowler.com/check/sagemaker_domain_sso_configured"
}
},
"Categories": [
"identity-access",
"gen-ai"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,27 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client
class sagemaker_domain_sso_configured(Check):
def execute(self):
findings = []
for domain in sagemaker_client.sagemaker_domains:
report = Check_Report_AWS(metadata=self.metadata(), resource=domain)
if domain.auth_mode == "SSO":
if (
domain.single_sign_on_managed_application_instance_id
or domain.single_sign_on_application_arn
):
report.status = "PASS"
report.status_extended = f"SageMaker domain {domain.name} is configured with SSO authentication and is associated with an IAM Identity Center instance."
else:
report.status = "FAIL"
report.status_extended = f"SageMaker domain {domain.name} is configured with SSO authentication but is not associated with an IAM Identity Center instance."
else:
report.status = "FAIL"
current_mode = domain.auth_mode if domain.auth_mode else "unknown"
report.status_extended = f"SageMaker domain {domain.name} is not configured with SSO authentication, current mode is {current_mode}."
findings.append(report)
return findings
@@ -15,6 +15,7 @@ class SageMaker(AWSService):
self.sagemaker_notebook_instances = []
self.sagemaker_models = []
self.sagemaker_training_jobs = []
self.sagemaker_domains = []
self.endpoint_configs = {}
# Retrieve resources concurrently
@@ -22,6 +23,7 @@ class SageMaker(AWSService):
self.__threading_call__(self._list_models)
self.__threading_call__(self._list_training_jobs)
self.__threading_call__(self._list_endpoint_configs)
self.__threading_call__(self._list_domains)
# Describe resources concurrently
self.__threading_call__(self._describe_model, self.sagemaker_models)
@@ -34,6 +36,7 @@ class SageMaker(AWSService):
self.__threading_call__(
self._describe_endpoint_config, list(self.endpoint_configs.values())
)
self.__threading_call__(self._describe_domain, self.sagemaker_domains)
# List tags concurrently for each resource collection
# This replaces the previous sequential sequential execution to improve performance
@@ -47,6 +50,7 @@ class SageMaker(AWSService):
self.__threading_call__(
self._list_tags_for_resource, list(self.endpoint_configs.values())
)
self.__threading_call__(self._list_tags_for_resource, self.sagemaker_domains)
def _list_notebook_instances(self, regional_client):
logger.info("SageMaker - listing notebook instances...")
@@ -218,6 +222,46 @@ class SageMaker(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_domains(self, regional_client):
logger.info("SageMaker - listing domains...")
try:
list_domains_paginator = regional_client.get_paginator("list_domains")
for page in list_domains_paginator.paginate():
for domain in page["Domains"]:
if not self.audit_resources or (
is_resource_filtered(domain["DomainArn"], self.audit_resources)
):
self.sagemaker_domains.append(
Domain(
domain_id=domain["DomainId"],
name=domain["DomainName"],
region=regional_client.region,
arn=domain["DomainArn"],
)
)
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _describe_domain(self, domain):
logger.info("SageMaker - describing domain...")
try:
regional_client = self.regional_clients[domain.region]
describe_domain = regional_client.describe_domain(DomainId=domain.domain_id)
if "AuthMode" in describe_domain:
domain.auth_mode = describe_domain["AuthMode"]
domain.single_sign_on_managed_application_instance_id = describe_domain.get(
"SingleSignOnManagedApplicationInstanceId"
)
domain.single_sign_on_application_arn = describe_domain.get(
"SingleSignOnApplicationArn"
)
except Exception as error:
logger.error(
f"{domain.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_endpoint_configs(self, regional_client):
logger.info("SageMaker - listing endpoint configs...")
try:
@@ -303,6 +347,17 @@ class ProductionVariant(BaseModel):
initial_instance_count: int
class Domain(BaseModel):
domain_id: str
name: str
region: str
arn: str
auth_mode: Optional[str] = None
single_sign_on_managed_application_instance_id: Optional[str] = None
single_sign_on_application_arn: Optional[str] = None
tags: Optional[list] = []
class EndpointConfig(BaseModel):
name: str
region: str
@@ -0,0 +1,234 @@
from unittest import mock
from prowler.providers.aws.services.sagemaker.sagemaker_service import Domain
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
set_mocked_aws_provider,
)
test_domain_name = "test-domain"
test_domain_id = "d-testdomain123"
domain_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:domain/{test_domain_id}"
test_sso_instance_id = "app-test-instance-id"
test_sso_application_arn = (
f"arn:aws:sso::{AWS_ACCOUNT_NUMBER}:application/sagemaker/apl-test"
)
class Test_sagemaker_domain_sso_configured:
def test_no_domains(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_domains = []
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.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import (
sagemaker_domain_sso_configured,
)
check = sagemaker_domain_sso_configured()
result = check.execute()
assert len(result) == 0
def test_domain_sso_configured_with_instance_id(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_domains = [
Domain(
domain_id=test_domain_id,
name=test_domain_name,
arn=domain_arn,
region=AWS_REGION_EU_WEST_1,
auth_mode="SSO",
single_sign_on_managed_application_instance_id=test_sso_instance_id,
)
]
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.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import (
sagemaker_domain_sso_configured,
)
check = sagemaker_domain_sso_configured()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"SageMaker domain {test_domain_name} is configured with SSO authentication and is associated with an IAM Identity Center instance."
)
assert result[0].resource_id == test_domain_name
assert result[0].resource_arn == domain_arn
def test_domain_sso_configured_with_application_arn(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_domains = [
Domain(
domain_id=test_domain_id,
name=test_domain_name,
arn=domain_arn,
region=AWS_REGION_EU_WEST_1,
auth_mode="SSO",
single_sign_on_application_arn=test_sso_application_arn,
)
]
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.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import (
sagemaker_domain_sso_configured,
)
check = sagemaker_domain_sso_configured()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"SageMaker domain {test_domain_name} is configured with SSO authentication and is associated with an IAM Identity Center instance."
)
def test_domain_sso_without_identity_center(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_domains = [
Domain(
domain_id=test_domain_id,
name=test_domain_name,
arn=domain_arn,
region=AWS_REGION_EU_WEST_1,
auth_mode="SSO",
)
]
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.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import (
sagemaker_domain_sso_configured,
)
check = sagemaker_domain_sso_configured()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"SageMaker domain {test_domain_name} is configured with SSO authentication but is not associated with an IAM Identity Center instance."
)
assert result[0].resource_id == test_domain_name
assert result[0].resource_arn == domain_arn
def test_domain_iam_mode(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_domains = [
Domain(
domain_id=test_domain_id,
name=test_domain_name,
arn=domain_arn,
region=AWS_REGION_EU_WEST_1,
auth_mode="IAM",
)
]
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.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import (
sagemaker_domain_sso_configured,
)
check = sagemaker_domain_sso_configured()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"SageMaker domain {test_domain_name} is not configured with SSO authentication, current mode is IAM."
)
assert result[0].resource_id == test_domain_name
assert result[0].resource_arn == domain_arn
def test_domain_auth_mode_unknown(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_domains = [
Domain(
domain_id=test_domain_id,
name=test_domain_name,
arn=domain_arn,
region=AWS_REGION_EU_WEST_1,
)
]
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.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_domain_sso_configured.sagemaker_domain_sso_configured import (
sagemaker_domain_sso_configured,
)
check = sagemaker_domain_sso_configured()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"SageMaker domain {test_domain_name} is not configured with SSO authentication, current mode is unknown."
)
@@ -26,6 +26,13 @@ kms_key_id = str(uuid4())
endpoint_config_name = "endpoint-config-test"
endpoint_config_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:endpoint-config/{endpoint_config_name}"
prod_variant_name = "Variant1"
test_domain_name = "test-domain"
test_domain_id = "d-testdomain123"
test_domain_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:domain/{test_domain_id}"
test_sso_instance_id = "app-test-instance-id"
test_sso_application_arn = (
f"arn:aws:sso::{AWS_ACCOUNT_NUMBER}:application/sagemaker/apl-test"
)
make_api_call = botocore.client.BaseClient._make_api_call
@@ -115,6 +122,25 @@ def mock_make_api_call(self, operation_name, kwarg):
},
]
}
if operation_name == "ListDomains":
return {
"Domains": [
{
"DomainId": test_domain_id,
"DomainName": test_domain_name,
"DomainArn": test_domain_arn,
},
],
}
if operation_name == "DescribeDomain":
return {
"DomainId": test_domain_id,
"DomainName": test_domain_name,
"DomainArn": test_domain_arn,
"AuthMode": "SSO",
"SingleSignOnManagedApplicationInstanceId": test_sso_instance_id,
"SingleSignOnApplicationArn": test_sso_application_arn,
}
return make_api_call(self, operation_name, kwarg)
@@ -249,6 +275,33 @@ class Test_SageMaker_Service:
else:
assert prod_variant.initial_instance_count == 2
# Test SageMaker list domains
def test_list_domains(self):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
sagemaker = SageMaker(aws_provider)
assert len(sagemaker.sagemaker_domains) == 1
assert sagemaker.sagemaker_domains[0].domain_id == test_domain_id
assert sagemaker.sagemaker_domains[0].name == test_domain_name
assert sagemaker.sagemaker_domains[0].arn == test_domain_arn
assert sagemaker.sagemaker_domains[0].region == AWS_REGION_EU_WEST_1
# Test SageMaker describe domain
def test_describe_domain(self):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
sagemaker = SageMaker(aws_provider)
assert len(sagemaker.sagemaker_domains) == 1
assert sagemaker.sagemaker_domains[0].auth_mode == "SSO"
assert (
sagemaker.sagemaker_domains[
0
].single_sign_on_managed_application_instance_id
== test_sso_instance_id
)
assert (
sagemaker.sagemaker_domains[0].single_sign_on_application_arn
== test_sso_application_arn
)
# Test SageMaker _list_tags_for_resource
def test_list_tags_for_resource_calls_client(self):
"""Test that _list_tags_for_resource calls the correct AWS client and updates the resource."""
@@ -312,14 +365,17 @@ class Test_SageMaker_Service:
patch(
"prowler.providers.aws.services.sagemaker.sagemaker_service.SageMaker._list_endpoint_configs"
),
patch(
"prowler.providers.aws.services.sagemaker.sagemaker_service.SageMaker._list_domains"
),
):
sagemaker_service = SageMaker(audit_info)
# Check that __threading_call__ was called for _list_tags_for_resource
# (at least 4 calls expected, one for each resource type)
# (one for each resource type: models, notebooks, training jobs, endpoint configs, domains)
tag_calls = [
c
for c in mock_threading_call.call_args_list
if c[0][0] == sagemaker_service._list_tags_for_resource
]
assert len(tag_calls) == 4
assert len(tag_calls) == 5