feat(rolesanywhere): flag profiles with unscoped sessions

This commit is contained in:
pedrooot
2026-08-10 15:25:16 -07:00
parent d3524d50fb
commit 88a8068773
8 changed files with 310 additions and 0 deletions
@@ -41,6 +41,7 @@
"lightsail:GetRelationalDatabases",
"macie2:GetMacieSession",
"macie2:GetAutomatedDiscoveryConfiguration",
"rolesanywhere:ListProfiles",
"rolesanywhere:ListTagsForResource",
"rolesanywhere:ListTrustAnchors",
"s3:GetAccountPublicAccessBlock",
@@ -213,6 +213,7 @@ Resources:
- "lightsail:GetRelationalDatabases"
- "macie2:GetMacieSession"
- "macie2:GetAutomatedDiscoveryConfiguration"
- "rolesanywhere:ListProfiles"
- "rolesanywhere:ListTagsForResource"
- "rolesanywhere:ListTrustAnchors"
- "s3:GetAccountPublicAccessBlock"
@@ -0,0 +1,41 @@
{
"Provider": "aws",
"CheckID": "rolesanywhere_profile_restricts_session_permissions",
"CheckTitle": "IAM Roles Anywhere profiles scope down the vended session permissions",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "rolesanywhere",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsRolesAnywhereProfile",
"ResourceGroup": "security",
"Description": "**IAM Roles Anywhere profiles** are assessed for **session scoping**. A profile that defines neither an inline `sessionPolicy` nor `managedPolicyArns` vends temporary credentials with the full permissions of every role it references, so any certificate accepted by the trust anchor obtains those permissions in full.",
"Risk": "Roles Anywhere profiles bind X.509 certificates to IAM roles. Without a session policy or managed policies scoping the session down, vended credentials carry the full permissions of the referenced role(s). An attacker presenting a valid certificate - or planting a rogue trust anchor and profile - gains durable privileged access that rotating IAM keys does not revoke.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html",
"https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateProfile.html",
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/security-best-practices.html"
],
"Remediation": {
"Code": {
"CLI": "aws rolesanywhere update-profile --profile-id <profile_id> --session-policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"<least_privilege_actions>\"],\"Resource\":[\"<scoped_resources>\"]}]}'",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::RolesAnywhere::Profile\n Properties:\n Name: scoped-profile\n Enabled: true\n RoleArns:\n - <role_arn>\n SessionPolicy: '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"<least_privilege_actions>\"],\"Resource\":[\"<scoped_resources>\"]}]}' # FIX: scope down the vended session\n```",
"Other": "1. Identify the least-privilege actions the workload actually needs\n2. Attach a sessionPolicy or managedPolicyArns to the Roles Anywhere profile that grants only those actions\n3. Prefer purpose-built roles per workload over broad roles referenced by many profiles\n4. Review trust anchors and profiles regularly for entries you did not create",
"Terraform": "```hcl\nresource \"aws_rolesanywhere_profile\" \"<example_resource_name>\" {\n name = \"scoped-profile\"\n enabled = true\n role_arns = [<role_arn>]\n session_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [<least_privilege_actions>]\n Resource = [<scoped_resources>]\n }]\n }) # FIX: scope down the vended session\n}\n```"
},
"Recommendation": {
"Text": "Attach a session policy or managed policies to every enabled IAM Roles Anywhere profile so the vended credentials are scoped below the referenced role's permissions. Pair each profile with a purpose-built least-privilege role and audit trust anchors and profiles regularly for unexpected entries.",
"Url": "https://hub.prowler.com/check/rolesanywhere_profile_restricts_session_permissions"
}
},
"Categories": [
"identity-access",
"trust-boundaries"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Disabled profiles cannot vend session credentials and are reported as PASS. A profile is considered scoped when it defines an inline sessionPolicy or references managed policies; this check does not evaluate whether the referenced role itself is least-privilege."
}
@@ -0,0 +1,45 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import (
rolesanywhere_client,
)
class rolesanywhere_profile_restricts_session_permissions(Check):
"""Verify that IAM Roles Anywhere profiles scope down the vended session.
A Roles Anywhere profile that defines neither an inline ``sessionPolicy`` nor
``managedPolicyArns`` vends temporary credentials carrying the full
permissions of every role it references. Any certificate accepted by the
associated trust anchor can then wield those permissions, turning the
profile into a durable privileged-access path that surviving key rotation
does not remove. Profiles that restrict the session are reported as PASS.
"""
def execute(self) -> list[Check_Report_AWS]:
findings = []
for profile in rolesanywhere_client.profiles.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=profile)
scoped = bool(profile.session_policy) or bool(profile.managed_policy_arns)
if not profile.enabled:
report.status = "PASS"
report.status_extended = (
f"IAM Roles Anywhere profile {profile.name} is disabled and "
"cannot vend session credentials."
)
elif scoped:
report.status = "PASS"
report.status_extended = (
f"IAM Roles Anywhere profile {profile.name} restricts vended "
"session permissions with a session policy or managed policies."
)
else:
report.status = "FAIL"
report.status_extended = (
f"IAM Roles Anywhere profile {profile.name} does not scope down "
"sessions; certificates authenticated through it inherit the full "
f"permissions of its role(s) {', '.join(profile.role_arns) or '<none>'}, "
"enabling durable privileged access."
)
findings.append(report)
return findings
@@ -11,7 +11,9 @@ class RolesAnywhere(AWSService):
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.trust_anchors = {}
self.profiles = {}
self.__threading_call__(self._list_trust_anchors)
self.__threading_call__(self._list_profiles)
def _list_trust_anchors(self, regional_client):
logger.info("RolesAnywhere - Listing Trust Anchors...")
@@ -52,6 +54,48 @@ class RolesAnywhere(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_profiles(self, regional_client):
logger.info("RolesAnywhere - Listing Profiles...")
try:
paginator = regional_client.get_paginator("list_profiles")
for page in paginator.paginate():
for profile in page.get("profiles", []):
arn = profile.get("profileArn", "")
if not arn:
continue
if self.audit_resources and not is_resource_filtered(
arn, self.audit_resources
):
continue
tags = []
try:
tags = regional_client.list_tags_for_resource(
resourceArn=arn
).get("tags", [])
except Exception as error:
logger.warning(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.profiles[arn] = Profile(
arn=arn,
id=profile.get("profileId", ""),
name=profile.get("name", ""),
region=regional_client.region,
enabled=profile.get("enabled", False),
role_arns=profile.get("roleArns", []) or [],
session_policy=profile.get("sessionPolicy", "") or "",
managed_policy_arns=profile.get("managedPolicyArns", []) or [],
duration_seconds=profile.get("durationSeconds", 0) or 0,
accept_role_session_name=profile.get(
"acceptRoleSessionName", False
),
tags=tags,
)
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class TrustAnchor(BaseModel):
arn: str
@@ -62,3 +106,17 @@ class TrustAnchor(BaseModel):
source_type: str = ""
acm_pca_arn: str = ""
tags: List[Dict[str, str]] = Field(default_factory=list)
class Profile(BaseModel):
arn: str
id: str
name: str
region: str
enabled: bool = False
role_arns: List[str] = Field(default_factory=list)
session_policy: str = ""
managed_policy_arns: List[str] = Field(default_factory=list)
duration_seconds: int = 0
accept_role_session_name: bool = False
tags: List[Dict[str, str]] = Field(default_factory=list)
@@ -0,0 +1,127 @@
from unittest import mock
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import Profile
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
PROFILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
PROFILE_NAME = "workload-profile"
PROFILE_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:profile/{PROFILE_ID}"
ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/workload-role"
MANAGED_POLICY_ARN = "arn:aws:iam::aws:policy/ReadOnlyAccess"
SESSION_POLICY = (
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow",'
'"Action":["s3:GetObject"],"Resource":["*"]}]}'
)
def _profile(
*,
enabled: bool = True,
session_policy: str = "",
managed_policy_arns=None,
role_arns=None,
):
return Profile(
arn=PROFILE_ARN,
id=PROFILE_ID,
name=PROFILE_NAME,
region=AWS_REGION_US_EAST_1,
enabled=enabled,
role_arns=role_arns if role_arns is not None else [ROLE_ARN],
session_policy=session_policy,
managed_policy_arns=managed_policy_arns or [],
)
def _build_client(profiles):
ra_client = mock.MagicMock()
ra_client.profiles = profiles
return ra_client
def _patched(ra_client):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
return [
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.rolesanywhere.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_client",
new=ra_client,
),
]
def _enter(patches):
from contextlib import ExitStack
stack = ExitStack()
for p in patches:
stack.enter_context(p)
return stack
def _run():
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_profile_restricts_session_permissions import (
rolesanywhere_profile_restricts_session_permissions,
)
return rolesanywhere_profile_restricts_session_permissions().execute()
class Test_rolesanywhere_profile_restricts_session_permissions:
def test_no_profiles(self):
with _enter(_patched(_build_client({}))):
assert len(_run()) == 0
def test_unscoped_profile_fails(self):
with _enter(_patched(_build_client({PROFILE_ARN: _profile()}))):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == PROFILE_ID
assert result[0].resource_arn == PROFILE_ARN
assert ROLE_ARN in result[0].status_extended
def test_unscoped_profile_without_roles_fails_with_none_fallback(self):
with _enter(_patched(_build_client({PROFILE_ARN: _profile(role_arns=[])}))):
result = _run()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "<none>" in result[0].status_extended
def test_profile_with_session_policy_passes(self):
with _enter(
_patched(
_build_client({PROFILE_ARN: _profile(session_policy=SESSION_POLICY)})
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
assert "session policy" in result[0].status_extended
def test_profile_with_managed_policies_passes(self):
with _enter(
_patched(
_build_client(
{PROFILE_ARN: _profile(managed_policy_arns=[MANAGED_POLICY_ARN])}
)
)
):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
def test_disabled_profile_passes(self):
with _enter(_patched(_build_client({PROFILE_ARN: _profile(enabled=False)}))):
result = _run()
assert len(result) == 1
assert result[0].status == "PASS"
assert "disabled" in result[0].status_extended
@@ -4,6 +4,7 @@ import botocore
from moto import mock_aws
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import (
Profile,
RolesAnywhere,
TrustAnchor,
)
@@ -16,6 +17,9 @@ from tests.providers.aws.utils import (
TA_ID = "11111111-2222-3333-4444-555555555555"
TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}"
PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/abc"
PROFILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
PROFILE_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:profile/{PROFILE_ID}"
ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/workload-role"
make_api_call = botocore.client.BaseClient._make_api_call
@@ -36,6 +40,21 @@ def mock_make_api_call(self, operation_name, kwarg):
}
]
}
if operation_name == "ListProfiles":
return {
"profiles": [
{
"profileArn": PROFILE_ARN,
"profileId": PROFILE_ID,
"name": "workload-profile",
"enabled": True,
"roleArns": [ROLE_ARN],
"sessionPolicy": '{"Version":"2012-10-17","Statement":[]}',
"durationSeconds": 3600,
"acceptRoleSessionName": False,
}
]
}
if operation_name == "ListTagsForResource":
return {"tags": [{"key": "Environment", "value": "test"}]}
return make_api_call(self, operation_name, kwarg)
@@ -78,6 +97,24 @@ class Test_RolesAnywhere_Service:
assert ta.region == AWS_REGION_US_EAST_1
assert ta.tags == [{"key": "Environment", "value": "test"}]
@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws
def test_list_profiles(self):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
rolesanywhere = RolesAnywhere(aws_provider)
assert len(rolesanywhere.profiles) == 1
profile = rolesanywhere.profiles[PROFILE_ARN]
assert isinstance(profile, Profile)
assert profile.id == PROFILE_ID
assert profile.name == "workload-profile"
assert profile.enabled is True
assert profile.role_arns == [ROLE_ARN]
assert profile.session_policy == '{"Version":"2012-10-17","Statement":[]}'
assert profile.managed_policy_arns == []
assert profile.duration_seconds == 3600
assert profile.region == AWS_REGION_US_EAST_1
assert profile.tags == [{"key": "Environment", "value": "test"}]
@patch(
"botocore.client.BaseClient._make_api_call", new=mock_make_api_call_tags_failure
)