mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(rolesanywhere): flag profiles with unscoped sessions
This commit is contained in:
@@ -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"
|
||||
|
||||
+41
@@ -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."
|
||||
}
|
||||
+45
@@ -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)
|
||||
|
||||
+127
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user