Merge branch 'master' into PRWLR-4758-django-must-support-read-only-replica-database-config

This commit is contained in:
Víctor Fernández Poyatos
2025-03-13 12:50:16 +01:00
41 changed files with 5272 additions and 54 deletions
+2 -2
View File
@@ -72,9 +72,9 @@ It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, Fe
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) |
|---|---|---|---|---|
| AWS | 564 | 82 | 33 | 10 |
| GCP | 77 | 13 | 5 | 3 |
| GCP | 77 | 13 | 6 | 3 |
| Azure | 140 | 18 | 7 | 3 |
| Kubernetes | 83 | 7 | 2 | 7 |
| Kubernetes | 83 | 7 | 4 | 7 |
| Microsoft365 | 5 | 2 | 1 | 0 |
> You can list the checks, services, compliance frameworks and categories with `prowler <provider> --list-checks`, `prowler <provider> --list-services`, `prowler <provider> --list-compliance` and `prowler <provider> --list-categories`.
+2 -2
View File
@@ -5,8 +5,8 @@ All notable changes to the **Prowler API** are documented in this file.
---
## [v1.6.0] (Prowler UNRELEASED)
### Added
- Support for developing new integrations [(#7167)](https://github.com/prowler-cloud/prowler/pull/7167).
- Support for read only replicas in the database [(#7210)](https://github.com/prowler-cloud/prowler/pull/7210).
---
@@ -29,6 +29,6 @@ All notable changes to the **Prowler API** are documented in this file.
- Daily scheduled scan instances are now created beforehand with `SCHEDULED` state [(#6700)](https://github.com/prowler-cloud/prowler/pull/6700).
- Findings endpoints now require at least one date filter [(#6800)](https://github.com/prowler-cloud/prowler/pull/6800).
- Findings metadata endpoint received a performance improvement [(#6863)](https://github.com/prowler-cloud/prowler/pull/6863).
- Increase the allowed length of the provider UID for Kubernetes providers [(#6869)](https://github.com/prowler-cloud/prowler/pull/6869).
- Increased the allowed length of the provider UID for Kubernetes providers [(#6869)](https://github.com/prowler-cloud/prowler/pull/6869).
---
+12
View File
@@ -318,3 +318,15 @@ class InvitationStateEnum(EnumType):
class InvitationStateEnumField(PostgresEnumField):
def __init__(self, *args, **kwargs):
super().__init__("invitation_state", *args, **kwargs)
# Postgres enum definition for Integration type
class IntegrationTypeEnum(EnumType):
enum_type_name = "integration_type"
class IntegrationTypeEnumField(PostgresEnumField):
def __init__(self, *args, **kwargs):
super().__init__("integration_type", *args, **kwargs)
+17
View File
@@ -24,6 +24,7 @@ from api.db_utils import (
from api.models import (
ComplianceOverview,
Finding,
Integration,
Invitation,
Membership,
PermissionChoices,
@@ -648,3 +649,19 @@ class ServiceOverviewFilter(ScanSummaryFilter):
}
)
return super().is_valid()
class IntegrationFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
integration_type = ChoiceFilter(choices=Integration.IntegrationChoices.choices)
integration_type__in = ChoiceInFilter(
choices=Integration.IntegrationChoices.choices,
field_name="integration_type",
lookup_expr="in",
)
class Meta:
model = Integration
fields = {
"inserted_at": ["date", "gte", "lte"],
}
@@ -0,0 +1,35 @@
# Generated by Django 5.1.5 on 2025-03-03 15:46
from functools import partial
from django.db import migrations
from api.db_utils import IntegrationTypeEnum, PostgresEnumMigration, register_enum
from api.models import Integration
IntegrationTypeEnumMigration = PostgresEnumMigration(
enum_name="integration_type",
enum_values=tuple(
integration_type[0]
for integration_type in Integration.IntegrationChoices.choices
),
)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("api", "0012_scan_report_output"),
]
operations = [
migrations.RunPython(
IntegrationTypeEnumMigration.create_enum_type,
reverse_code=IntegrationTypeEnumMigration.drop_enum_type,
),
migrations.RunPython(
partial(register_enum, enum_class=IntegrationTypeEnum),
reverse_code=migrations.RunPython.noop,
),
]
@@ -0,0 +1,131 @@
# Generated by Django 5.1.5 on 2025-03-03 15:46
import uuid
import django.db.models.deletion
from django.db import migrations, models
import api.db_utils
import api.rls
from api.rls import RowLevelSecurityConstraint
class Migration(migrations.Migration):
dependencies = [
("api", "0013_integrations_enum"),
]
operations = [
migrations.CreateModel(
name="Integration",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("enabled", models.BooleanField(default=False)),
("connected", models.BooleanField(blank=True, null=True)),
(
"connection_last_checked_at",
models.DateTimeField(blank=True, null=True),
),
(
"integration_type",
api.db_utils.IntegrationTypeEnumField(
choices=[
("amazon_s3", "Amazon S3"),
("saml", "SAML"),
("aws_security_hub", "AWS Security Hub"),
("jira", "JIRA"),
("slack", "Slack"),
]
),
),
("configuration", models.JSONField(default=dict)),
("_credentials", models.BinaryField(db_column="credentials")),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={"db_table": "integrations", "abstract": False},
),
migrations.AddConstraint(
model_name="integration",
constraint=RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_integration",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.CreateModel(
name="IntegrationProviderRelationship",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
(
"integration",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="api.integration",
),
),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.provider"
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "integration_provider_mappings",
"constraints": [
models.UniqueConstraint(
fields=("integration_id", "provider_id"),
name="unique_integration_provider_rel",
),
],
},
),
migrations.AddConstraint(
model_name="IntegrationProviderRelationship",
constraint=RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_integrationproviderrelationship",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddField(
model_name="integration",
name="providers",
field=models.ManyToManyField(
blank=True,
related_name="integrations",
through="api.IntegrationProviderRelationship",
to="api.provider",
),
),
]
+78
View File
@@ -21,6 +21,7 @@ from uuid6 import uuid7
from api.db_utils import (
CustomUserManager,
FindingDeltaEnumField,
IntegrationTypeEnumField,
InvitationStateEnumField,
MemberRoleEnumField,
ProviderEnumField,
@@ -1138,3 +1139,80 @@ class ScanSummary(RowLevelSecurityProtectedModel):
class JSONAPIMeta:
resource_name = "scan-summaries"
class Integration(RowLevelSecurityProtectedModel):
class IntegrationChoices(models.TextChoices):
S3 = "amazon_s3", _("Amazon S3")
SAML = "saml", _("SAML")
AWS_SECURITY_HUB = "aws_security_hub", _("AWS Security Hub")
JIRA = "jira", _("JIRA")
SLACK = "slack", _("Slack")
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
enabled = models.BooleanField(default=False)
connected = models.BooleanField(null=True, blank=True)
connection_last_checked_at = models.DateTimeField(null=True, blank=True)
integration_type = IntegrationTypeEnumField(choices=IntegrationChoices.choices)
configuration = models.JSONField(default=dict)
_credentials = models.BinaryField(db_column="credentials")
providers = models.ManyToManyField(
Provider,
related_name="integrations",
through="IntegrationProviderRelationship",
blank=True,
)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "integrations"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class JSONAPIMeta:
resource_name = "integrations"
@property
def credentials(self):
if isinstance(self._credentials, memoryview):
encrypted_bytes = self._credentials.tobytes()
elif isinstance(self._credentials, str):
encrypted_bytes = self._credentials.encode()
else:
encrypted_bytes = self._credentials
decrypted_data = fernet.decrypt(encrypted_bytes)
return json.loads(decrypted_data.decode())
@credentials.setter
def credentials(self, value):
encrypted_data = fernet.encrypt(json.dumps(value).encode())
self._credentials = encrypted_data
class IntegrationProviderRelationship(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
integration = models.ForeignKey(Integration, on_delete=models.CASCADE)
provider = models.ForeignKey(Provider, on_delete=models.CASCADE)
inserted_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "integration_provider_mappings"
constraints = [
models.UniqueConstraint(
fields=["integration_id", "provider_id"],
name="unique_integration_provider_rel",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
+10 -6
View File
@@ -58,11 +58,11 @@ class RowLevelSecurityConstraint(models.BaseConstraint):
drop_sql_query = """
ALTER TABLE %(table_name)s NO FORCE ROW LEVEL SECURITY;
ALTER TABLE %(table_name)s DISABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE %(table_name) TO %(db_user)s;
REVOKE ALL ON TABLE %(table_name)s FROM %(db_user)s;
"""
drop_policy_sql_query = """
DROP POLICY IF EXISTS %(db_user)s_%(table_name)s_{statement} on %(table_name)s;
DROP POLICY IF EXISTS %(db_user)s_%(raw_table_name)s_{statement} ON %(table_name)s;
"""
def __init__(
@@ -104,16 +104,20 @@ class RowLevelSecurityConstraint(models.BaseConstraint):
def remove_sql(self, model: Any, schema_editor: Any) -> Any:
field_column = schema_editor.quote_name(self.target_field)
raw_table_name = model._meta.db_table
table_name = raw_table_name
if self.partition_name:
raw_table_name = f"{raw_table_name}_{self.partition_name}"
table_name = raw_table_name
full_drop_sql_query = (
f"{self.drop_sql_query}"
f"{''.join([self.drop_policy_sql_query.format(statement) for statement in self.statements])}"
f"{''.join([self.drop_policy_sql_query.format(statement=statement) for statement in self.statements])}"
)
table_name = model._meta.db_table
if self.partition_name:
table_name = f"{table_name}_{self.partition_name}"
return Statement(
full_drop_sql_query,
table_name=Table(table_name, schema_editor.quote_name),
raw_table_name=raw_table_name,
field_column=field_column,
db_user=DB_USER,
partition_name=self.partition_name,
File diff suppressed because it is too large Load Diff
+106 -1
View File
@@ -1,7 +1,19 @@
from unittest.mock import ANY, Mock, patch
import pytest
from django.urls import reverse
from rest_framework import status
from unittest.mock import patch, ANY, Mock
from api.models import (
Membership,
ProviderGroup,
ProviderGroupMembership,
Role,
RoleProviderGroupRelationship,
User,
UserRoleRelationship,
)
from api.v1.serializers import TokenSerializer
@pytest.mark.django_db
@@ -304,3 +316,96 @@ class TestProviderViewSet:
reverse("provider-connection", kwargs={"pk": provider.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
class TestLimitedVisibility:
TEST_EMAIL = "rbac@rbac.com"
TEST_PASSWORD = "thisisapassword123"
@pytest.fixture
def limited_admin_user(
self, django_db_setup, django_db_blocker, tenants_fixture, providers_fixture
):
with django_db_blocker.unblock():
tenant = tenants_fixture[0]
provider = providers_fixture[0]
user = User.objects.create_user(
name="testing",
email=self.TEST_EMAIL,
password=self.TEST_PASSWORD,
)
Membership.objects.create(
user=user,
tenant=tenant,
role=Membership.RoleChoices.OWNER,
)
role = Role.objects.create(
name="limited_visibility",
tenant=tenant,
manage_users=True,
manage_account=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=False,
)
UserRoleRelationship.objects.create(
user=user,
role=role,
tenant=tenant,
)
provider_group = ProviderGroup.objects.create(
name="limited_visibility_group",
tenant=tenant,
)
ProviderGroupMembership.objects.create(
tenant=tenant,
provider=provider,
provider_group=provider_group,
)
RoleProviderGroupRelationship.objects.create(
tenant=tenant, role=role, provider_group=provider_group
)
return user
@pytest.fixture
def authenticated_client_rbac_limited(
self, limited_admin_user, tenants_fixture, client
):
client.user = limited_admin_user
tenant_id = tenants_fixture[0].id
serializer = TokenSerializer(
data={
"type": "tokens",
"email": self.TEST_EMAIL,
"password": self.TEST_PASSWORD,
"tenant_id": tenant_id,
}
)
serializer.is_valid(raise_exception=True)
access_token = serializer.validated_data["access"]
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
return client
def test_integrations(
self, authenticated_client_rbac_limited, integrations_fixture, providers_fixture
):
# Integration 2 is related to provider1 and provider 2
# This user cannot see provider 2
integration = integrations_fixture[1]
response = authenticated_client_rbac_limited.get(
reverse("integration-detail", kwargs={"pk": integration.id})
)
assert response.status_code == status.HTTP_200_OK
assert integration.providers.count() == 2
assert (
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
)
+413
View File
@@ -14,6 +14,7 @@ from django.urls import reverse
from rest_framework import status
from api.models import (
Integration,
Invitation,
Membership,
Provider,
@@ -4571,3 +4572,415 @@ class TestScheduleViewSet:
reverse("schedule-daily"), data=json_payload, format="json"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
class TestIntegrationViewSet:
def test_integrations_list(self, authenticated_client, integrations_fixture):
response = authenticated_client.get(reverse("integration-list"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(integrations_fixture)
def test_integrations_retrieve(self, authenticated_client, integrations_fixture):
integration1, *_ = integrations_fixture
response = authenticated_client.get(
reverse("integration-detail", kwargs={"pk": integration1.id}),
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["id"] == str(integration1.id)
assert (
response.json()["data"]["attributes"]["configuration"]
== integration1.configuration
)
def test_integrations_invalid_retrieve(self, authenticated_client):
response = authenticated_client.get(
reverse(
"integration-detail",
kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"include_values, expected_resources",
[
("providers", ["providers"]),
],
)
def test_integrations_list_include(
self,
include_values,
expected_resources,
authenticated_client,
integrations_fixture,
):
response = authenticated_client.get(
reverse("integration-list"), {"include": include_values}
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(integrations_fixture)
assert "included" in response.json()
included_data = response.json()["included"]
for expected_type in expected_resources:
assert any(
d.get("type") == expected_type for d in included_data
), f"Expected type '{expected_type}' not found in included data"
@pytest.mark.parametrize(
"integration_type, configuration, credentials",
[
# Amazon S3 - AWS credentials
(
Integration.IntegrationChoices.S3,
{
"bucket_name": "bucket-name",
"output_directory": "output-directory",
},
{
"role_arn": "arn:aws",
"external_id": "external-id",
},
),
# Amazon S3 - No credentials (AWS self-hosted)
(
Integration.IntegrationChoices.S3,
{
"bucket_name": "bucket-name",
"output_directory": "output-directory",
},
{},
),
],
)
def test_integrations_create_valid(
self,
authenticated_client,
providers_fixture,
integration_type,
configuration,
credentials,
):
provider = Provider.objects.first()
data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": integration_type,
"configuration": configuration,
"credentials": credentials,
},
"relationships": {
"providers": {
"data": [{"type": "providers", "id": str(provider.id)}]
}
},
}
}
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Integration.objects.count() == 1
integration = Integration.objects.first()
assert integration.configuration == data["data"]["attributes"]["configuration"]
assert (
integration.integration_type
== data["data"]["attributes"]["integration_type"]
)
assert "credentials" not in response.json()["data"]["attributes"]
assert (
str(provider.id)
== data["data"]["relationships"]["providers"]["data"][0]["id"]
)
def test_integrations_create_valid_relationships(
self,
authenticated_client,
providers_fixture,
):
provider1, provider2, *_ = providers_fixture
data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.S3,
"configuration": {
"bucket_name": "bucket-name",
"output_directory": "output-directory",
},
"credentials": {
"role_arn": "arn:aws",
"external_id": "external-id",
},
},
"relationships": {
"providers": {
"data": [
{"type": "providers", "id": str(provider1.id)},
{"type": "providers", "id": str(provider2.id)},
]
}
},
}
}
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Integration.objects.first().providers.count() == 2
@pytest.mark.parametrize(
"attributes, error_code, error_pointer",
(
[
(
{
"integration_type": "whatever",
"configuration": {
"bucket_name": "bucket-name",
"output_directory": "output-directory",
},
"credentials": {
"role_arn": "arn:aws",
"external_id": "external-id",
},
},
"invalid_choice",
"integration_type",
),
(
{
"integration_type": "amazon_s3",
"configuration": {},
"credentials": {
"role_arn": "arn:aws",
"external_id": "external-id",
},
},
"required",
"bucket_name",
),
(
{
"integration_type": "amazon_s3",
"configuration": {
"bucket_name": "bucket_name",
"output_directory": "output_directory",
"invalid_key": "invalid_value",
},
"credentials": {
"role_arn": "arn:aws",
"external_id": "external-id",
},
},
"invalid",
None,
),
(
{
"integration_type": "amazon_s3",
"configuration": {
"bucket_name": "bucket_name",
"output_directory": "output_directory",
},
"credentials": {"invalid_key": "invalid_key"},
},
"invalid",
None,
),
]
),
)
def test_integrations_invalid_create(
self,
authenticated_client,
attributes,
error_code,
error_pointer,
):
data = {
"data": {
"type": "integrations",
"attributes": attributes,
}
}
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["errors"][0]["code"] == error_code
assert (
response.json()["errors"][0]["source"]["pointer"]
== f"/data/attributes/{error_pointer}"
if error_pointer
else "/data"
)
def test_integrations_partial_update(
self, authenticated_client, integrations_fixture
):
integration, *_ = integrations_fixture
data = {
"data": {
"type": "integrations",
"id": str(integration.id),
"attributes": {
"credentials": {
"aws_access_key_id": "new_value",
},
# integration_type is `amazon_s3`
"configuration": {
"bucket_name": "new_bucket_name",
"output_directory": "new_output_directory",
},
},
}
}
response = authenticated_client.patch(
reverse("integration-detail", kwargs={"pk": integration.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
integration.refresh_from_db()
assert integration.credentials["aws_access_key_id"] == "new_value"
assert integration.configuration["bucket_name"] == "new_bucket_name"
assert integration.configuration["output_directory"] == "new_output_directory"
def test_integrations_partial_update_relationships(
self, authenticated_client, integrations_fixture
):
integration, *_ = integrations_fixture
data = {
"data": {
"type": "integrations",
"id": str(integration.id),
"attributes": {
"credentials": {
"aws_access_key_id": "new_value",
},
# integration_type is `amazon_s3`
"configuration": {
"bucket_name": "new_bucket_name",
"output_directory": "new_output_directory",
},
},
"relationships": {"providers": {"data": []}},
}
}
assert integration.providers.count() > 0
response = authenticated_client.patch(
reverse("integration-detail", kwargs={"pk": integration.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
integration.refresh_from_db()
assert integration.providers.count() == 0
def test_integrations_partial_update_invalid_content_type(
self, authenticated_client, integrations_fixture
):
integration, *_ = integrations_fixture
response = authenticated_client.patch(
reverse("integration-detail", kwargs={"pk": integration.id}),
data={},
)
assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
def test_integrations_partial_update_invalid_content(
self, authenticated_client, integrations_fixture
):
integration, *_ = integrations_fixture
data = {
"data": {
"type": "integrations",
"id": str(integration.id),
"attributes": {"invalid_config": "value"},
}
}
response = authenticated_client.patch(
reverse("integration-detail", kwargs={"pk": integration.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_integrations_delete(
self,
authenticated_client,
integrations_fixture,
):
integration, *_ = integrations_fixture
response = authenticated_client.delete(
reverse("integration-detail", kwargs={"pk": integration.id})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_integrations_delete_invalid(self, authenticated_client):
response = authenticated_client.delete(
reverse(
"integration-detail",
kwargs={"pk": "e67d0283-440f-48d1-b5f8-38d0763474f4"},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"filter_name, filter_value, expected_count",
(
[
("inserted_at", TODAY, 2),
("inserted_at.gte", "2024-01-01", 2),
("inserted_at.lte", "2024-01-01", 0),
("integration_type", Integration.IntegrationChoices.S3, 2),
("integration_type", Integration.IntegrationChoices.SLACK, 0),
(
"integration_type__in",
f"{Integration.IntegrationChoices.S3},{Integration.IntegrationChoices.SLACK}",
2,
),
]
),
)
def test_integrations_filters(
self,
authenticated_client,
integrations_fixture,
filter_name,
filter_value,
expected_count,
):
response = authenticated_client.get(
reverse("integration-list"),
{f"filter[{filter_name}]": filter_value},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == expected_count
@pytest.mark.parametrize(
"filter_name",
(
[
"invalid",
]
),
)
def test_integrations_filters_invalid(self, authenticated_client, filter_name):
response = authenticated_client.get(
reverse("integration-list"),
{f"filter[{filter_name}]": "whatever"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -0,0 +1,122 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework_json_api import serializers
from rest_framework_json_api.serializers import ValidationError
class BaseValidateSerializer(serializers.Serializer):
def validate(self, data):
if hasattr(self, "initial_data"):
initial_data = set(self.initial_data.keys()) - {"id", "type"}
unknown_keys = initial_data - set(self.fields.keys())
if unknown_keys:
raise ValidationError(f"Invalid fields: {unknown_keys}")
return data
# Integrations
class S3ConfigSerializer(BaseValidateSerializer):
bucket_name = serializers.CharField()
output_directory = serializers.CharField()
class Meta:
resource_name = "integrations"
class AWSCredentialSerializer(BaseValidateSerializer):
role_arn = serializers.CharField(required=False)
external_id = serializers.CharField(required=False)
role_session_name = serializers.CharField(required=False)
session_duration = serializers.IntegerField(
required=False, min_value=900, max_value=43200
)
aws_access_key_id = serializers.CharField(required=False)
aws_secret_access_key = serializers.CharField(required=False)
aws_session_token = serializers.CharField(required=False)
class Meta:
resource_name = "integrations"
@extend_schema_field(
{
"oneOf": [
{
"type": "object",
"title": "AWS Credentials",
"properties": {
"role_arn": {
"type": "string",
"description": "The Amazon Resource Name (ARN) of the role to assume. Required for AWS role "
"assumption.",
},
"external_id": {
"type": "string",
"description": "An identifier to enhance security for role assumption.",
},
"aws_access_key_id": {
"type": "string",
"description": "The AWS access key ID. Only required if the environment lacks pre-configured "
"AWS credentials.",
},
"aws_secret_access_key": {
"type": "string",
"description": "The AWS secret access key. Required if 'aws_access_key_id' is provided or if "
"no AWS credentials are pre-configured.",
},
"aws_session_token": {
"type": "string",
"description": "The session token for temporary credentials, if applicable.",
},
"session_duration": {
"type": "integer",
"minimum": 900,
"maximum": 43200,
"default": 3600,
"description": "The duration (in seconds) for the role session.",
},
"role_session_name": {
"type": "string",
"description": "An identifier for the role session, useful for tracking sessions in AWS logs. "
"The regex used to validate this parameter is a string of characters consisting of "
"upper- and lower-case alphanumeric characters with no spaces. You can also include "
"underscores or any of the following characters: =,.@-\n\n"
"Examples:\n"
"- MySession123\n"
"- User_Session-1\n"
"- Test.Session@2",
"pattern": "^[a-zA-Z0-9=,.@_-]+$",
},
},
},
]
}
)
class IntegrationCredentialField(serializers.JSONField):
pass
@extend_schema_field(
{
"oneOf": [
{
"type": "object",
"title": "Amazon S3",
"properties": {
"bucket_name": {
"type": "string",
"description": "The name of the S3 bucket where files will be stored.",
},
"output_directory": {
"type": "string",
"description": "The directory path within the bucket where files will be saved.",
},
},
"required": ["bucket_name", "output_directory"],
},
]
}
)
class IntegrationConfigField(serializers.JSONField):
pass
+207 -1
View File
@@ -16,6 +16,8 @@ from rest_framework_simplejwt.tokens import RefreshToken
from api.models import (
ComplianceOverview,
Finding,
Integration,
IntegrationProviderRelationship,
Invitation,
InvitationRoleRelationship,
Membership,
@@ -34,6 +36,12 @@ from api.models import (
UserRoleRelationship,
)
from api.rls import Tenant
from api.v1.serializer_utils.integrations import (
AWSCredentialSerializer,
IntegrationConfigField,
IntegrationCredentialField,
S3ConfigSerializer,
)
# Tokens
@@ -1606,8 +1614,8 @@ class RoleSerializer(RLSSerializer, BaseWriteSerializer):
"manage_account",
# Disable for the first release
# "manage_billing",
# "manage_integrations",
# /Disable for the first release
"manage_integrations",
"manage_providers",
"manage_scans",
"permission_state",
@@ -2013,3 +2021,201 @@ class ScheduleDailyCreateSerializer(serializers.Serializer):
if unknown_keys:
raise ValidationError(f"Invalid fields: {unknown_keys}")
return data
# Integrations
class BaseWriteIntegrationSerializer(BaseWriteSerializer):
@staticmethod
def validate_integration_data(
integration_type: str,
providers: list[Provider], # noqa
configuration: dict,
credentials: dict,
):
if integration_type == Integration.IntegrationChoices.S3:
config_serializer = S3ConfigSerializer
credentials_serializers = [AWSCredentialSerializer]
# TODO: This will be required for AWS Security Hub
# if providers and not all(
# provider.provider == Provider.ProviderChoices.AWS
# for provider in providers
# ):
# raise serializers.ValidationError(
# {"providers": "All providers must be AWS for the S3 integration."}
# )
else:
raise serializers.ValidationError(
{
"integration_type": f"Integration type not supported yet: {integration_type}"
}
)
config_serializer(data=configuration).is_valid(raise_exception=True)
for cred_serializer in credentials_serializers:
try:
cred_serializer(data=credentials).is_valid(raise_exception=True)
break
except ValidationError:
continue
else:
raise ValidationError(
{"credentials": "Invalid credentials for the integration type."}
)
class IntegrationSerializer(RLSSerializer):
"""
Serializer for the Integration model.
"""
providers = serializers.ResourceRelatedField(
queryset=Provider.objects.all(), many=True
)
class Meta:
model = Integration
fields = [
"id",
"inserted_at",
"updated_at",
"enabled",
"connected",
"connection_last_checked_at",
"integration_type",
"configuration",
"providers",
"url",
]
included_serializers = {
"providers": "api.v1.serializers.ProviderIncludeSerializer",
}
def to_representation(self, instance):
representation = super().to_representation(instance)
allowed_providers = self.context.get("allowed_providers")
if allowed_providers:
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
representation["providers"] = [
provider
for provider in representation["providers"]
if provider["id"] in allowed_provider_ids
]
return representation
class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
credentials = IntegrationCredentialField(write_only=True)
configuration = IntegrationConfigField()
providers = serializers.ResourceRelatedField(
queryset=Provider.objects.all(), many=True, required=False
)
class Meta:
model = Integration
fields = [
"inserted_at",
"updated_at",
"enabled",
"connected",
"connection_last_checked_at",
"integration_type",
"configuration",
"credentials",
"providers",
]
extra_kwargs = {
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
"connected": {"read_only": True},
"enabled": {"read_only": True},
"connection_last_checked_at": {"read_only": True},
}
def validate(self, attrs):
integration_type = attrs.get("integration_type")
providers = attrs.get("providers")
configuration = attrs.get("configuration")
credentials = attrs.get("credentials")
validated_attrs = super().validate(attrs)
self.validate_integration_data(
integration_type, providers, configuration, credentials
)
return validated_attrs
def create(self, validated_data):
tenant_id = self.context.get("tenant_id")
providers = validated_data.pop("providers", [])
integration = Integration.objects.create(tenant_id=tenant_id, **validated_data)
through_model_instances = [
IntegrationProviderRelationship(
integration=integration,
provider=provider,
tenant_id=tenant_id,
)
for provider in providers
]
IntegrationProviderRelationship.objects.bulk_create(through_model_instances)
return integration
class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
credentials = IntegrationCredentialField(write_only=True, required=False)
configuration = IntegrationConfigField(required=False)
providers = serializers.ResourceRelatedField(
queryset=Provider.objects.all(), many=True, required=False
)
class Meta:
model = Integration
fields = [
"inserted_at",
"updated_at",
"enabled",
"connected",
"connection_last_checked_at",
"integration_type",
"configuration",
"credentials",
"providers",
]
extra_kwargs = {
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
"connected": {"read_only": True},
"connection_last_checked_at": {"read_only": True},
"integration_type": {"read_only": True},
}
def validate(self, attrs):
integration_type = self.instance.integration_type
providers = attrs.get("providers")
configuration = attrs.get("configuration") or self.instance.configuration
credentials = attrs.get("credentials") or self.instance.credentials
validated_attrs = super().validate(attrs)
self.validate_integration_data(
integration_type, providers, configuration, credentials
)
return validated_attrs
def update(self, instance, validated_data):
tenant_id = self.context.get("tenant_id")
if validated_data.get("providers") is not None:
instance.providers.clear()
new_relationships = [
IntegrationProviderRelationship(
integration=instance, provider=provider, tenant_id=tenant_id
)
for provider in validated_data["providers"]
]
IntegrationProviderRelationship.objects.bulk_create(new_relationships)
return super().update(instance, validated_data)
+2
View File
@@ -10,6 +10,7 @@ from api.v1.views import (
FindingViewSet,
GithubSocialLoginView,
GoogleSocialLoginView,
IntegrationViewSet,
InvitationAcceptViewSet,
InvitationViewSet,
MembershipViewSet,
@@ -47,6 +48,7 @@ router.register(
)
router.register(r"overviews", OverviewViewSet, basename="overview")
router.register(r"schedules", ScheduleViewSet, basename="schedule")
router.register(r"integrations", IntegrationViewSet, basename="integration")
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
tenants_router.register(
+75 -1
View File
@@ -57,6 +57,7 @@ from api.db_router import MainRouter
from api.filters import (
ComplianceOverviewFilter,
FindingFilter,
IntegrationFilter,
InvitationFilter,
MembershipFilter,
ProviderFilter,
@@ -74,6 +75,7 @@ from api.filters import (
from api.models import (
ComplianceOverview,
Finding,
Integration,
Invitation,
Membership,
Provider,
@@ -102,6 +104,9 @@ from api.v1.serializers import (
FindingDynamicFilterSerializer,
FindingMetadataSerializer,
FindingSerializer,
IntegrationCreateSerializer,
IntegrationSerializer,
IntegrationUpdateSerializer,
InvitationAcceptSerializer,
InvitationCreateSerializer,
InvitationSerializer,
@@ -239,7 +244,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.5.0"
spectacular_settings.VERSION = "1.6.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -295,6 +300,11 @@ class SchemaView(SpectacularAPIView):
"description": "Endpoints for task management, allowing retrieval of task status and "
"revoking tasks that have not started.",
},
{
"name": "Integration",
"description": "Endpoints for managing third-party integrations, including registration, configuration,"
" retrieval, and deletion of integrations such as S3, JIRA, or other services.",
},
]
return super().get(request, *args, **kwargs)
@@ -2433,3 +2443,67 @@ class ScheduleViewSet(BaseRLSViewSet):
)
},
)
@extend_schema_view(
list=extend_schema(
tags=["Integration"],
summary="List all integrations",
description="Retrieve a list of all configured integrations with options for filtering by various criteria.",
),
retrieve=extend_schema(
tags=["Integration"],
summary="Retrieve integration details",
description="Fetch detailed information about a specific integration by its ID.",
),
create=extend_schema(
tags=["Integration"],
summary="Create a new integration",
description="Register a new integration with the system, providing necessary configuration details.",
),
partial_update=extend_schema(
tags=["Integration"],
summary="Partially update an integration",
description="Modify certain fields of an existing integration without affecting other settings.",
),
destroy=extend_schema(
tags=["Integration"],
summary="Delete an integration",
description="Remove an integration from the system by its ID.",
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
class IntegrationViewSet(BaseRLSViewSet):
queryset = Integration.objects.all()
serializer_class = IntegrationSerializer
http_method_names = ["get", "post", "patch", "delete"]
filterset_class = IntegrationFilter
ordering = ["integration_type", "-inserted_at"]
# RBAC required permissions
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
allowed_providers = None
def get_queryset(self):
user_roles = get_role(self.request.user)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all integrations
queryset = Integration.objects.filter(tenant_id=self.request.tenant_id)
else:
# User lacks permission, filter providers based on provider groups associated with the role
allowed_providers = get_providers(user_roles)
queryset = Integration.objects.filter(providers__in=allowed_providers)
self.allowed_providers = allowed_providers
return queryset
def get_serializer_class(self):
if self.action == "create":
return IntegrationCreateSerializer
elif self.action == "partial_update":
return IntegrationUpdateSerializer
return super().get_serializer_class()
def get_serializer_context(self):
context = super().get_serializer_context()
context["allowed_providers"] = self.allowed_providers
return context
+42
View File
@@ -15,6 +15,8 @@ from api.db_utils import rls_transaction
from api.models import (
ComplianceOverview,
Finding,
Integration,
IntegrationProviderRelationship,
Invitation,
Membership,
Provider,
@@ -877,6 +879,46 @@ def scan_summaries_fixture(tenants_fixture, providers_fixture):
)
@pytest.fixture
def integrations_fixture(providers_fixture):
provider1, provider2, *_ = providers_fixture
tenant_id = provider1.tenant_id
integration1 = Integration.objects.create(
tenant_id=tenant_id,
enabled=True,
connected=True,
integration_type="amazon_s3",
configuration={"key": "value"},
credentials={"psswd": "1234"},
)
IntegrationProviderRelationship.objects.create(
tenant_id=tenant_id,
integration=integration1,
provider=provider1,
)
integration2 = Integration.objects.create(
tenant_id=tenant_id,
enabled=True,
connected=True,
integration_type="amazon_s3",
configuration={"key": "value"},
credentials={"psswd": "1234"},
)
IntegrationProviderRelationship.objects.create(
tenant_id=tenant_id,
integration=integration2,
provider=provider1,
)
IntegrationProviderRelationship.objects.create(
tenant_id=tenant_id,
integration=integration2,
provider=provider2,
)
return integration1, integration2
def get_authorization_header(access_token: str) -> dict:
return {"Authorization": f"Bearer {access_token}"}
@@ -0,0 +1,23 @@
import warnings
from dashboard.common_methods import get_section_container_iso
warnings.filterwarnings("ignore")
def get_table(data):
aux = data[
[
"REQUIREMENTS_ATTRIBUTES_CATEGORY",
"REQUIREMENTS_ATTRIBUTES_OBJETIVE_ID",
"REQUIREMENTS_ATTRIBUTES_OBJETIVE_NAME",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
]
return get_section_container_iso(
aux, "REQUIREMENTS_ATTRIBUTES_CATEGORY", "REQUIREMENTS_ATTRIBUTES_OBJETIVE_ID"
)
View File
+16
View File
@@ -60,6 +60,9 @@ from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
from prowler.lib.outputs.compliance.iso27001.iso27001_aws import AWSISO27001
from prowler.lib.outputs.compliance.iso27001.iso27001_azure import AzureISO27001
from prowler.lib.outputs.compliance.iso27001.iso27001_gcp import GCPISO27001
from prowler.lib.outputs.compliance.iso27001.iso27001_kubernetes import (
KubernetesISO27001,
)
from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp_aws import AWSKISAISMSP
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_aws import AWSMitreAttack
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import (
@@ -631,6 +634,19 @@ def prowler():
)
generated_outputs["compliance"].append(cis)
cis.batch_write_data_to_file()
elif compliance_name.startswith("iso27001_"):
# Generate ISO27001 Finding Object
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
iso27001 = KubernetesISO27001(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(iso27001)
iso27001.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ class AWSISO27001(ComplianceOutput):
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into AWS ENS compliance format.
- transform: Transforms findings into AWS ISO 27001 compliance format.
"""
def transform(
@@ -23,7 +23,7 @@ class AWSISO27001(ComplianceOutput):
compliance_name: str,
) -> None:
"""
Transforms a list of findings into AWS ENS compliance format.
Transforms a list of findings into AWS ISO 27001 compliance format.
Parameters:
- findings (list): A list of findings.
@@ -13,7 +13,7 @@ class AzureISO27001(ComplianceOutput):
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into AWS ENS compliance format.
- transform: Transforms findings into Azure ISO 27001 compliance format.
"""
def transform(
@@ -23,7 +23,7 @@ class AzureISO27001(ComplianceOutput):
compliance_name: str,
) -> None:
"""
Transforms a list of findings into Azure ENS compliance format.
Transforms a list of findings into Azure ISO 27001 compliance format.
Parameters:
- findings (list): A list of findings.
@@ -13,7 +13,7 @@ class GCPISO27001(ComplianceOutput):
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into AWS ENS compliance format.
- transform: Transforms findings into GCP ISO 27001 compliance format.
"""
def transform(
@@ -0,0 +1,87 @@
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
from prowler.lib.outputs.compliance.iso27001.models import KubernetesISO27001Model
from prowler.lib.outputs.finding import Finding
class KubernetesISO27001(ComplianceOutput):
"""
This class represents the Kubernetes ISO 27001 compliance output.
Attributes:
- _data (list): A list to store transformed data from findings.
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into Kubernetes ISO 27001 compliance format.
"""
def transform(
self,
findings: list[Finding],
compliance: Compliance,
compliance_name: str,
) -> None:
"""
Transforms a list of findings into Kubernetes ISO 27001 compliance format.
Parameters:
- findings (list): A list of findings.
- compliance (Compliance): A compliance model.
- compliance_name (str): The name of the compliance model.
Returns:
- None
"""
for finding in findings:
# Get the compliance requirements for the finding
finding_requirements = finding.compliance.get(compliance_name, [])
for requirement in compliance.Requirements:
if requirement.Id in finding_requirements:
for attribute in requirement.Attributes:
compliance_row = KubernetesISO27001Model(
Provider=finding.provider,
Description=compliance.Description,
Context=finding.account_name,
Namespace=finding.region,
AssessmentDate=str(finding.timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Category=attribute.Category,
Requirements_Attributes_Objetive_ID=attribute.Objetive_ID,
Requirements_Attributes_Objetive_Name=attribute.Objetive_Name,
Requirements_Attributes_Check_Summary=attribute.Check_Summary,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
CheckId=finding.check_id,
Muted=finding.muted,
ResourceName=finding.resource_name,
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = KubernetesISO27001Model(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
Context="",
Namespace="",
AssessmentDate=str(finding.timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Name=requirement.Name,
Requirements_Attributes_Category=attribute.Category,
Requirements_Attributes_Objetive_ID=attribute.Objetive_ID,
Requirements_Attributes_Objetive_Name=attribute.Objetive_Name,
Requirements_Attributes_Check_Summary=attribute.Check_Summary,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
)
self._data.append(compliance_row)
@@ -74,3 +74,28 @@ class GCPISO27001Model(BaseModel):
CheckId: str
Muted: bool
ResourceName: str
class KubernetesISO27001Model(BaseModel):
"""
KubernetesISO27001Model generates a finding's output in CSV Kubernetes ISO27001 format.
"""
Provider: str
Description: str
Context: str
Namespace: str
AssessmentDate: str
Requirements_Id: str
Requirements_Name: str
Requirements_Description: str
Requirements_Attributes_Category: str
Requirements_Attributes_Objetive_ID: str
Requirements_Attributes_Objetive_Name: str
Requirements_Attributes_Check_Summary: str
Status: str
StatusExtended: str
ResourceId: str
CheckId: str
Muted: bool
ResourceName: str
@@ -7,12 +7,15 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
class cloudwatch_alarm_actions_alarm_state_configured(Check):
def execute(self):
findings = []
for metric_alarm in cloudwatch_client.metric_alarms:
report = Check_Report_AWS(metadata=self.metadata(), resource=metric_alarm)
report.status = "PASS"
report.status_extended = f"CloudWatch metric alarm {metric_alarm.name} has actions configured for the ALARM state."
if not metric_alarm.alarm_actions:
report.status = "FAIL"
report.status_extended = f"CloudWatch metric alarm {metric_alarm.name} does not have actions configured for the ALARM state."
findings.append(report)
if cloudwatch_client.metric_alarms is not None:
for metric_alarm in cloudwatch_client.metric_alarms:
report = Check_Report_AWS(
metadata=self.metadata(), resource=metric_alarm
)
report.status = "PASS"
report.status_extended = f"CloudWatch metric alarm {metric_alarm.name} has actions configured for the ALARM state."
if not metric_alarm.alarm_actions:
report.status = "FAIL"
report.status_extended = f"CloudWatch metric alarm {metric_alarm.name} does not have actions configured for the ALARM state."
findings.append(report)
return findings
@@ -7,14 +7,17 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
class cloudwatch_alarm_actions_enabled(Check):
def execute(self):
findings = []
for metric_alarm in cloudwatch_client.metric_alarms:
report = Check_Report_AWS(metadata=self.metadata(), resource=metric_alarm)
report.status = "PASS"
report.status_extended = (
f"CloudWatch metric alarm {metric_alarm.name} has actions enabled."
)
if not metric_alarm.actions_enabled:
report.status = "FAIL"
report.status_extended = f"CloudWatch metric alarm {metric_alarm.name} does not have actions enabled."
findings.append(report)
if cloudwatch_client.metric_alarms is not None:
for metric_alarm in cloudwatch_client.metric_alarms:
report = Check_Report_AWS(
metadata=self.metadata(), resource=metric_alarm
)
report.status = "PASS"
report.status_extended = (
f"CloudWatch metric alarm {metric_alarm.name} has actions enabled."
)
if not metric_alarm.actions_enabled:
report.status = "FAIL"
report.status_extended = f"CloudWatch metric alarm {metric_alarm.name} does not have actions enabled."
findings.append(report)
return findings
@@ -3,28 +3,45 @@ from prowler.providers.aws.services.rds.rds_client import rds_client
class rds_instance_event_subscription_parameter_groups(Check):
"""Ensure RDS parameter group event categories of configuration change are subscribed.
This check is useful to ensure we receive notification of events that may affect the security, availability, and reliability of the RDS database instances associated with these parameter groups.
"""
def execute(self):
"""Execute the RDS Parameter Group events are subscribed check.
Iterates through the RDS DB event subscriptions and checks if the event source is DB parameter group and the event list is empty (so it's suscribe to all categories) or contains only configuration change.
Returns:
List[Check_Report_AWS]: A list of reports for each RDS DB event subscription.
"""
findings = []
if rds_client.provider.scan_unused_services or rds_client.db_instances:
for db_event in rds_client.db_event_subscriptions:
report = Check_Report_AWS(metadata=self.metadata(), resource=db_event)
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "RDS parameter group event categories of configuration change is not subscribed."
report.resource_id = rds_client.audited_account
report.resource_arn = rds_client._get_rds_arn_template(db_event.region)
report.region = db_event.region
report.resource_tags = []
if db_event.source_type == "db-parameter-group" and db_event.enabled:
if db_event.event_list == [] or db_event.event_list == [
"configuration change",
]:
report = Check_Report_AWS(
metadata=self.metadata(), resource=db_event
)
if db_event.source_type == "db-parameter-group":
report = Check_Report_AWS(
metadata=self.metadata(), resource=db_event
)
if db_event.enabled and (
db_event.event_list == []
or db_event.event_list
== [
"configuration change",
]
):
report.status = "PASS"
report.status_extended = (
"RDS parameter group events are subscribed."
)
else:
report.status = "FAIL"
report.status_extended = "RDS parameter group event category of configuration change is not subscribed."
findings.append(report)
return findings
@@ -0,0 +1,30 @@
{
"Provider": "microsoft365",
"CheckID": "entra_admin_mfa_enabled_for_administrative_roles",
"CheckTitle": "Ensure multifactor authentication is enabled for all users in administrative roles.",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Conditional Access Policy",
"Description": "Ensure that multifactor authentication (MFA) is enabled for all users in administrative roles to enhance security and reduce the risk of unauthorized access.",
"Risk": "Without MFA enabled for administrative roles, attackers could compromise privileged accounts with only a single authentication factor, increasing the risk of data breaches and unauthorized access to sensitive resources.",
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-admin-mfa",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Protection > Conditional Access and select Policies. 3. Click 'New policy' and configure: Users: Select users and groups > Directory roles (include admin roles). Target resources: Include 'All cloud apps' with no exclusions. Grant: Select 'Grant Access' and check 'Require multifactor authentication'. 4. Set policy to 'Report Only' for testing before full enforcement. 5. Click 'Create'.",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable MFA for all users in administrative roles using a Conditional Access policy in Microsoft Entra.",
"Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-admin-mfa"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,80 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportMicrosoft365
from prowler.providers.microsoft365.services.entra.entra_client import entra_client
from prowler.providers.microsoft365.services.entra.entra_service import (
AdminRoles,
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
)
class entra_admin_mfa_enabled_for_administrative_roles(Check):
"""
Ensure multifactor authentication is enabled for all users in administrative roles.
This check verifies that at least one Conditional Access Policy in Microsoft Entra, which is in an enabled state,
applies to administrative roles and enforces multifactor authentication (MFA). Enforcing MFA for privileged accounts
is critical to reduce the risk of unauthorized access through compromised credentials.
The check fails if no enabled policy is found that requires MFA for any administrative role.
"""
def execute(self) -> List[CheckReportMicrosoft365]:
"""
Execute the admin MFA requirement check for administrative roles.
Iterates over the Conditional Access Policies retrieved from the Entra client and generates a report
indicating whether MFA is enforced for users in administrative roles.
Returns:
List[CheckReportMicrosoft365]: A list containing a single report with the result of the check.
"""
findings = []
report = CheckReportMicrosoft365(
metadata=self.metadata(),
resource={},
resource_name="Conditional Access Policies",
resource_id="conditionalAccessPolicies",
)
report.status = "FAIL"
report.status_extended = "No Conditional Access Policy requiring MFA for administrative roles was found."
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
continue
if not ({admin_role.value for admin_role in AdminRoles}).issubset(
set(policy.conditions.user_conditions.included_roles)
):
if "All" not in policy.conditions.user_conditions.included_users:
continue
if (
"All"
not in policy.conditions.application_conditions.included_applications
):
continue
if (
ConditionalAccessGrantControl.MFA
in policy.grant_controls.built_in_controls
):
report = CheckReportMicrosoft365(
metadata=self.metadata(),
resource=entra_client.conditional_access_policies,
resource_name=policy.display_name,
resource_id=policy.id,
)
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces MFA for administrative roles."
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' only reports MFA for administrative roles but does not enforce it."
break
findings.append(report)
return findings
@@ -0,0 +1,30 @@
{
"Provider": "microsoft365",
"CheckID": "entra_identity_protection_sign_in_risk_enabled",
"CheckTitle": "Ensure that Identity Protection sign-in risk policies are enabled",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Conditional Access Policy",
"Description": "Ensure that Identity Protection sign-in risk policies are enabled to detect and respond to suspicious high and medium risk login attempts in real time.",
"Risk": "Without Identity Protection sign-in risk policies enabled, suspicious sign-in attempts may go unnoticed, allowing attackers to access accounts using stolen or compromised credentials. This increases the risk of unauthorized access, data breaches, and privilege escalation.",
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. 4. Set the following conditions within the policy. Under Users or workload identities choose All users. Under Cloud apps or actions choose All cloud apps. Under Conditions choose Sign-in risk then Yes and check the risk level boxes High and Medium. Under Access Controls select Grant then in the right pane click Grant access then select Require multifactor authentication. Under Session select Sign-in Frequency and set to Every time. Click Select. 5. Under Enable policy set it to Report Only until the organization is ready to enable it. 6. Click Create.",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable Identity Protection sign-in risk policies to detect and respond to suspicious login attempts in real time. Configure Conditional Access to require MFA for risky sign-ins and ensure all users are enrolled in MFA to prevent account lockouts. Regularly review sign-in risk reports to identify and mitigate potential security threats.",
"Url": "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-risk-policies"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,81 @@
from prowler.lib.check.models import Check, CheckReportMicrosoft365
from prowler.providers.microsoft365.services.entra.entra_client import entra_client
from prowler.providers.microsoft365.services.entra.entra_service import (
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
RiskLevel,
SignInFrequencyInterval,
)
class entra_identity_protection_sign_in_risk_enabled(Check):
"""Check if at least one Conditional Access policy is a Identity Protection sign-in risk policy.
This check ensures that at least one Conditional Access policy is a Identity Protection sign-in risk policy.
"""
def execute(self) -> list[CheckReportMicrosoft365]:
"""Execute the check to ensure that at least one Conditional Access policy is a Identity Protection sign-in risk policy.
Returns:
list[CheckReportMicrosoft365]: A list containing the results of the check.
"""
findings = []
report = CheckReportMicrosoft365(
metadata=self.metadata(),
resource={},
resource_name="Conditional Access Policies",
resource_id="conditionalAccessPolicies",
)
report.status = "FAIL"
report.status_extended = "No Conditional Access Policy is a sign-in risk based Identity Protection Policy."
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
continue
if "All" not in policy.conditions.user_conditions.included_users:
continue
if (
"All"
not in policy.conditions.application_conditions.included_applications
):
continue
if (
ConditionalAccessGrantControl.MFA
not in policy.grant_controls.built_in_controls
):
continue
if (
SignInFrequencyInterval.EVERY_TIME
!= policy.session_controls.sign_in_frequency.interval
):
continue
report = CheckReportMicrosoft365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
if (
RiskLevel.HIGH not in policy.conditions.sign_in_risk_levels
or RiskLevel.MEDIUM not in policy.conditions.sign_in_risk_levels
):
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a sign-in risk based Identity Protection Policy but does not protect against high and medium sign-in risk attempts."
elif policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a sign-in risk based Identity Protection Policy and reports high and medium risk potential sign-in attempts, but does not protect against them."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a sign-in risk based Identity Protection Policy and does protect against high and medium risk potential sign-in attempts."
break
findings.append(report)
return findings
@@ -178,6 +178,14 @@ class Entra(Microsoft365Service):
[],
)
],
sign_in_risk_levels=[
RiskLevel(risk_level)
for risk_level in getattr(
policy.conditions,
"sign_in_risk_levels",
[],
)
],
),
grant_controls=GrantControls(
built_in_controls=(
@@ -347,6 +355,7 @@ class Conditions(BaseModel):
application_conditions: Optional[ApplicationsConditions]
user_conditions: Optional[UsersConditions]
user_risk_levels: List[RiskLevel] = []
sign_in_risk_levels: List[RiskLevel] = []
class PersistentBrowser(BaseModel):
+1 -1
View File
@@ -1,6 +1,6 @@
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core"]
requires = ["poetry-core>=2.0"]
# https://peps.python.org/pep-0621/
[project]
@@ -28,7 +28,6 @@ class Test_cloudwatch_alarm_actions_alarm_state_configured:
new=CloudWatch(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_alarm_actions_alarm_state_configured.cloudwatch_alarm_actions_alarm_state_configured import (
cloudwatch_alarm_actions_alarm_state_configured,
)
@@ -38,6 +37,38 @@ class Test_cloudwatch_alarm_actions_alarm_state_configured:
assert len(result) == 0
@mock_aws
def test_none_cloudwatch_alarms(self):
cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1)
cloudwatch_client.metric_alarms = []
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import (
CloudWatch,
)
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_alarm_actions_alarm_state_configured.cloudwatch_alarm_actions_alarm_state_configured.cloudwatch_client",
new=CloudWatch(aws_provider),
) as cloudwatch_client_mock,
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_alarm_actions_alarm_state_configured.cloudwatch_alarm_actions_alarm_state_configured import (
cloudwatch_alarm_actions_alarm_state_configured,
)
cloudwatch_client_mock.metric_alarms = None
check = cloudwatch_alarm_actions_alarm_state_configured()
result = check.execute()
assert len(result) == 0
@mock_aws
def test_cloudwatch_alarms_actions_configured(self):
cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1)
@@ -66,7 +97,6 @@ class Test_cloudwatch_alarm_actions_alarm_state_configured:
new=CloudWatch(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_alarm_actions_alarm_state_configured.cloudwatch_alarm_actions_alarm_state_configured import (
cloudwatch_alarm_actions_alarm_state_configured,
)
@@ -116,7 +146,6 @@ class Test_cloudwatch_alarm_actions_alarm_state_configured:
new=CloudWatch(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_alarm_actions_alarm_state_configured.cloudwatch_alarm_actions_alarm_state_configured import (
cloudwatch_alarm_actions_alarm_state_configured,
)
@@ -28,7 +28,6 @@ class Test_cloudwatch_alarm_actions_enabled:
new=CloudWatch(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_alarm_actions_enabled.cloudwatch_alarm_actions_enabled import (
cloudwatch_alarm_actions_enabled,
)
@@ -38,6 +37,37 @@ class Test_cloudwatch_alarm_actions_enabled:
assert len(result) == 0
def test_none_cloudwatch_alarms(self):
cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1)
cloudwatch_client.metric_alarms = []
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import (
CloudWatch,
)
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_alarm_actions_enabled.cloudwatch_alarm_actions_enabled.cloudwatch_client",
new=CloudWatch(aws_provider),
) as cloudwatch_client_mock,
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_alarm_actions_enabled.cloudwatch_alarm_actions_enabled import (
cloudwatch_alarm_actions_enabled,
)
cloudwatch_client_mock.metric_alarms = None
check = cloudwatch_alarm_actions_enabled()
result = check.execute()
assert len(result) == 0
@mock_aws
def test_cloudwatch_alarms_actions_enabled(self):
cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1)
@@ -66,7 +96,6 @@ class Test_cloudwatch_alarm_actions_enabled:
new=CloudWatch(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_alarm_actions_enabled.cloudwatch_alarm_actions_enabled import (
cloudwatch_alarm_actions_enabled,
)
@@ -116,7 +145,6 @@ class Test_cloudwatch_alarm_actions_enabled:
new=CloudWatch(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_alarm_actions_enabled.cloudwatch_alarm_actions_enabled import (
cloudwatch_alarm_actions_enabled,
)
@@ -47,6 +47,7 @@ class Test_rds_instance__no_event_subscriptions:
assert result[0].resource_id == AWS_ACCOUNT_NUMBER
assert result[0].resource_arn == RDS_ACCOUNT_ARN
assert result[0].resource_tags == []
assert result[0].resource == {}
@mock_aws
def test_rds_no_events_ignoring(self):
@@ -190,3 +191,61 @@ class Test_rds_instance__no_event_subscriptions:
== f"arn:aws:rds:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:es:TestSub"
)
assert result[0].resource_tags == []
@mock_aws
def test_rds_parameter_event_subscription_not_parameter_group(self):
conn = client("rds", region_name=AWS_REGION_US_EAST_1)
conn.create_db_parameter_group(
DBParameterGroupName="test",
DBParameterGroupFamily="default.aurora-postgresql14",
Description="test parameter group",
)
conn.create_db_instance(
DBInstanceIdentifier="db-master-1",
AllocatedStorage=10,
Engine="aurora-postgresql",
DBName="aurora-postgres",
DBInstanceClass="db.m1.small",
DBParameterGroupName="test",
DBClusterIdentifier="db-cluster-1",
)
conn.create_event_subscription(
SubscriptionName="TestSub",
SnsTopicArn=f"arn:aws:sns:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:test",
SourceType="db-instance",
Enabled=True,
Tags=[
{"Key": "test", "Value": "testing"},
],
)
from prowler.providers.aws.services.rds.rds_service import RDS
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
with mock.patch(
"prowler.providers.aws.services.rds.rds_instance_event_subscription_parameter_groups.rds_instance_event_subscription_parameter_groups.rds_client",
new=RDS(aws_provider),
):
# Test Check
from prowler.providers.aws.services.rds.rds_instance_event_subscription_parameter_groups.rds_instance_event_subscription_parameter_groups import (
rds_instance_event_subscription_parameter_groups,
)
check = rds_instance_event_subscription_parameter_groups()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "RDS parameter group event categories of configuration change is not subscribed."
)
assert result[0].region == AWS_REGION_US_EAST_1
assert result[0].resource_id == AWS_ACCOUNT_NUMBER
assert result[0].resource_arn == RDS_ACCOUNT_ARN
assert result[0].resource_tags == []
assert result[0].resource == {}
@@ -0,0 +1,539 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.microsoft365.services.entra.entra_service import (
ApplicationsConditions,
ConditionalAccessGrantControl,
ConditionalAccessPolicy,
ConditionalAccessPolicyState,
Conditions,
GrantControls,
GrantControlOperator,
PersistentBrowser,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
UsersConditions,
)
from tests.providers.microsoft365.microsoft365_fixtures import (
DOMAIN,
set_mocked_microsoft365_provider,
)
class Test_entra_admin_mfa_enabled_for_administrative_roles:
def test_no_conditional_access_policies(self):
"""No conditional access policies configured: expected FAIL."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import (
entra_admin_mfa_enabled_for_administrative_roles,
)
entra_client.conditional_access_policies = {}
check = entra_admin_mfa_enabled_for_administrative_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requiring MFA for administrative roles was found."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
def test_policy_disabled(self):
"""Policy in DISABLED state: expected to be ignored and return FAIL."""
policy_id = str(uuid4())
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import (
entra_admin_mfa_enabled_for_administrative_roles,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name="Disabled Policy",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"], excluded_applications=[]
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.MFA],
operator=GrantControlOperator.AND,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.DISABLED,
)
}
check = entra_admin_mfa_enabled_for_administrative_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requiring MFA for administrative roles was found."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
def test_policy_missing_admin_roles(self):
"""
Enabled policy that does not apply to administrative roles:
Does not include 'All' in included_users nor administrative roles in included_roles.
Expected FAIL.
"""
policy_id = str(uuid4())
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import (
entra_admin_mfa_enabled_for_administrative_roles,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name="No Admin Roles Policy",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"], excluded_applications=[]
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=[],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.MFA],
operator=GrantControlOperator.AND,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_admin_mfa_enabled_for_administrative_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requiring MFA for administrative roles was found."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
def test_policy_missing_application_all(self):
"""
Enabled policy that includes administrative users (via 'All')
but does not have "All" in included_applications.
Expected FAIL.
"""
policy_id = str(uuid4())
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import (
entra_admin_mfa_enabled_for_administrative_roles,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name="Missing Application All Policy",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["MicrosoftAdminPortals"],
excluded_applications=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.MFA],
operator=GrantControlOperator.AND,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_admin_mfa_enabled_for_administrative_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requiring MFA for administrative roles was found."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
def test_policy_valid(self):
"""
Valid policy:
- State enabled for reporting only
- Applies to administrative roles via 'All' in included_users
- Application conditions include "All"
- MFA is configured in grant_controls
Expected FAIL due to is only for reporting.
"""
policy_id = str(uuid4())
display_name = "Valid MFA Policy"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import (
entra_admin_mfa_enabled_for_administrative_roles,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"], excluded_applications=[]
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.MFA],
operator=GrantControlOperator.AND,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
)
}
check = entra_admin_mfa_enabled_for_administrative_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
expected_status_extended = f"Conditional Access Policy '{display_name}' only reports MFA for administrative roles but does not enforce it."
assert result[0].status_extended == expected_status_extended
assert result[0].resource == entra_client.conditional_access_policies
assert result[0].resource_name == display_name
assert result[0].resource_id == policy_id
def test_policy_valid_through_roles(self):
"""
Valid policy:
- State enabled (ENABLED)
- Applies to administrative roles
- Application conditions include "All"
- MFA is configured in grant_controls
Expected PASS.
"""
policy_id = str(uuid4())
display_name = "Valid MFA Policy"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import (
entra_admin_mfa_enabled_for_administrative_roles,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"], excluded_applications=[]
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=[],
excluded_users=[],
included_roles=[
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3",
"c4e39bd9-1100-46d3-8c65-fb160da0071f",
"b0f54661-2d74-4c50-afa3-1ec803f12efe",
"158c047a-c907-4556-b7ef-446551a6b5f7",
"b1be1c3e-b65d-4f19-8427-f6fa0d97feb9",
"29232cdf-9323-42fd-ade2-1d097af3e4de",
"62e90394-69f5-4237-9190-012177145e10",
"f2ef992c-3afb-46b9-b7cf-a126ee74c451",
"729827e3-9c14-49f7-bb1b-9608f156bbb8",
"966707d0-3269-4727-9be2-8c3a10f19b9d",
"7be44c8a-adaf-4e2a-84d6-ab2649e08a13",
"e8611ab8-c189-46e8-94e1-60213ab1f814",
"194ae4cb-b126-40b2-bd5b-6091b380977d",
"f28a1f50-f6e7-4571-818b-6a12f2af6b6c",
"fe930be7-5e62-47db-91af-98c3a49a38b1",
],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.MFA],
operator=GrantControlOperator.AND,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_admin_mfa_enabled_for_administrative_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
expected_status_extended = f"Conditional Access Policy '{display_name}' enforces MFA for administrative roles."
assert result[0].status_extended == expected_status_extended
assert result[0].resource == entra_client.conditional_access_policies
assert result[0].resource_name == display_name
assert result[0].resource_id == policy_id
def test_policy_valid_one_missing_role(self):
"""
Valid policy:
- State enabled (ENABLED or ENABLED_FOR_REPORTING)
- Applies to administrative roles except one
- Application conditions include "All"
- MFA is configured in grant_controls
Expected FAIL.
"""
policy_id = str(uuid4())
display_name = "Valid MFA Policy"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import (
entra_admin_mfa_enabled_for_administrative_roles,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"], excluded_applications=[]
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=[],
excluded_users=[],
included_roles=[
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3",
"c4e39bd9-1100-46d3-8c65-fb160da0071f",
"b0f54661-2d74-4c50-afa3-1ec803f12efe",
"158c047a-c907-4556-b7ef-446551a6b5f7",
"b1be1c3e-b65d-4f19-8427-f6fa0d97feb9",
"29232cdf-9323-42fd-ade2-1d097af3e4de",
"62e90394-69f5-4237-9190-012177145e10",
"f2ef992c-3afb-46b9-b7cf-a126ee74c451",
"729827e3-9c14-49f7-bb1b-9608f156bbb8",
"966707d0-3269-4727-9be2-8c3a10f19b9d",
"7be44c8a-adaf-4e2a-84d6-ab2649e08a13",
"e8611ab8-c189-46e8-94e1-60213ab1f814",
"194ae4cb-b126-40b2-bd5b-6091b380977d",
"f28a1f50-f6e7-4571-818b-6a12f2af6b6c",
],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.MFA],
operator=GrantControlOperator.AND,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
)
}
check = entra_admin_mfa_enabled_for_administrative_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
expected_status_extended = "No Conditional Access Policy requiring MFA for administrative roles was found."
assert result[0].status_extended == expected_status_extended
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
@@ -0,0 +1,375 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.microsoft365.services.entra.entra_service import (
ApplicationsConditions,
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
PersistentBrowser,
RiskLevel,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
UsersConditions,
)
from tests.providers.microsoft365.microsoft365_fixtures import (
DOMAIN,
set_mocked_microsoft365_provider,
)
class Test_entra_identity_protection_sign_in_risk_enabled:
def test_entra_no_conditional_access_policies(self):
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_identity_protection_sign_in_risk_enabled.entra_identity_protection_sign_in_risk_enabled.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_identity_protection_sign_in_risk_enabled.entra_identity_protection_sign_in_risk_enabled import (
entra_identity_protection_sign_in_risk_enabled,
)
entra_client.conditional_access_policies = {}
check = entra_identity_protection_sign_in_risk_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy is a sign-in risk based Identity Protection Policy."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
assert result[0].location == "global"
def test_entra_identity_protection_user_risk_policy_disabled(self):
id = str(uuid4())
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_identity_protection_sign_in_risk_enabled.entra_identity_protection_sign_in_risk_enabled.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_identity_protection_sign_in_risk_enabled.entra_identity_protection_sign_in_risk_enabled import (
entra_identity_protection_sign_in_risk_enabled,
)
from prowler.providers.microsoft365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name="Test",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=[], excluded_applications=[]
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=[],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
user_risk_levels=[],
sign_in_risk_levels=[],
),
grant_controls=GrantControls(
built_in_controls=[], operator=GrantControlOperator.OR
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.DISABLED,
)
}
check = entra_identity_protection_sign_in_risk_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy is a sign-in risk based Identity Protection Policy."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
assert result[0].location == "global"
def test_entra_identity_protection_user_risk_policy_enabled_not_enough_risk(self):
id = str(uuid4())
display_name = "Test"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_identity_protection_sign_in_risk_enabled.entra_identity_protection_sign_in_risk_enabled.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_identity_protection_sign_in_risk_enabled.entra_identity_protection_sign_in_risk_enabled import (
entra_identity_protection_sign_in_risk_enabled,
)
from prowler.providers.microsoft365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
user_risk_levels=[RiskLevel.LOW],
sign_in_risk_levels=[RiskLevel.LOW],
),
grant_controls=GrantControls(
built_in_controls=[
ConditionalAccessGrantControl.MFA,
ConditionalAccessGrantControl.PASSWORD_CHANGE,
],
operator=GrantControlOperator.AND,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
)
}
check = entra_identity_protection_sign_in_risk_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{display_name}' is a sign-in risk based Identity Protection Policy but does not protect against high and medium sign-in risk attempts."
)
assert (
result[0].resource
== entra_client.conditional_access_policies[id].dict()
)
assert result[0].resource_name == display_name
assert result[0].resource_id == id
assert result[0].location == "global"
def test_entra_identity_protection_user_risk_policy_enabled_for_reporting(self):
id = str(uuid4())
display_name = "Test"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_identity_protection_sign_in_risk_enabled.entra_identity_protection_sign_in_risk_enabled.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_identity_protection_sign_in_risk_enabled.entra_identity_protection_sign_in_risk_enabled import (
entra_identity_protection_sign_in_risk_enabled,
)
from prowler.providers.microsoft365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
user_risk_levels=[RiskLevel.HIGH],
sign_in_risk_levels=[RiskLevel.HIGH, RiskLevel.MEDIUM],
),
grant_controls=GrantControls(
built_in_controls=[
ConditionalAccessGrantControl.MFA,
ConditionalAccessGrantControl.PASSWORD_CHANGE,
],
operator=GrantControlOperator.AND,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
)
}
check = entra_identity_protection_sign_in_risk_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{display_name}' is a sign-in risk based Identity Protection Policy and reports high and medium risk potential sign-in attempts, but does not protect against them."
)
assert (
result[0].resource
== entra_client.conditional_access_policies[id].dict()
)
assert result[0].resource_name == display_name
assert result[0].resource_id == id
assert result[0].location == "global"
def test_entra_identity_protection_user_risk_policy_enabled(self):
id = str(uuid4())
display_name = "Test"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_microsoft365_provider(),
),
mock.patch(
"prowler.providers.microsoft365.services.entra.entra_identity_protection_sign_in_risk_enabled.entra_identity_protection_sign_in_risk_enabled.entra_client",
new=entra_client,
),
):
from prowler.providers.microsoft365.services.entra.entra_identity_protection_sign_in_risk_enabled.entra_identity_protection_sign_in_risk_enabled import (
entra_identity_protection_sign_in_risk_enabled,
)
from prowler.providers.microsoft365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
user_risk_levels=[RiskLevel.HIGH],
sign_in_risk_levels=[RiskLevel.HIGH, RiskLevel.MEDIUM],
),
grant_controls=GrantControls(
built_in_controls=[
ConditionalAccessGrantControl.MFA,
ConditionalAccessGrantControl.PASSWORD_CHANGE,
],
operator=GrantControlOperator.AND,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_identity_protection_sign_in_risk_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Conditional Access Policy '{display_name}' is a sign-in risk based Identity Protection Policy and does protect against high and medium risk potential sign-in attempts."
)
assert (
result[0].resource
== entra_client.conditional_access_policies[id].dict()
)
assert result[0].resource_name == display_name
assert result[0].resource_id == id
assert result[0].location == "global"