merge branch 'master' into PRWLR-5989-ensure-that-spf-records-are-published-for-all-exchange

This commit is contained in:
Daniel Barranquero committed 2025-05-13 09:36:12 +02:00
commit 159995abb6
104 files changed
+9595 -1569

No files matched your search

+2 -1
View File
@@ -30,5 +30,6 @@ jobs:
"PROWLER_COMMIT_SHORT_SHA": "${{ env.SHORT_SHA }}",
"PROWLER_PR_TITLE": "${{ github.event.pull_request.title }}",
"PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }},
"PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }}
"PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }},
"PROWLER_PR_URL":${{ toJson(github.event.pull_request.html_url) }}
}'
+52 -1
View File
@@ -25,17 +25,31 @@ jobs:
MINOR_VERSION=${BASH_REMATCH[2]}
FIX_VERSION=${BASH_REMATCH[3]}
# Export version components to GitHub environment
echo "MAJOR_VERSION=${MAJOR_VERSION}" >> "${GITHUB_ENV}"
echo "MINOR_VERSION=${MINOR_VERSION}" >> "${GITHUB_ENV}"
echo "FIX_VERSION=${FIX_VERSION}" >> "${GITHUB_ENV}"
if (( MAJOR_VERSION == 5 )); then
if (( FIX_VERSION == 0 )); then
echo "Minor Release: $PROWLER_VERSION"
# Set up next minor version for master
BUMP_VERSION_TO=${MAJOR_VERSION}.$((MINOR_VERSION + 1)).${FIX_VERSION}
echo "BUMP_VERSION_TO=${BUMP_VERSION_TO}" >> "${GITHUB_ENV}"
TARGET_BRANCH=${BASE_BRANCH}
echo "TARGET_BRANCH=${TARGET_BRANCH}" >> "${GITHUB_ENV}"
# Set up patch version for version branch
PATCH_VERSION_TO=${MAJOR_VERSION}.${MINOR_VERSION}.1
echo "PATCH_VERSION_TO=${PATCH_VERSION_TO}" >> "${GITHUB_ENV}"
VERSION_BRANCH=v${MAJOR_VERSION}.${MINOR_VERSION}
echo "VERSION_BRANCH=${VERSION_BRANCH}" >> "${GITHUB_ENV}"
echo "Bumping to next minor version: ${BUMP_VERSION_TO} in branch ${TARGET_BRANCH}"
echo "Bumping to next patch version: ${PATCH_VERSION_TO} in branch ${VERSION_BRANCH}"
else
echo "Patch Release: $PROWLER_VERSION"
@@ -74,7 +88,6 @@ jobs:
git --no-pager diff
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
@@ -92,3 +105,41 @@ jobs:
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
- name: Handle patch version for minor release
if: env.FIX_VERSION == '0'
run: |
echo "Using PROWLER_VERSION=$PROWLER_VERSION"
echo "Using PATCH_VERSION_TO=$PATCH_VERSION_TO"
set -e
echo "Bumping version in pyproject.toml ..."
sed -i "s|version = \"${PROWLER_VERSION}\"|version = \"${PATCH_VERSION_TO}\"|" pyproject.toml
echo "Bumping version in prowler/config/config.py ..."
sed -i "s|prowler_version = \"${PROWLER_VERSION}\"|prowler_version = \"${PATCH_VERSION_TO}\"|" prowler/config/config.py
echo "Bumping version in .env ..."
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PATCH_VERSION_TO}|" .env
git --no-pager diff
- name: Create Pull Request for patch version
if: env.FIX_VERSION == '0'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
base: ${{ env.VERSION_BRANCH }}
commit-message: "chore(release): Bump version to v${{ env.PATCH_VERSION_TO }}"
branch: "version-bump-to-v${{ env.PATCH_VERSION_TO }}"
title: "chore(release): Bump version to v${{ env.PATCH_VERSION_TO }}"
body: |
### Description
Bump Prowler version to v${{ env.PATCH_VERSION_TO }}
### License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
+108 -48
View File
@@ -3,7 +3,7 @@
<img align="center" src="https://github.com/prowler-cloud/prowler/blob/master/docs/img/prowler-logo-white.png#gh-dark-mode-only" width="50%" height="50%">
</p>
<p align="center">
<b><i>Prowler Open Source</b> is as dynamic and adaptable as the environment they’re meant to protect. Trusted by the leaders in security.
<b><i>Prowler Open Source</b> is as dynamic and adaptable as the environment it secures. It is trusted by the industry leaders to uphold the highest standards in security.
</p>
<p align="center">
<b>Learn more at <a href="https://prowler.com">prowler.com</i></b>
@@ -43,15 +43,29 @@
# Description
**Prowler** is an Open Source security tool to perform AWS, Azure, Google Cloud and Kubernetes security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness, and also remediations! We have Prowler CLI (Command Line Interface) that we call Prowler Open Source and a service on top of it that we call <a href="https://prowler.com">Prowler Cloud</a>.
**Prowler** is an open-source security tool designed to assess and enforce security best practices across AWS, Azure, Google Cloud, and Kubernetes. It supports tasks such as security audits, incident response, continuous monitoring, system hardening, forensic readiness, and remediation processes.
Prowler includes hundreds of built-in controls to ensure compliance with standards and frameworks, including:
- **Industry Standards:** CIS, NIST 800, NIST CSF, and CISA
- **Regulatory Compliance and Governance:** RBI, FedRAMP, and PCI-DSS
- **Frameworks for Sensitive Data and Privacy:** GDPR, HIPAA, and FFIEC
- **Frameworks for Organizational Governance and Quality Control:** SOC2 and GXP
- **AWS-Specific Frameworks:** AWS Foundational Technical Review (FTR) and AWS Well-Architected Framework (Security Pillar)
- **National Security Standards:** ENS (Spanish National Security Scheme)
- **Custom Security Frameworks:** Tailored to your needs
## Prowler CLI and Prowler Cloud
Prowler offers a Command Line Interface (CLI), known as Prowler Open Source, and an additional service built on top of it, called <a href="https://prowler.com">Prowler Cloud</a>.
## Prowler App
Prowler App is a web application that allows you to run Prowler in your cloud provider accounts and visualize the results in a user-friendly interface.
Prowler App is a web-based application that simplifies running Prowler across your cloud provider accounts. It provides a user-friendly interface to visualize the results and streamline your security assessments.
![Prowler App](docs/img/overview.png)
>More details at [Prowler App Documentation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-app-installation)
>For more details, refer to the [Prowler App Documentation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-app-installation)
## Prowler CLI
@@ -60,6 +74,7 @@ prowler <provider>
```
![Prowler CLI Execution](docs/img/short-display.png)
## Prowler Dashboard
```console
@@ -67,7 +82,7 @@ prowler dashboard
```
![Prowler Dashboard](docs/img/dashboard.png)
It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks.
# Prowler at a Glance
| 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) |
|---|---|---|---|---|
@@ -75,18 +90,19 @@ It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, Fe
| GCP | 79 | 13 | 7 | 3 |
| Azure | 140 | 18 | 8 | 3 |
| Kubernetes | 83 | 7 | 4 | 7 |
| M365 | 44 | 2 | 1 | 0 |
| M365 | 44 | 2 | 2 | 0 |
| NHN (Unofficial) | 6 | 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`.
> Use the following commands to list Prowler's available checks, services, compliance frameworks, and categories: `prowler <provider> --list-checks`, `prowler <provider> --list-services`, `prowler <provider> --list-compliance` and `prowler <provider> --list-categories`.
# 💻 Installation
## Prowler App
Prowler App can be installed in different ways, depending on your environment:
Installing Prowler App
Prowler App offers flexible installation methods tailored to various environments:
> See how to use Prowler App in the [Prowler App Usage Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/).
> For detailed instructions on using Prowler App, refer to the [Prowler App Usage Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/).
### Docker Compose
@@ -102,8 +118,16 @@ curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/mast
docker compose up -d
```
> Containers are built for `linux/amd64`. If your workstation's architecture is different, please set `DOCKER_DEFAULT_PLATFORM=linux/amd64` in your environment or use the `--platform linux/amd64` flag in the docker command.
> Enjoy Prowler App at http://localhost:3000 by signing up with your email and password.
> Containers are built for `linux/amd64`.
### Configuring Your Workstation for Prowler App
If your workstation's architecture is incompatible, you can resolve this by:
- **Setting the environment variable**: `DOCKER_DEFAULT_PLATFORM=linux/amd64`
- **Using the following flag in your Docker command**: `--platform linux/amd64`
> Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started.
### From GitHub
@@ -129,12 +153,12 @@ python manage.py migrate --database admin
gunicorn -c config/guniconf.py config.wsgi:application
```
> [!IMPORTANT]
> Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`.
> As of Poetry v2.0.0, the `poetry shell` command has been deprecated. Use `poetry env activate` instead for environment activation.
>
> If your poetry version is below 2.0.0 you must keep using `poetry shell` to activate your environment.
> In case you have any doubts, consult the Poetry environment activation guide: https://python-poetry.org/docs/managing-environments/#activating-the-environment
> If your Poetry version is below v2.0.0, continue using `poetry shell` to activate your environment.
> For further guidance, refer to the Poetry Environment Activation Guide https://python-poetry.org/docs/managing-environments/#activating-the-environment.
> Now, you can access the API documentation at http://localhost:8080/api/v1/docs.
> After completing the setup, access the API documentation at http://localhost:8080/api/v1/docs.
**Commands to run the API Worker**
@@ -172,29 +196,31 @@ npm run build
npm start
```
> Enjoy Prowler App at http://localhost:3000 by signing up with your email and password.
> Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started.
## Prowler CLI
### Pip package
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/), thus can be installed using pip with Python > 3.9.1, < 3.13:
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >3.9.1, <3.13:
```console
pip install prowler
prowler -v
```
>More details at [https://docs.prowler.com](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-cli-installation)
>For further guidance, refer to [https://docs.prowler.com](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-cli-installation)
### Containers
The available versions of Prowler CLI are the following:
**Available Versions of Prowler CLI**
- `latest`: in sync with `master` branch (bear in mind that it is not a stable version)
- `v4-latest`: in sync with `v4` branch (bear in mind that it is not a stable version)
- `v3-latest`: in sync with `v3` branch (bear in mind that it is not a stable version)
- `<x.y.z>` (release): you can find the releases [here](https://github.com/prowler-cloud/prowler/releases), those are stable releases.
- `stable`: this tag always point to the latest release.
- `v4-stable`: this tag always point to the latest release for v4.
- `v3-stable`: this tag always point to the latest release for v3.
The following versions of Prowler CLI are available, depending on your requirements:
- `latest`: Synchronizes with the `master` branch. Note that this version is not stable.
- `v4-latest`: Synchronizes with the `v4` branch. Note that this version is not stable.
- `v3-latest`: Synchronizes with the `v3` branch. Note that this version is not stable.
- `<x.y.z>` (release): Stable releases corresponding to specific versions. You can find the complete list of releases [here](https://github.com/prowler-cloud/prowler/releases).
- `stable`: Always points to the latest release.
- `v4-stable`: Always points to the latest release for v4.
- `v3-stable`: Always points to the latest release for v3.
The container images are available here:
- Prowler CLI:
@@ -206,7 +232,7 @@ The container images are available here:
### From GitHub
Python > 3.9.1, < 3.13 is required with pip and poetry:
Python >3.9.1, <3.13 is required with pip and Poetry:
``` console
git clone https://github.com/prowler-cloud/prowler
@@ -216,25 +242,46 @@ poetry install
python prowler-cli.py -v
```
> [!IMPORTANT]
> Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`.
>
> If your poetry version is below 2.0.0 you must keep using `poetry shell` to activate your environment.
> In case you have any doubts, consult the Poetry environment activation guide: https://python-poetry.org/docs/managing-environments/#activating-the-environment
> To clone Prowler on Windows, configure Git to support long file paths by running the following command: `git config core.longpaths true`.
> If you want to clone Prowler from Windows, use `git config core.longpaths true` to allow long file paths.
# 📐✏️ High level architecture
> [!IMPORTANT]
> As of Poetry v2.0.0, the `poetry shell` command has been deprecated. Use `poetry env activate` instead for environment activation.
>
> If your Poetry version is below v2.0.0, continue using `poetry shell` to activate your environment.
> For further guidance, refer to the Poetry Environment Activation Guide https://python-poetry.org/docs/managing-environments/#activating-the-environment.
# ✏️ High level architecture
## Prowler App
The **Prowler App** consists of three main components:
**Prowler App** is composed of three key components:
- **Prowler UI**: A user-friendly web interface for running Prowler and viewing results, powered by Next.js.
- **Prowler API**: The backend API that executes Prowler scans and stores the results, built with Django REST Framework.
- **Prowler SDK**: A Python SDK that integrates with the Prowler CLI for advanced functionality.
- **Prowler UI**: A web-based interface, built with Next.js, providing a user-friendly experience for executing Prowler scans and visualizing results.
- **Prowler API**: A backend service, developed with Django REST Framework, responsible for running Prowler scans and storing the generated results.
- **Prowler SDK**: A Python SDK designed to extend the functionality of the Prowler CLI for advanced capabilities.
![Prowler App Architecture](docs/img/prowler-app-architecture.png)
## Prowler CLI
You can run Prowler from your workstation, a Kubernetes Job, a Google Compute Engine, an Azure VM, an EC2 instance, Fargate or any other container, CloudShell and many more.
**Running Prowler**
Prowler can be executed across various environments, offering flexibility to meet your needs. It can be run from:
- Your own workstation
- A Kubernetes Job
- Google Compute Engine
- Azure Virtual Machines (VMs)
- Amazon EC2 instances
- AWS Fargate or other container platforms
- CloudShell
And many more environments.
![Architecture](docs/img/architecture.png)
@@ -242,23 +289,36 @@ You can run Prowler from your workstation, a Kubernetes Job, a Google Compute En
## General
- `Allowlist` now is called `Mutelist`.
- The `--quiet` option has been deprecated, now use the `--status` flag to select the finding's status you want to get from PASS, FAIL or MANUAL.
- All `INFO` finding's status has changed to `MANUAL`.
- The CSV output format is common for all the providers.
- The `--quiet` option has been deprecated. Use the `--status` flag to filter findings based on their status: PASS, FAIL, or MANUAL.
- All findings with an `INFO` status have been reclassified as `MANUAL`.
- The CSV output format is standardized across all providers.
We have deprecated some of our outputs formats:
- The native JSON is replaced for the JSON [OCSF](https://schema.ocsf.io/) v1.1.0, common for all the providers.
**Deprecated Output Formats**
The following formats are now deprecated:
- Native JSON has been replaced with JSON in [OCSF] v1.1.0 format, which is standardized across all providers (https://schema.ocsf.io/).
## AWS
- Deprecate the AWS flag --sts-endpoint-region since we use AWS STS regional tokens.
- To send only FAILS to AWS Security Hub, now use either `--send-sh-only-fails` or `--security-hub --status FAIL`.
**AWS Flag Deprecation**
The flag --sts-endpoint-region has been deprecated due to the adoption of AWS STS regional tokens.
**Sending FAIL Results to AWS Security Hub**
- To send only FAILS to AWS Security Hub, use one of the following options: `--send-sh-only-fails` or `--security-hub --status FAIL`.
# 📖 Documentation
Install, Usage, Tutorials and Developer Guide is at https://docs.prowler.com/
**Documentation Resources**
For installation instructions, usage details, tutorials, and the Developer Guide, visit https://docs.prowler.com/
# 📃 License
Prowler is licensed as Apache License 2.0 as specified in each file. You may obtain a copy of the License at
<http://www.apache.org/licenses/LICENSE-2.0>
**Prowler License Information**
Prowler is licensed under the Apache License 2.0, as indicated in each file within the repository. Obtaining a Copy of the License
A copy of the License is available at <http://www.apache.org/licenses/LICENSE-2.0>
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler API** are documented in this file.
## [v1.8.0] (Prowler UNRELEASED)
### Added
- Added huge improvements to `/findings/metadata` and resource related filters for findings [(#7690)](https://github.com/prowler-cloud/prowler/pull/7690).
- Added improvements to `/overviews` endpoints [(#7690)](https://github.com/prowler-cloud/prowler/pull/7690).
- Added new queue to perform backfill background tasks [(#7690)](https://github.com/prowler-cloud/prowler/pull/7690).
---
## [v1.7.0] (Prowler v5.6.0)
+1 -1
View File
@@ -28,7 +28,7 @@ start_prod_server() {
start_worker() {
echo "Starting the worker..."
poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion -E --max-tasks-per-child 1
poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill -E --max-tasks-per-child 1
}
start_worker_beat() {
+1 -1
View File
@@ -35,7 +35,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.7.0"
version = "1.8.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
+71
View File
@@ -227,6 +227,77 @@ def register_enum(apps, schema_editor, enum_class): # noqa: F841
register_adapter(enum_class, enum_adapter)
def create_index_on_partitions(
apps, # noqa: F841
schema_editor,
parent_table: str,
index_name: str,
columns: str,
method: str = "BTREE",
where: str = "",
):
"""
Create an index on every existing partition of `parent_table`.
Args:
parent_table: The name of the root table (e.g. "findings").
index_name: A short name for the index (will be prefixed per-partition).
columns: The parenthesized column list, e.g. "tenant_id, scan_id, status".
method: The index method—BTREE, GIN, etc. Defaults to BTREE.
where: Optional WHERE clause (without the leading "WHERE"), e.g. "status = 'FAIL'".
"""
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT inhrelid::regclass::text
FROM pg_inherits
WHERE inhparent = %s::regclass
""",
[parent_table],
)
partitions = [row[0] for row in cursor.fetchall()]
where_sql = f" WHERE {where}" if where else ""
for partition in partitions:
idx_name = f"{partition.replace('.', '_')}_{index_name}"
sql = (
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {idx_name} "
f"ON {partition} USING {method} ({columns})"
f"{where_sql};"
)
schema_editor.execute(sql)
def drop_index_on_partitions(
apps, # noqa: F841
schema_editor,
parent_table: str,
index_name: str,
):
"""
Drop the per-partition indexes that were created by create_index_on_partitions.
Args:
parent_table: The name of the root table (e.g. "findings").
index_name: The same short name used when creating them.
"""
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT inhrelid::regclass::text
FROM pg_inherits
WHERE inhparent = %s::regclass
""",
[parent_table],
)
partitions = [row[0] for row in cursor.fetchall()]
for partition in partitions:
idx_name = f"{partition.replace('.', '_')}_{index_name}"
sql = f"DROP INDEX CONCURRENTLY IF EXISTS {idx_name};"
schema_editor.execute(sql)
# Postgres enum definition for member role
+17 -8
View File
@@ -293,16 +293,16 @@ class FindingFilter(FilterSet):
resources = UUIDInFilter(field_name="resource__id", lookup_expr="in")
region = CharFilter(field_name="resources__region")
region__in = CharInFilter(field_name="resources__region", lookup_expr="in")
region = CharFilter(method="filter_resource_region")
region__in = CharInFilter(field_name="resource_regions", lookup_expr="overlap")
region__icontains = CharFilter(
field_name="resources__region", lookup_expr="icontains"
field_name="resource_regions", lookup_expr="icontains"
)
service = CharFilter(field_name="resources__service")
service__in = CharInFilter(field_name="resources__service", lookup_expr="in")
service = CharFilter(method="filter_resource_service")
service__in = CharInFilter(field_name="resource_services", lookup_expr="overlap")
service__icontains = CharFilter(
field_name="resources__service", lookup_expr="icontains"
field_name="resource_services", lookup_expr="icontains"
)
resource_uid = CharFilter(field_name="resources__uid")
@@ -317,8 +317,8 @@ class FindingFilter(FilterSet):
field_name="resources__name", lookup_expr="icontains"
)
resource_type = CharFilter(field_name="resources__type")
resource_type__in = CharInFilter(field_name="resources__type", lookup_expr="in")
resource_type = CharFilter(method="filter_resource_type")
resource_type__in = CharInFilter(field_name="resource_types", lookup_expr="overlap")
resource_type__icontains = CharFilter(
field_name="resources__type", lookup_expr="icontains"
)
@@ -385,6 +385,15 @@ class FindingFilter(FilterSet):
},
}
def filter_resource_type(self, queryset, name, value):
return queryset.filter(resource_types__contains=[value])
def filter_resource_region(self, queryset, name, value):
return queryset.filter(resource_regions__contains=[value])
def filter_resource_service(self, queryset, name, value):
return queryset.filter(resource_services__contains=[value])
def filter_queryset(self, queryset):
if not (self.data.get("scan") or self.data.get("scan__in")) and not (
self.data.get("inserted_at")
@@ -12,6 +12,7 @@ from api.models import (
Provider,
Resource,
ResourceFindingMapping,
ResourceScanSummary,
Scan,
StatusChoices,
)
@@ -133,6 +134,7 @@ class Command(BaseCommand):
region=random.choice(possible_regions),
service=random.choice(possible_services),
type=random.choice(possible_types),
inserted_at="2024-10-01T00:00:00Z",
)
)
@@ -181,6 +183,10 @@ class Command(BaseCommand):
"servicename": assigned_resource.service,
"resourcetype": assigned_resource.type,
},
resource_types=[assigned_resource.type],
resource_regions=[assigned_resource.region],
resource_services=[assigned_resource.service],
inserted_at="2024-10-01T00:00:00Z",
)
)
@@ -197,12 +203,22 @@ class Command(BaseCommand):
# Create ResourceFindingMapping
mappings = []
for index, f in enumerate(findings):
scan_resource_cache: set[tuple] = set()
for index, finding_instance in enumerate(findings):
resource_instance = resources[findings_resources_mapping[index]]
mappings.append(
ResourceFindingMapping(
tenant_id=tenant_id,
resource=resources[findings_resources_mapping[index]],
finding=f,
resource=resource_instance,
finding=finding_instance,
)
)
scan_resource_cache.add(
(
str(resource_instance.id),
resource_instance.service,
resource_instance.region,
resource_instance.type,
)
)
@@ -220,6 +236,38 @@ class Command(BaseCommand):
"Resource-finding mappings created successfully.\n\n"
)
)
with rls_transaction(tenant_id):
scan.progress = 99
scan.save()
self.stdout.write(self.style.WARNING("Creating finding filter values..."))
resource_scan_summaries = [
ResourceScanSummary(
tenant_id=tenant_id,
scan_id=str(scan.id),
resource_id=resource_id,
service=service,
region=region,
resource_type=resource_type,
)
for resource_id, service, region, resource_type in scan_resource_cache
]
num_batches = ceil(len(resource_scan_summaries) / batch_size)
with rls_transaction(tenant_id):
for i in tqdm(
range(0, len(resource_scan_summaries), batch_size),
total=num_batches,
):
with rls_transaction(tenant_id):
ResourceScanSummary.objects.bulk_create(
resource_scan_summaries[i : i + batch_size],
ignore_conflicts=True,
)
self.stdout.write(
self.style.SUCCESS("Finding filter values created successfully.\n\n")
)
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to populate test data: {e}"))
scan_state = "failed"
@@ -0,0 +1,81 @@
# Generated by Django 5.1.7 on 2025-05-05 10:01
import uuid
import django.db.models.deletion
import uuid6
from django.db import migrations, models
import api.rls
class Migration(migrations.Migration):
dependencies = [
("api", "0017_m365_provider"),
]
operations = [
migrations.CreateModel(
name="ResourceScanSummary",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("scan_id", models.UUIDField(db_index=True, default=uuid6.uuid7)),
("resource_id", models.UUIDField(db_index=True, default=uuid.uuid4)),
("service", models.CharField(max_length=100)),
("region", models.CharField(max_length=100)),
("resource_type", models.CharField(max_length=100)),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "resource_scan_summaries",
"indexes": [
models.Index(
fields=["tenant_id", "scan_id", "service"],
name="rss_tenant_scan_svc_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "region"],
name="rss_tenant_scan_reg_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "resource_type"],
name="rss_tenant_scan_type_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "region", "service"],
name="rss_tenant_scan_reg_svc_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "service", "resource_type"],
name="rss_tenant_scan_svc_type_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "region", "resource_type"],
name="rss_tenant_scan_reg_type_idx",
),
],
"unique_together": {("tenant_id", "scan_id", "resource_id")},
},
),
migrations.AddConstraint(
model_name="resourcescansummary",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_resourcescansummary",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
@@ -0,0 +1,42 @@
import django.contrib.postgres.fields
import django.contrib.postgres.indexes
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0018_resource_scan_summaries"),
]
operations = [
migrations.AddField(
model_name="finding",
name="resource_regions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=100),
blank=True,
null=True,
size=None,
),
),
migrations.AddField(
model_name="finding",
name="resource_services",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=100),
blank=True,
null=True,
size=None,
),
),
migrations.AddField(
model_name="finding",
name="resource_types",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=100),
blank=True,
null=True,
size=None,
),
),
]
@@ -0,0 +1,86 @@
from functools import partial
from django.db import migrations
from api.db_utils import create_index_on_partitions, drop_index_on_partitions
class Migration(migrations.Migration):
atomic = False
dependencies = [
("api", "0019_finding_denormalize_resource_fields"),
]
operations = [
migrations.RunPython(
partial(
create_index_on_partitions,
parent_table="findings",
index_name="gin_find_service_idx",
columns="resource_services",
method="GIN",
),
reverse_code=partial(
drop_index_on_partitions,
parent_table="findings",
index_name="gin_find_service_idx",
),
),
migrations.RunPython(
partial(
create_index_on_partitions,
parent_table="findings",
index_name="gin_find_region_idx",
columns="resource_regions",
method="GIN",
),
reverse_code=partial(
drop_index_on_partitions,
parent_table="findings",
index_name="gin_find_region_idx",
),
),
migrations.RunPython(
partial(
create_index_on_partitions,
parent_table="findings",
index_name="gin_find_rtype_idx",
columns="resource_types",
method="GIN",
),
reverse_code=partial(
drop_index_on_partitions,
parent_table="findings",
index_name="gin_find_rtype_idx",
),
),
migrations.RunPython(
partial(
drop_index_on_partitions,
parent_table="findings",
index_name="findings_uid_idx",
),
reverse_code=partial(
create_index_on_partitions,
parent_table="findings",
index_name="findings_uid_idx",
columns="uid",
method="BTREE",
),
),
migrations.RunPython(
partial(
drop_index_on_partitions,
parent_table="findings",
index_name="findings_filter_idx",
),
reverse_code=partial(
create_index_on_partitions,
parent_table="findings",
index_name="findings_filter_idx",
columns="scan_id, impact, severity, status, check_id, delta",
method="BTREE",
),
),
]
@@ -0,0 +1,37 @@
import django.contrib.postgres.indexes
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("api", "0020_findings_new_performance_indexes_partitions"),
]
operations = [
migrations.AddIndex(
model_name="finding",
index=django.contrib.postgres.indexes.GinIndex(
fields=["resource_services"], name="gin_find_service_idx"
),
),
migrations.AddIndex(
model_name="finding",
index=django.contrib.postgres.indexes.GinIndex(
fields=["resource_regions"], name="gin_find_region_idx"
),
),
migrations.AddIndex(
model_name="finding",
index=django.contrib.postgres.indexes.GinIndex(
fields=["resource_types"], name="gin_find_rtype_idx"
),
),
migrations.RemoveIndex(
model_name="finding",
name="findings_uid_idx",
),
migrations.RemoveIndex(
model_name="finding",
name="findings_filter_idx",
),
]
@@ -0,0 +1,38 @@
# Generated by Django 5.1.8 on 2025-05-12 10:04
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
dependencies = [
("api", "0021_findings_new_performance_indexes_parent"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
AddIndexConcurrently(
model_name="scan",
index=models.Index(
condition=models.Q(("state", "completed")),
fields=["tenant_id", "provider_id", "state", "-inserted_at"],
name="scans_prov_state_ins_desc_idx",
),
),
AddIndexConcurrently(
model_name="scansummary",
index=models.Index(
fields=["tenant_id", "scan_id", "service"],
name="ss_tenant_scan_service_idx",
),
),
AddIndexConcurrently(
model_name="scansummary",
index=models.Index(
fields=["tenant_id", "scan_id", "severity"],
name="ss_tenant_scan_severity_idx",
),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.1.8 on 2025-05-12 10:18
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
dependencies = [
("api", "0022_scan_summaries_performance_indexes"),
]
operations = [
AddIndexConcurrently(
model_name="resource",
index=models.Index(
fields=["tenant_id", "id"], name="resources_tenant_id_idx"
),
),
AddIndexConcurrently(
model_name="resource",
index=models.Index(
fields=["tenant_id", "provider_id"],
name="resources_tenant_provider_idx",
),
),
]
+105 -15
View File
@@ -5,6 +5,7 @@ from uuid import UUID, uuid4
from cryptography.fernet import Fernet
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.core.validators import MinLengthValidator
@@ -447,6 +448,11 @@ class Scan(RowLevelSecurityProtectedModel):
fields=["tenant_id", "provider_id", "state", "inserted_at"],
name="scans_prov_state_insert_idx",
),
models.Index(
fields=["tenant_id", "provider_id", "state", "-inserted_at"],
condition=Q(state=StateChoices.COMPLETED),
name="scans_prov_state_ins_desc_idx",
),
]
class JSONAPIMeta:
@@ -573,6 +579,11 @@ class Resource(RowLevelSecurityProtectedModel):
name="resource_tenant_metadata_idx",
),
GinIndex(fields=["text_search"], name="gin_resources_search_idx"),
models.Index(fields=["tenant_id", "id"], name="resources_tenant_id_idx"),
models.Index(
fields=["tenant_id", "provider_id"],
name="resources_tenant_provider_idx",
),
]
constraints = [
@@ -673,6 +684,21 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
muted = models.BooleanField(default=False, null=False)
compliance = models.JSONField(default=dict, null=True, blank=True)
# Denormalize resource data for performance
resource_regions = ArrayField(
models.CharField(max_length=100), blank=True, null=True
)
resource_services = ArrayField(
models.CharField(max_length=100),
blank=True,
null=True,
)
resource_types = ArrayField(
models.CharField(max_length=100),
blank=True,
null=True,
)
# Relationships
scan = models.ForeignKey(to=Scan, related_name="findings", on_delete=models.CASCADE)
@@ -713,18 +739,6 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
]
indexes = [
models.Index(fields=["uid"], name="findings_uid_idx"),
models.Index(
fields=[
"scan_id",
"impact",
"severity",
"status",
"check_id",
"delta",
],
name="findings_filter_idx",
),
models.Index(fields=["tenant_id", "id"], name="findings_tenant_and_id_idx"),
GinIndex(fields=["text_search"], name="gin_findings_search_idx"),
models.Index(fields=["tenant_id", "scan_id"], name="find_tenant_scan_idx"),
@@ -736,19 +750,38 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
condition=Q(delta="new"),
name="find_delta_new_idx",
),
GinIndex(fields=["resource_services"], name="gin_find_service_idx"),
GinIndex(fields=["resource_regions"], name="gin_find_region_idx"),
GinIndex(fields=["resource_types"], name="gin_find_rtype_idx"),
]
class JSONAPIMeta:
resource_name = "findings"
def add_resources(self, resources: list[Resource] | None):
# Add new relationships with the tenant_id field
if not resources:
return
self.resource_regions = self.resource_regions or []
self.resource_services = self.resource_services or []
self.resource_types = self.resource_types or []
# Deduplication
regions = set(self.resource_regions)
services = set(self.resource_services)
types = set(self.resource_types)
for resource in resources:
ResourceFindingMapping.objects.update_or_create(
resource=resource, finding=self, tenant_id=self.tenant_id
)
regions.add(resource.region)
services.add(resource.service)
types.add(resource.type)
# Save the instance
self.resource_regions = list(regions)
self.resource_services = list(services)
self.resource_types = list(types)
self.save()
@@ -1150,7 +1183,15 @@ class ScanSummary(RowLevelSecurityProtectedModel):
models.Index(
fields=["tenant_id", "scan_id"],
name="scan_summaries_tenant_scan_idx",
)
),
models.Index(
fields=["tenant_id", "scan_id", "service"],
name="ss_tenant_scan_service_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "severity"],
name="ss_tenant_scan_severity_idx",
),
]
class JSONAPIMeta:
@@ -1232,3 +1273,52 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel):
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class ResourceScanSummary(RowLevelSecurityProtectedModel):
scan_id = models.UUIDField(default=uuid7, db_index=True)
resource_id = models.UUIDField(default=uuid4, db_index=True)
service = models.CharField(max_length=100)
region = models.CharField(max_length=100)
resource_type = models.CharField(max_length=100)
class Meta:
db_table = "resource_scan_summaries"
unique_together = (("tenant_id", "scan_id", "resource_id"),)
indexes = [
# Single-dimension lookups:
models.Index(
fields=["tenant_id", "scan_id", "service"],
name="rss_tenant_scan_svc_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "region"],
name="rss_tenant_scan_reg_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "resource_type"],
name="rss_tenant_scan_type_idx",
),
# Two-dimension cross-filters:
models.Index(
fields=["tenant_id", "scan_id", "region", "service"],
name="rss_tenant_scan_reg_svc_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "service", "resource_type"],
name="rss_tenant_scan_svc_type_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "region", "resource_type"],
name="rss_tenant_scan_reg_type_idx",
),
]
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
+2 -2
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.7.0
version: 1.8.0
description: |-
Prowler API specification.
@@ -4505,7 +4505,7 @@ paths:
description: ''
/api/v1/scans/{id}/compliance/{name}:
get:
operationId: scan_compliance_download
operationId: scans_compliance_retrieve
description: Download a specific compliance report (e.g., 'cis_1.4_aws') as
a CSV file.
summary: Retrieve compliance report as CSV
+8 -7
View File
@@ -3096,7 +3096,9 @@ class TestFindingViewSet:
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_findings_metadata_retrieve(self, authenticated_client, findings_fixture):
def test_findings_metadata_retrieve(
self, authenticated_client, findings_fixture, backfill_scan_metadata_fixture
):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
reverse("finding-metadata"),
@@ -3119,14 +3121,14 @@ class TestFindingViewSet:
)
# assert data["data"]["attributes"]["tags"] == expected_tags
def test_findings_metadata_severity_retrieve(
self, authenticated_client, findings_fixture
def test_findings_metadata_resource_filter_retrieve(
self, authenticated_client, findings_fixture, backfill_scan_metadata_fixture
):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
reverse("finding-metadata"),
{
"filter[severity__in]": ["low", "medium"],
"filter[region]": "eu-west-1",
"filter[inserted_at]": finding_1.inserted_at.strftime("%Y-%m-%d"),
},
)
@@ -4814,9 +4816,8 @@ class TestOverviewViewSet:
assert response.json()["data"][0]["attributes"]["findings"]["pass"] == 2
assert response.json()["data"][0]["attributes"]["findings"]["fail"] == 1
assert response.json()["data"][0]["attributes"]["findings"]["muted"] == 1
assert response.json()["data"][0]["attributes"]["resources"]["total"] == len(
resources_fixture
)
# Since we rely on completed scans, there are only 2 resources now
assert response.json()["data"][0]["attributes"]["resources"]["total"] == 2
def test_overview_services_list_no_required_filters(
self, authenticated_client, scan_summaries_fixture
+34 -1
View File
@@ -1,11 +1,14 @@
from datetime import datetime, timezone
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from django.contrib.postgres.aggregates import ArrayAgg
from django.db.models import Subquery
from rest_framework.exceptions import NotFound, ValidationError
from api.db_router import MainRouter
from api.exceptions import InvitationTokenExpiredException
from api.models import Invitation, Provider
from api.models import Invitation, Provider, Resource
from api.v1.serializers import FindingMetadataSerializer
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.azure.azure_provider import AzureProvider
from prowler.providers.common.models import Connection
@@ -205,3 +208,33 @@ def validate_invitation(
)
return invitation
# ToRemove after removing the fallback mechanism in /findings/metadata
def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset):
filtered_ids = filtered_queryset.order_by().values("id")
relevant_resources = Resource.all_objects.filter(
tenant_id=tenant_id, findings__id__in=Subquery(filtered_ids)
).only("service", "region", "type")
aggregation = relevant_resources.aggregate(
services=ArrayAgg("service", flat=True),
regions=ArrayAgg("region", flat=True),
resource_types=ArrayAgg("type", flat=True),
)
services = sorted(set(aggregation["services"] or []))
regions = sorted({region for region in aggregation["regions"] or [] if region})
resource_types = sorted(set(aggregation["resource_types"] or []))
result = {
"services": services,
"regions": regions,
"resource_types": resource_types,
}
serializer = FindingMetadataSerializer(data=result)
serializer.is_valid(raise_exception=True)
return serializer.data
+147 -91
View File
@@ -1,5 +1,6 @@
import glob
import os
from datetime import datetime, timedelta, timezone
import sentry_sdk
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
@@ -20,6 +21,7 @@ from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Subquery,
from django.db.models.functions import Coalesce
from django.http import HttpResponse
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django_celery_beat.models import PeriodicTask
@@ -48,6 +50,7 @@ from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from tasks.beat import schedule_provider_scan
from tasks.jobs.export import get_s3_client
from tasks.tasks import (
backfill_scan_resource_summaries_task,
check_provider_connection_task,
delete_provider_task,
delete_tenant_task,
@@ -87,6 +90,7 @@ from api.models import (
ProviderSecret,
Resource,
ResourceFindingMapping,
ResourceScanSummary,
Role,
RoleProviderGroupRelationship,
Scan,
@@ -100,7 +104,12 @@ from api.models import (
from api.pagination import ComplianceOverviewPagination
from api.rbac.permissions import Permissions, get_providers, get_role
from api.rls import Tenant
from api.utils import CustomOAuth2Client, validate_invitation
from api.utils import (
CustomOAuth2Client,
get_findings_metadata_no_aggregations,
validate_invitation,
)
from api.uuid_utils import datetime_to_uuid7, uuid7_start
from api.v1.serializers import (
ComplianceOverviewFullSerializer,
ComplianceOverviewMetadataSerializer,
@@ -249,7 +258,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.7.0"
spectacular_settings.VERSION = "1.8.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -1780,25 +1789,103 @@ class FindingViewSet(BaseRLSViewSet):
@action(detail=False, methods=["get"], url_name="metadata")
def metadata(self, request):
tenant_id = self.request.tenant_id
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
# Force filter validation
filtered_queryset = self.filter_queryset(self.get_queryset())
filtered_ids = filtered_queryset.order_by().values("id")
tenant_id = request.tenant_id
query_params = request.query_params
relevant_resources = Resource.all_objects.filter(
tenant_id=tenant_id, findings__id__in=Subquery(filtered_ids)
).only("service", "region", "type")
queryset = ResourceScanSummary.objects.filter(tenant_id=tenant_id)
scan_based_filters = {}
aggregation = relevant_resources.aggregate(
services=ArrayAgg("service", flat=True),
regions=ArrayAgg("region", flat=True),
resource_types=ArrayAgg("type", flat=True),
if scans := query_params.get("filter[scan__in]") or query_params.get(
"filter[scan]"
):
queryset = queryset.filter(scan_id__in=scans.split(","))
scan_based_filters = {"id__in": scans.split(",")}
else:
exact = query_params.get("filter[inserted_at]")
gte = query_params.get("filter[inserted_at__gte]")
lte = query_params.get("filter[inserted_at__lte]")
date_filters = {}
if exact:
date = parse_date(exact)
datetime_start = datetime.combine(
date, datetime.min.time(), tzinfo=timezone.utc
)
datetime_end = datetime_start + timedelta(days=1)
date_filters["scan_id__gte"] = uuid7_start(
datetime_to_uuid7(datetime_start)
)
date_filters["scan_id__lt"] = uuid7_start(
datetime_to_uuid7(datetime_end)
)
else:
if gte:
date_start = parse_date(gte)
datetime_start = datetime.combine(
date_start, datetime.min.time(), tzinfo=timezone.utc
)
date_filters["scan_id__gte"] = uuid7_start(
datetime_to_uuid7(datetime_start)
)
if lte:
date_end = parse_date(lte)
datetime_end = datetime.combine(
date_end + timedelta(days=1),
datetime.min.time(),
tzinfo=timezone.utc,
)
date_filters["scan_id__lt"] = uuid7_start(
datetime_to_uuid7(datetime_end)
)
if date_filters:
queryset = queryset.filter(**date_filters)
scan_based_filters = {
key.lstrip("scan_"): value for key, value in date_filters.items()
}
# ToRemove: Temporary fallback mechanism
if not queryset.exists():
scan_ids = Scan.objects.filter(
tenant_id=tenant_id, **scan_based_filters
).values_list("id", flat=True)
for scan_id in scan_ids:
backfill_scan_resource_summaries_task.apply_async(
kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
)
return Response(
get_findings_metadata_no_aggregations(tenant_id, filtered_queryset)
)
if service_filter := query_params.get("filter[service]") or query_params.get(
"filter[service__in]"
):
queryset = queryset.filter(service__in=service_filter.split(","))
if region_filter := query_params.get("filter[region]") or query_params.get(
"filter[region__in]"
):
queryset = queryset.filter(region__in=region_filter.split(","))
if resource_type_filter := query_params.get(
"filter[resource_type]"
) or query_params.get("filter[resource_type__in]"):
queryset = queryset.filter(
resource_type__in=resource_type_filter.split(",")
)
services = list(
queryset.values_list("service", flat=True).distinct().order_by("service")
)
regions = list(
queryset.values_list("region", flat=True).distinct().order_by("region")
)
resource_types = list(
queryset.values_list("resource_type", flat=True)
.distinct()
.order_by("resource_type")
)
services = sorted(set(aggregation["services"] or []))
regions = sorted({region for region in aggregation["regions"] or [] if region})
resource_types = sorted(set(aggregation["resource_types"] or []))
result = {
"services": services,
@@ -1808,7 +1895,7 @@ class FindingViewSet(BaseRLSViewSet):
serializer = self.get_serializer(data=result)
serializer.is_valid(raise_exception=True)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.data)
@extend_schema_view(
@@ -2375,8 +2462,8 @@ class OverviewViewSet(BaseRLSViewSet):
def _get_filtered_queryset(model):
if role.unlimited_visibility:
return model.objects.filter(tenant_id=self.request.tenant_id)
return model.objects.filter(
return model.all_objects.filter(tenant_id=self.request.tenant_id)
return model.all_objects.filter(
tenant_id=self.request.tenant_id, scan__provider__in=providers
)
@@ -2420,51 +2507,38 @@ class OverviewViewSet(BaseRLSViewSet):
tenant_id = self.request.tenant_id
latest_scan_ids = (
Scan.objects.filter(
tenant_id=tenant_id,
state=StateChoices.COMPLETED,
)
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
findings_aggregated = (
ScanSummary.objects.filter(tenant_id=tenant_id, scan_id__in=latest_scan_ids)
.values("scan__provider__provider")
resource_count_queryset = (
Resource.all_objects.filter(
tenant_id=tenant_id,
provider_id=OuterRef("scan__provider_id"),
)
.order_by()
.values("provider_id")
.annotate(cnt=Count("id"))
.values("cnt")
)
overview_queryset = (
ScanSummary.all_objects.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
.values(provider=F("scan__provider__provider"))
.annotate(
findings_passed=Coalesce(Sum("_pass"), 0),
findings_failed=Coalesce(Sum("fail"), 0),
findings_muted=Coalesce(Sum("muted"), 0),
total_findings=Coalesce(Sum("total"), 0),
total_resources=Coalesce(Subquery(resource_count_queryset), 0),
)
)
resources_aggregated = (
Resource.objects.filter(tenant_id=tenant_id)
.values("provider__provider")
.annotate(total_resources=Count("id"))
)
resources_dict = {
row["provider__provider"]: row["total_resources"]
for row in resources_aggregated
}
overview = []
for row in findings_aggregated:
provider_type = row["scan__provider__provider"]
overview.append(
{
"provider": provider_type,
"total_resources": resources_dict.get(provider_type, 0),
"total_findings": row["total_findings"],
"findings_passed": row["findings_passed"],
"findings_failed": row["findings_failed"],
"findings_muted": row["findings_muted"],
}
)
serializer = OverviewProviderSerializer(overview, many=True)
serializer = OverviewProviderSerializer(overview_queryset, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_name="findings")
@@ -2473,22 +2547,16 @@ class OverviewViewSet(BaseRLSViewSet):
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
latest_scan_subquery = (
Scan.objects.filter(
tenant_id=tenant_id,
state=StateChoices.COMPLETED,
provider_id=OuterRef("scan__provider_id"),
)
.order_by("-inserted_at")
.values("id")[:1]
latest_scan_ids = (
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
annotated_queryset = filtered_queryset.annotate(
latest_scan_id=Subquery(latest_scan_subquery)
filtered_queryset = filtered_queryset.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
filtered_queryset = annotated_queryset.filter(scan_id=F("latest_scan_id"))
aggregated_totals = filtered_queryset.aggregate(
_pass=Sum("_pass") or 0,
fail=Sum("fail") or 0,
@@ -2518,22 +2586,16 @@ class OverviewViewSet(BaseRLSViewSet):
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
latest_scan_subquery = (
Scan.objects.filter(
tenant_id=tenant_id,
state=StateChoices.COMPLETED,
provider_id=OuterRef("scan__provider_id"),
)
.order_by("-inserted_at")
.values("id")[:1]
latest_scan_ids = (
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
annotated_queryset = filtered_queryset.annotate(
latest_scan_id=Subquery(latest_scan_subquery)
filtered_queryset = filtered_queryset.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
filtered_queryset = annotated_queryset.filter(scan_id=F("latest_scan_id"))
severity_counts = (
filtered_queryset.values("severity")
.annotate(count=Sum("total"))
@@ -2554,22 +2616,16 @@ class OverviewViewSet(BaseRLSViewSet):
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
latest_scan_subquery = (
Scan.objects.filter(
tenant_id=tenant_id,
state=StateChoices.COMPLETED,
provider_id=OuterRef("scan__provider_id"),
)
.order_by("-inserted_at")
.values("id")[:1]
latest_scan_ids = (
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
annotated_queryset = filtered_queryset.annotate(
latest_scan_id=Subquery(latest_scan_subquery)
filtered_queryset = filtered_queryset.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
filtered_queryset = annotated_queryset.filter(scan_id=F("latest_scan_id"))
services_data = (
filtered_queryset.values("service")
.annotate(_pass=Sum("_pass"))
+1
View File
@@ -111,6 +111,7 @@ SPECTACULAR_SETTINGS = {
"PREPROCESSING_HOOKS": [
"drf_spectacular_jsonapi.hooks.fix_nested_path_parameters",
],
"TITLE": "API Reference - Prowler",
}
WSGI_APPLICATION = "config.wsgi.application"
+9
View File
@@ -10,6 +10,7 @@ from django.urls import reverse
from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.test import APIClient
from tasks.jobs.backfill import backfill_resource_scan_summaries
from api.db_utils import rls_transaction
from api.models import (
@@ -920,6 +921,14 @@ def integrations_fixture(providers_fixture):
return integration1, integration2
@pytest.fixture
def backfill_scan_metadata_fixture(scans_fixture, findings_fixture):
for scan_instance in scans_fixture:
tenant_id = scan_instance.tenant_id
scan_id = scan_instance.id
backfill_resource_scan_summaries(tenant_id=tenant_id, scan_id=scan_id)
def get_authorization_header(access_token: str) -> dict:
return {"Authorization": f"Bearer {access_token}"}
+61
View File
@@ -0,0 +1,61 @@
from api.db_utils import rls_transaction
from api.models import (
Resource,
ResourceFindingMapping,
ResourceScanSummary,
Scan,
StateChoices,
)
def backfill_resource_scan_summaries(tenant_id: str, scan_id: str):
with rls_transaction(tenant_id):
if ResourceScanSummary.objects.filter(
tenant_id=tenant_id, scan_id=scan_id
).exists():
return {"status": "already backfilled"}
with rls_transaction(tenant_id):
if not Scan.objects.filter(
tenant_id=tenant_id,
id=scan_id,
state__in=(StateChoices.COMPLETED, StateChoices.FAILED),
).exists():
return {"status": "scan is not completed"}
resource_ids_qs = (
ResourceFindingMapping.objects.filter(
tenant_id=tenant_id, finding__scan_id=scan_id
)
.values_list("resource_id", flat=True)
.distinct()
)
resource_ids = list(resource_ids_qs)
if not resource_ids:
return {"status": "no resources to backfill"}
resources_qs = Resource.objects.filter(
tenant_id=tenant_id, id__in=resource_ids
).only("id", "service", "region", "type")
summaries = []
for resource in resources_qs.iterator():
summaries.append(
ResourceScanSummary(
tenant_id=tenant_id,
scan_id=scan_id,
resource_id=str(resource.id),
service=resource.service,
region=resource.region,
resource_type=resource.type,
)
)
for i in range(0, len(summaries), 500):
ResourceScanSummary.objects.bulk_create(
summaries[i : i + 500], ignore_conflicts=True
)
return {"status": "backfilled", "inserted": len(summaries)}
+93 -57
View File
@@ -19,6 +19,7 @@ from api.models import (
Finding,
Provider,
Resource,
ResourceScanSummary,
ResourceTag,
Scan,
ScanSummary,
@@ -121,6 +122,7 @@ def perform_prowler_scan(
check_status_by_region = {}
exception = None
unique_resources = set()
scan_resource_cache: set[tuple[str, str, str, str]] = set()
start_time = time.time()
with rls_transaction(tenant_id):
@@ -295,6 +297,16 @@ def perform_prowler_scan(
continue
region_dict[finding.check_id] = finding.status.value
# Update scan resource summaries
scan_resource_cache.add(
(
str(resource_instance.id),
resource_instance.service,
resource_instance.region,
resource_instance.type,
)
)
# Update scan progress
with rls_transaction(tenant_id):
scan_instance.progress = progress
@@ -314,66 +326,90 @@ def perform_prowler_scan(
scan_instance.unique_resource_count = len(unique_resources)
scan_instance.save()
if exception is None:
try:
regions = prowler_provider.get_regions()
except AttributeError:
regions = set()
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
provider_instance.provider
]
compliance_overview_by_region = {
region: deepcopy(compliance_template) for region in regions
}
for region, check_status in check_status_by_region.items():
compliance_data = compliance_overview_by_region.setdefault(
region, deepcopy(compliance_template)
)
for check_name, status in check_status.items():
generate_scan_compliance(
compliance_data,
provider_instance.provider,
check_name,
status,
)
# Prepare compliance overview objects
compliance_overview_objects = []
for region, compliance_data in compliance_overview_by_region.items():
for compliance_id, compliance in compliance_data.items():
compliance_overview_objects.append(
ComplianceOverview(
tenant_id=tenant_id,
scan=scan_instance,
region=region,
compliance_id=compliance_id,
framework=compliance["framework"],
version=compliance["version"],
description=compliance["description"],
requirements=compliance["requirements"],
requirements_passed=compliance["requirements_status"]["passed"],
requirements_failed=compliance["requirements_status"]["failed"],
requirements_manual=compliance["requirements_status"]["manual"],
total_requirements=compliance["total_requirements"],
)
)
try:
with rls_transaction(tenant_id):
ComplianceOverview.objects.bulk_create(
compliance_overview_objects, batch_size=100
)
except Exception as overview_exception:
import sentry_sdk
sentry_sdk.capture_exception(overview_exception)
logger.error(
f"Error storing compliance overview for scan {scan_id}: {overview_exception}"
)
if exception is not None:
raise exception
try:
regions = prowler_provider.get_regions()
except AttributeError:
regions = set()
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
provider_instance.provider
]
compliance_overview_by_region = {
region: deepcopy(compliance_template) for region in regions
}
for region, check_status in check_status_by_region.items():
compliance_data = compliance_overview_by_region.setdefault(
region, deepcopy(compliance_template)
)
for check_name, status in check_status.items():
generate_scan_compliance(
compliance_data,
provider_instance.provider,
check_name,
status,
)
# Prepare compliance overview objects
compliance_overview_objects = []
for region, compliance_data in compliance_overview_by_region.items():
for compliance_id, compliance in compliance_data.items():
compliance_overview_objects.append(
ComplianceOverview(
tenant_id=tenant_id,
scan=scan_instance,
region=region,
compliance_id=compliance_id,
framework=compliance["framework"],
version=compliance["version"],
description=compliance["description"],
requirements=compliance["requirements"],
requirements_passed=compliance["requirements_status"]["passed"],
requirements_failed=compliance["requirements_status"]["failed"],
requirements_manual=compliance["requirements_status"]["manual"],
total_requirements=compliance["total_requirements"],
)
)
try:
with rls_transaction(tenant_id):
ComplianceOverview.objects.bulk_create(
compliance_overview_objects, batch_size=500
)
except Exception as overview_exception:
import sentry_sdk
sentry_sdk.capture_exception(overview_exception)
logger.error(
f"Error storing compliance overview for scan {scan_id}: {overview_exception}"
)
try:
resource_scan_summaries = [
ResourceScanSummary(
tenant_id=tenant_id,
scan_id=scan_id,
resource_id=resource_id,
service=service,
region=region,
resource_type=resource_type,
)
for resource_id, service, region, resource_type in scan_resource_cache
]
with rls_transaction(tenant_id):
ResourceScanSummary.objects.bulk_create(
resource_scan_summaries, batch_size=500, ignore_conflicts=True
)
except Exception as filter_exception:
import sentry_sdk
sentry_sdk.capture_exception(filter_exception)
logger.error(
f"Error storing filter values for scan {scan_id}: {filter_exception}"
)
serializer = ScanTaskSerializer(instance=scan_instance)
return serializer.data
+13
View File
@@ -7,6 +7,7 @@ from celery.utils.log import get_task_logger
from config.celery import RLSTask
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY
from django_celery_beat.models import PeriodicTask
from tasks.jobs.backfill import backfill_resource_scan_summaries
from tasks.jobs.connection import check_provider_connection
from tasks.jobs.deletion import delete_provider, delete_tenant
from tasks.jobs.export import (
@@ -358,3 +359,15 @@ def generate_outputs(scan_id: str, provider_id: str, tenant_id: str):
Scan.all_objects.filter(id=scan_id).update(output_location=final_location)
logger.info(f"Scan outputs at {final_location}")
return {"upload": did_upload}
@shared_task(name="backfill-scan-resource-summaries", queue="backfill")
def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str):
"""
Tries to backfill the resource scan summaries table for a given scan.
Args:
tenant_id (str): The tenant identifier.
scan_id (str): The scan identifier.
"""
return backfill_resource_scan_summaries(tenant_id=tenant_id, scan_id=scan_id)
@@ -0,0 +1,79 @@
from uuid import uuid4
import pytest
from tasks.jobs.backfill import backfill_resource_scan_summaries
from api.models import ResourceScanSummary, Scan, StateChoices
@pytest.mark.django_db
class TestBackfillResourceScanSummaries:
@pytest.fixture(scope="function")
def resource_scan_summary_data(self, scans_fixture):
scan = scans_fixture[0]
return ResourceScanSummary.objects.create(
tenant_id=scan.tenant_id,
scan_id=scan.id,
resource_id=str(uuid4()),
service="aws",
region="us-east-1",
resource_type="instance",
)
@pytest.fixture(scope="function")
def get_not_completed_scans(self, providers_fixture):
provider_id = providers_fixture[0].id
tenant_id = providers_fixture[0].tenant_id
scan_1 = Scan.objects.create(
tenant_id=tenant_id,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.EXECUTING,
provider_id=provider_id,
)
scan_2 = Scan.objects.create(
tenant_id=tenant_id,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.AVAILABLE,
provider_id=provider_id,
)
return scan_1, scan_2
def test_already_backfilled(self, resource_scan_summary_data):
tenant_id = resource_scan_summary_data.tenant_id
scan_id = resource_scan_summary_data.scan_id
result = backfill_resource_scan_summaries(tenant_id, scan_id)
assert result == {"status": "already backfilled"}
def test_not_completed_scan(self, get_not_completed_scans):
for scan_instance in get_not_completed_scans:
tenant_id = scan_instance.tenant_id
scan_id = scan_instance.id
result = backfill_resource_scan_summaries(tenant_id, scan_id)
assert result == {"status": "scan is not completed"}
def test_successful_backfill_inserts_one_summary(
self, resources_fixture, findings_fixture
):
tenant_id = findings_fixture[0].tenant_id
scan_id = findings_fixture[0].scan_id
# This scan affects the first two resources
resources = resources_fixture[:2]
result = backfill_resource_scan_summaries(tenant_id, scan_id)
assert result == {"status": "backfilled", "inserted": len(resources)}
# Verify correct values
summaries = ResourceScanSummary.objects.filter(
tenant_id=tenant_id, scan_id=scan_id
)
assert summaries.count() == len(resources)
for resource in resources:
summary = summaries.get(resource_id=resource.id)
assert summary.resource_id == resource.id
assert summary.service == resource.service
assert summary.region == resource.region
assert summary.resource_type == resource.type
+2 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import argparse
import os
import re
import subprocess
import sys
@@ -122,8 +123,7 @@ def main() -> None:
args = parser.parse_args()
metrics_dir = Path(args.metrics_dir)
if not metrics_dir.is_dir():
sys.exit(f"Metrics directory not found: {metrics_dir}")
os.makedirs(metrics_dir, exist_ok=True)
metrics_data: dict[str, pd.DataFrame] = {}
for csv_file in sorted(metrics_dir.glob("*.csv")):
+1
View File
@@ -1,2 +1,3 @@
locust==2.34.1
matplotlib==3.10.1
pandas==2.2.3
+16 -2
View File
@@ -180,13 +180,27 @@ class APIUser(APIUserBase):
)
endpoint = (
f"/findings?filter[{filter_name}]={filter_value}"
f"/findings/metadata?filter[{filter_name}]={filter_value}"
f"&filter[inserted_at]={TARGET_INSERTED_AT}"
f"&{get_sort_value(FINDINGS_UI_SORT_VALUES)}"
)
self.client.get(endpoint, headers=get_auth_headers(self.token), name=name)
@task
@task(3)
def findings_metadata_resource_filter_scan_large(self):
name = "/findings/metadata?filter[resource_filter]&filter[scan_id] - 500k"
filter_name, filter_value = get_next_resource_filter(
self.available_resource_filters
)
endpoint = (
f"/findings/metadata?filter[{filter_name}]={filter_value}"
f"&filter[scan]={self.l_scan_id}"
f"&{get_sort_value(FINDINGS_UI_SORT_VALUES)}"
)
self.client.get(endpoint, headers=get_auth_headers(self.token), name=name)
@task(2)
def findings_resource_filter_large_scan_include(self):
name = "/findings?filter[resource_filter][scan]&include - 500k"
filter_name, filter_value = get_next_resource_filter(
@@ -0,0 +1,24 @@
import warnings
from dashboard.common_methods import get_section_containers_cis
warnings.filterwarnings("ignore")
def get_table(data):
aux = data[
[
"REQUIREMENTS_ID",
"REQUIREMENTS_DESCRIPTION",
"REQUIREMENTS_ATTRIBUTES_SECTION",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
].copy()
return get_section_containers_cis(
aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1 -49
View File
@@ -23,55 +23,7 @@ In order to see which compliance frameworks are cover by Prowler, you can use op
prowler <provider> --list-compliance
```
### AWS
- `aws_account_security_onboarding_aws`
- `aws_audit_manager_control_tower_guardrails_aws`
- `aws_foundational_security_best_practices_aws`
- `aws_foundational_technical_review_aws`
- `aws_well_architected_framework_reliability_pillar_aws`
- `aws_well_architected_framework_security_pillar_aws`
- `cis_1.4_aws`
- `cis_1.5_aws`
- `cis_2.0_aws`
- `cis_3.0_aws`
- `cisa_aws`
- `ens_rd2022_aws`
- `fedramp_low_revision_4_aws`
- `fedramp_moderate_revision_4_aws`
- `ffiec_aws`
- `gdpr_aws`
- `gxp_21_cfr_part_11_aws`
- `gxp_eu_annex_11_aws`
- `hipaa_aws`
- `iso27001_2013_aws`
- `kisa_isms_p_2023_aws`
- `kisa_isms_p_2023_korean_aws`
- `mitre_attack_aws`
- `nist_800_171_revision_2_aws`
- `nist_800_53_revision_4_aws`
- `nist_800_53_revision_5_aws`
- `nist_csf_1.1_aws`
- `pci_3.2.1_aws`
- `rbi_cyber_security_framework_aws`
- `soc2_aws`
### Azure
- `cis_2.0_azure`
- `cis_2.1_azure`
- `ens_rd2022_azure`
- `mitre_attack_azure`
### GCP
- `cis_2.0_gcp`
- `ens_rd2022_gcp`
- `mitre_attack_gcp`
### Kubernetes
- `cis_1.8_kubernetes`
The full and updated list of supported compliance frameworks for each provider is available at [Prowler Hub](https://hub.prowler.com/compliance).
## List Requirements of Compliance Frameworks
For each compliance framework, you can use option `--list-compliance-requirements` to list its requirements:
+16
View File
@@ -20,3 +20,19 @@ kubectl logs prowler-XXXXX --namespace prowler-ns
???+ note
By default, `prowler` will scan all namespaces in your active Kubernetes context. Use the [`--namespace`](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/kubernetes/namespace/) flag to specify the namespace(s) to be scanned.
???+ tip "Identifying the cluster in reports"
When running in in-cluster mode, the Kubernetes API does not expose the actual cluster name by default.
To uniquely identify the cluster in logs and reports, you can:
- Use the `--cluster-name` flag to manually set the cluster name:
```bash
prowler -p kubernetes --cluster-name production-cluster
```
- Or set the `CLUSTER_NAME` environment variable:
```yaml
env:
- name: CLUSTER_NAME
value: production-cluster
```
+34 -4
View File
@@ -194,15 +194,45 @@ To view all `new` findings that have not been seen prior to this scan, click the
## **Step 9: Download the Outputs**
Once the scan is complete, you can download the output files generated by Prowler as a single `zip` file. This archive contains the CSV, JSON-OSCF, and HTML reports detailing the findings.
Once a scan is complete, navigate to the Scan Jobs section to download the output files generated by Prowler:
To download these files, click the **Download** button. This button becomes available only after the scan has finished.
<img src="../../img/scan_jobs_section.png" alt="Scan Jobs section" width="700"/>
These outputs are bundled into a single .zip archive containing:
- CSV report
- JSON-OSCF formatted results
- HTML report
- A folder with individual compliance reports
???+ note "Note"
The Download button only becomes active after a scan completes successfully.
<img src="../../img/download_output.png" alt="Download output" width="700"/>
This action downloads a `zip` file containing an `output` folder, which includes the files mentioned above: CSV, JSON-OSCF, and HTML reports.
The `zip` file unpacks into a folder named like `prowler-output-<provider_id>-<timestamp>`, which includes all of the above outputs. In the example below, you can see the `.csv`, .`json`, and `.html` reports alongside a subfolder for detailed compliance checks.
<img src="../../img/output_folder.png" alt="Output folder" width="700"/>
???+ note "API Note"
To learn more about the API endpoint the UI uses to download ZIP exports, see: [Prowler API Reference - Download Scan Output](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_report_retrieve)
For more information about the API endpoint used by the UI to download the ZIP archive, refer to: [Prowler API Reference - Download Scan Output](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_report_retrieve)
## **Step 10: Download specified compliance report**
Once your scan has finished, you don’t need to grab the entire ZIP—just pull down the specific compliance report you want:
- Navigate to the **Compliance** section of the UI.
<img src="../../img/compliance_section.png" alt="Compliance section" width="700"/>
- Find the Framework report you need.
- Click its **Download** icon to retrieve that report’s CSV file with all the detailed findings.
<img src="../../img/compliance_download.png" alt="Download compliance output" width="700"/>
???+ note "API Note"
To fetch a single compliance report via API, see the Retrieve compliance report as CSV endpoint in the Prowler API Reference.[Prowler API Reference - Retrieve compliance report as CSV](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_compliance_retrieve)
+14
View File
@@ -2,6 +2,18 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [v5.7.0] (Prowler v5.7.0)
### Added
- Update the compliance list supported for each provider from docs. [(#7694)](https://github.com/prowler-cloud/prowler/pull/7694)
- Allow setting cluster name in in-cluster mode in Kubernetes. [(#7695)](https://github.com/prowler-cloud/prowler/pull/7695)
- Add Prowler ThreatScore for M365 provider. [(#7692)](https://github.com/prowler-cloud/prowler/pull/7692)
### Fixed
- Update CIS 4.0 for M365 provider. [(#7699)](https://github.com/prowler-cloud/prowler/pull/7699)
---
## [v5.6.0] (Prowler v5.6.0)
### Added
@@ -51,6 +63,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Modified check `exchange_mailbox_properties_auditing_enabled` to make it configurable. [(#7662)](https://github.com/prowler-cloud/prowler/pull/7662)
- Add snapshots to m365 documentation. [(#7673)](https://github.com/prowler-cloud/prowler/pull/7673)
- Add support for static credentials for sending findings to Amazon S3 and AWS Security Hub. [(#7322)](https://github.com/prowler-cloud/prowler/pull/7322)
- Add Prowler ThreatScore for M365 provider. [(#7692)](https://github.com/prowler-cloud/prowler/pull/7692)
- Add Microsoft User and User Credential auth to reports [(#7681)](https://github.com/prowler-cloud/prowler/pull/7681)
### Fixed
+15
View File
@@ -79,6 +79,9 @@ from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_azur
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_gcp import (
ProwlerThreatScoreGCP,
)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_m365 import (
ProwlerThreatScoreM365,
)
from prowler.lib.outputs.csv.csv import CSV
from prowler.lib.outputs.finding import Finding
from prowler.lib.outputs.html.html import HTML
@@ -726,6 +729,18 @@ def prowler():
)
generated_outputs["compliance"].append(cis)
cis.batch_write_data_to_file()
elif compliance_name == "prowler_threatscore_m365":
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
prowler_threatscore = ProwlerThreatScoreM365(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(prowler_threatscore)
prowler_threatscore.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -12,7 +12,7 @@ from prowler.lib.logger import logger
timestamp = datetime.today()
timestamp_utc = datetime.now(timezone.utc).replace(tzinfo=timezone.utc)
prowler_version = "5.6.0"
prowler_version = "5.7.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://prowler.com/wp-content/uploads/logo-html.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -79,3 +79,29 @@ class ProwlerThreatScoreGCPModel(BaseModel):
ResourceName: str
CheckId: str
Muted: bool
class ProwlerThreatScoreM365Model(BaseModel):
"""
ProwlerThreatScoreM365Model generates a finding's output in M365 Prowler ThreatScore Compliance format.
"""
Provider: str
Description: str
TenantId: str
Location: str
AssessmentDate: str
Requirements_Id: str
Requirements_Description: str
Requirements_Attributes_Title: str
Requirements_Attributes_Section: str
Requirements_Attributes_SubSection: Optional[str]
Requirements_Attributes_AttributeDescription: str
Requirements_Attributes_AdditionalInformation: str
Requirements_Attributes_LevelOfRisk: int
Status: str
StatusExtended: str
ResourceId: str
ResourceName: str
CheckId: str
Muted: bool
@@ -0,0 +1,91 @@
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
from prowler.lib.outputs.compliance.prowler_threatscore.models import (
ProwlerThreatScoreM365Model,
)
from prowler.lib.outputs.finding import Finding
class ProwlerThreatScoreM365(ComplianceOutput):
"""
This class represents the M365 Prowler ThreatScore 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 M365 Prowler ThreatScore compliance format.
"""
def transform(
self,
findings: list[Finding],
compliance: Compliance,
compliance_name: str,
) -> None:
"""
Transforms a list of findings into M365 Prowler ThreatScore 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 = ProwlerThreatScoreM365Model(
Provider=finding.provider,
Description=compliance.Description,
TenantId=finding.account_uid,
Location=finding.region,
AssessmentDate=str(finding.timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Title=attribute.Title,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_SubSection=attribute.SubSection,
Requirements_Attributes_AttributeDescription=attribute.AttributeDescription,
Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation,
Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
)
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 = ProwlerThreatScoreM365Model(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
TenantId="",
Location="",
AssessmentDate=str(finding.timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Title=attribute.Title,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_SubSection=attribute.SubSection,
Requirements_Attributes_AttributeDescription=attribute.AttributeDescription,
Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation,
Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
)
self._data.append(compliance_row)
+3
View File
@@ -581,6 +581,9 @@ class HTML(Output):
<li class="list-group-item">
<b>M365 Identity ID:</b> {provider.identity.identity_id}
</li>
{f'''<li class="list-group-item">
<b>M365 User:</b> {provider.identity.user}
</li>''' if hasattr(provider.identity, 'user') and provider.identity.user is not None else ""}
</ul>
</div>
</div>"""
@@ -750,6 +750,7 @@
"eu-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -1989,10 +1990,13 @@
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-north-1",
"eu-south-1",
@@ -2000,6 +2004,7 @@
"eu-west-1",
"eu-west-2",
"eu-west-3",
"il-central-1",
"me-central-1",
"me-south-1",
"sa-east-1",
@@ -2845,6 +2850,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
@@ -2858,6 +2864,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -3342,6 +3349,7 @@
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
@@ -4203,6 +4211,7 @@
"ap-northeast-2",
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-west-1",
"eu-west-2",
@@ -4682,6 +4691,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
@@ -4695,6 +4705,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -5129,6 +5140,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -5265,6 +5277,7 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
@@ -5918,7 +5931,10 @@
"us-west-1",
"us-west-2"
],
"aws-cn": [],
"aws-cn": [
"cn-north-1",
"cn-northwest-1"
],
"aws-us-gov": []
}
},
@@ -8249,6 +8265,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -11133,9 +11150,20 @@
},
"timestream-query": {
"regions": {
"aws": [],
"aws": [
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-west-1",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
"aws-us-gov": []
"aws-us-gov": [
"us-gov-west-1"
]
}
},
"timestream-write": {
@@ -11893,6 +11921,7 @@
"eu-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"il-central-1",
"sa-east-1",
"us-east-1",
+1
View File
@@ -207,6 +207,7 @@ class Provider(ABC):
kubeconfig_file=arguments.kubeconfig_file,
context=arguments.context,
namespace=arguments.namespace,
cluster_name=arguments.cluster_name,
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
@@ -70,6 +70,7 @@ class KubernetesProvider(Provider):
kubeconfig_file: str = None,
context: str = None,
namespace: list = None,
cluster_name: str = None,
config_path: str = None,
config_content: dict = {},
fixer_config: dict = {},
@@ -84,6 +85,7 @@ class KubernetesProvider(Provider):
kubeconfig_file (str): Path to the kubeconfig file.
kubeconfig_content (str or dict): Content of the kubeconfig file.
context (str): Context name.
cluster_name (str): Cluster name.
namespace (list): List of namespaces.
config_content (dict): Audit configuration.
config_path (str): Path to the configuration file.
@@ -147,7 +149,9 @@ class KubernetesProvider(Provider):
"""
logger.info("Instantiating Kubernetes Provider ...")
self._session = self.setup_session(kubeconfig_file, kubeconfig_content, context)
self._session = self.setup_session(
kubeconfig_file, kubeconfig_content, context, cluster_name
)
if not namespace:
logger.info("Retrieving all namespaces ...")
self._namespaces = self.get_all_namespaces()
@@ -227,6 +231,7 @@ class KubernetesProvider(Provider):
kubeconfig_file: str = None,
kubeconfig_content: Union[dict, str] = None,
context: str = None,
cluster_name: str = None,
) -> KubernetesSession:
"""
Sets up the Kubernetes session.
@@ -235,7 +240,7 @@ class KubernetesProvider(Provider):
kubeconfig_file (str): Path to the kubeconfig file.
kubeconfig_content (str or dict): Content of the kubeconfig file.
context (str): Context name.
cluster_name (str): Cluster name.
Returns:
Tuple: A tuple containing the API client and the context.
@@ -270,11 +275,15 @@ class KubernetesProvider(Provider):
# If the kubeconfig file is not found, try to use the in-cluster config
logger.info("Using in-cluster config")
config.load_incluster_config()
# Use CLI flag or env var to set cluster name
resolved_cluster_name = cluster_name or os.getenv(
"CLUSTER_NAME", "in-cluster"
)
context = {
"name": "In-Cluster",
"context": {
"cluster": "in-cluster", # Placeholder, as the real cluster name is not available
"user": "service-account-name", # Also a placeholder
"cluster": resolved_cluster_name,
"user": "service-account-name",
},
}
return KubernetesSession(
@@ -27,3 +27,9 @@ def init_parser(self):
metavar="NAMESPACES",
help="The namespaces where to scan for the Kubernetes resources. By default, Prowler will scan all namespaces available.",
)
k8s_auth_subparser.add_argument(
"--cluster-name",
nargs="?",
metavar="CLUSTER_NAME",
help="Manually specify the cluster name in in-cluster mode, by default it will be 'in-cluster'",
)
+10 -10
View File
@@ -98,21 +98,21 @@ class M365BaseException(ProwlerException):
"message": "User and Password environment variables are needed to use Credentials authentication method.",
"remediation": "Ensure your environment variables are properly set up.",
},
(6023, "M365EnvironmentUserCredentialsError"): {
"message": "User or Password environment variables are not correct.",
"remediation": "Ensure you are using the right credentials.",
(6023, "M365UserCredentialsError"): {
"message": "The provided User credentials are not valid.",
"remediation": "Check the User credentials and ensure they are valid.",
},
(6024, "M365NotValidUserError"): {
"message": "The provided M365 User is not valid.",
"remediation": "Check the M365 User and ensure it is a valid user.",
"message": "The provided User is not valid.",
"remediation": "Check the User and ensure it is a valid user.",
},
(6025, "M365NotValidEncryptedPasswordError"): {
"message": "The provided M365 Encrypted Password is not valid.",
"remediation": "Check the M365 Encrypted Password and ensure it is a valid password.",
"message": "The provided Encrypted Password is not valid.",
"remediation": "Check the Encrypted Password and ensure it is a valid password.",
},
(6026, "M365UserNotBelongingToTenantError"): {
"message": "The provided M365 User does not belong to the specified tenant.",
"remediation": "Check the M365 User email domain and ensure it belongs to the specified tenant.",
"message": "The provided User does not belong to the specified tenant.",
"remediation": "Check the User email domain and ensure it belongs to the specified tenant.",
},
}
@@ -298,7 +298,7 @@ class M365MissingEnvironmentCredentialsError(M365CredentialsError):
)
class M365EnvironmentUserCredentialsError(M365CredentialsError):
class M365UserCredentialsError(M365CredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
6023, file=file, original_exception=original_exception, message=message
@@ -525,6 +525,26 @@ class M365PowerShell(PowerShellSession):
"Get-HostedContentFilterPolicy | ConvertTo-Json", json_parse=True
)
def get_inbound_spam_filter_rule(self) -> dict:
"""
Get Inbound Spam Filter Rule.
Retrieves the current inbound spam filter rule settings for Exchange Online.
Returns:
dict: Inbound spam filter rule settings in JSON format.
Example:
>>> get_inbound_spam_filter_rule()
{
"Name": "Rule1",
"State": "Enabled"
}
"""
return self.execute(
"Get-HostedContentFilterRule | ConvertTo-Json", json_parse=True
)
def get_report_submission_policy(self) -> dict:
"""
Get Exchange Online Report Submission Policy.
+30 -18
View File
@@ -33,7 +33,6 @@ from prowler.providers.m365.exceptions.exceptions import (
M365ConfigCredentialsError,
M365CredentialsUnavailableError,
M365DefaultAzureCredentialError,
M365EnvironmentUserCredentialsError,
M365EnvironmentVariableError,
M365GetTokenIdentityError,
M365HTTPResponseError,
@@ -51,6 +50,7 @@ from prowler.providers.m365.exceptions.exceptions import (
M365SetUpSessionError,
M365TenantIdAndClientIdNotBelongingToClientSecretError,
M365TenantIdAndClientSecretNotBelongingToClientIdError,
M365UserCredentialsError,
)
from prowler.providers.m365.lib.mutelist.mutelist import M365Mutelist
from prowler.providers.m365.lib.powershell.m365_powershell import (
@@ -207,6 +207,7 @@ class M365Provider(Provider):
m365_credentials=m365_credentials,
provider_id=self.identity.tenant_domain,
init_modules=init_modules,
identity=self.identity,
)
# Audit Config
@@ -382,6 +383,7 @@ class M365Provider(Provider):
m365_credentials: dict = {},
provider_id: str = None,
init_modules: bool = False,
identity: M365IdentityInfo = None,
) -> M365Credentials:
"""Gets the M365 credentials.
@@ -394,6 +396,7 @@ class M365Provider(Provider):
If False, returns empty credentials.
"""
credentials = None
if m365_credentials:
credentials = M365Credentials(
user=m365_credentials.get("user", ""),
@@ -428,15 +431,18 @@ class M365Provider(Provider):
)
if credentials:
if identity:
identity.identity_type = "Service Principal and User Credentials"
identity.user = credentials.user
test_session = M365PowerShell(credentials)
try:
if test_session.test_credentials(credentials):
if init_modules:
initialize_m365_powershell_modules()
return credentials
raise M365EnvironmentUserCredentialsError(
raise M365UserCredentialsError(
file=os.path.basename(__file__),
message="M365_USER or M365_ENCRYPTED_PASSWORD environment variables are not correct. Please ensure you are using the right credentials.",
message="The provided User credentials are not valid.",
)
finally:
test_session.close()
@@ -994,7 +1000,7 @@ class M365Provider(Provider):
except ValueError:
raise M365NotValidTenantIdError(
file=os.path.basename(__file__),
message="The provided M365 Tenant ID is not valid.",
message="The provided Tenant ID is not valid.",
)
# Validate the Client ID
@@ -1003,28 +1009,28 @@ class M365Provider(Provider):
except ValueError:
raise M365NotValidClientIdError(
file=os.path.basename(__file__),
message="The provided M365 Client ID is not valid.",
message="The provided Client ID is not valid.",
)
# Validate the Client Secret
if not client_secret:
raise M365NotValidClientSecretError(
file=os.path.basename(__file__),
message="The provided M365 Client Secret is not valid.",
message="The provided Client Secret is not valid.",
)
# Validate the User
if not user:
raise M365NotValidUserError(
file=os.path.basename(__file__),
message="The provided M365 User is not valid.",
message="The provided User is not valid.",
)
# Validate the Encrypted Password
if not encrypted_password:
raise M365NotValidEncryptedPasswordError(
file=os.path.basename(__file__),
message="The provided M365 Encrypted Password is not valid.",
message="The provided Encrypted Password is not valid.",
)
try:
@@ -1042,7 +1048,7 @@ class M365Provider(Provider):
)
raise M365ClientIdAndClientSecretNotBelongingToTenantIdError(
file=os.path.basename(__file__),
message="The provided M365 Client ID and Client Secret do not belong to the specified Tenant ID.",
message="The provided Client ID and Client Secret do not belong to the specified Tenant ID.",
)
except M365NotValidClientIdError as client_id_error:
logger.error(
@@ -1050,7 +1056,7 @@ class M365Provider(Provider):
)
raise M365TenantIdAndClientSecretNotBelongingToClientIdError(
file=os.path.basename(__file__),
message="The provided M365 Tenant ID and Client Secret do not belong to the specified Client ID.",
message="The provided Tenant ID and Client Secret do not belong to the specified Client ID.",
)
except M365NotValidClientSecretError as client_secret_error:
logger.error(
@@ -1058,7 +1064,7 @@ class M365Provider(Provider):
)
raise M365TenantIdAndClientIdNotBelongingToClientSecretError(
file=os.path.basename(__file__),
message="The provided M365 Tenant ID and Client ID do not belong to the specified Client Secret.",
message="The provided Tenant ID and Client ID do not belong to the specified Client Secret.",
)
@staticmethod
@@ -1100,19 +1106,25 @@ class M365Provider(Provider):
if f"Tenant '{tenant_id}'" in error_description:
raise M365NotValidTenantIdError(
file=os.path.basename(__file__),
message="The provided Microsoft 365 Tenant ID is not valid for the specified Client ID and Client Secret.",
message="The provided Tenant ID is not valid for the specified Client ID and Client Secret.",
)
if f"Application with identifier '{client_id}'" in error_description:
raise M365NotValidClientIdError(
file=os.path.basename(__file__),
message="The provided Microsoft 365 Client ID is not valid for the specified Tenant ID and Client Secret.",
message="The provided Client ID is not valid for the specified Tenant ID and Client Secret.",
)
if "Invalid client secret provided" in error_description:
raise M365NotValidClientSecretError(
file=os.path.basename(__file__),
message="The provided Microsoft 365 Client Secret is not valid for the specified Tenant ID and Client ID.",
message="The provided Client Secret is not valid for the specified Tenant ID and Client ID.",
)
except Exception as e:
# Generic exception handling (if needed)
raise RuntimeError(f"An unexpected error occurred: {str(e)}")
except (
M365NotValidTenantIdError,
M365NotValidClientIdError,
M365NotValidClientSecretError,
) as m365_error:
# M365 specific errors already raised
raise RuntimeError(f"{m365_error}")
except Exception as error:
# Generic exception handling for unexpected errors
raise RuntimeError(f"An unexpected error occurred: {str(error)}")
+1
View File
@@ -10,6 +10,7 @@ class M365IdentityInfo(BaseModel):
tenant_id: str = ""
tenant_domain: str = "Unknown tenant domain (missing AAD permissions)"
location: str = ""
user: str = None
class M365RegionConfig(BaseModel):
@@ -23,37 +23,155 @@ class defender_antiphishing_policy_configured(Check):
List[CheckReportM365]: A list of reports containing the result of the check.
"""
findings = []
for policy_name, policy in defender_client.antiphishing_policies.items():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Defender Anti-Phishing Policy",
resource_id=policy_name,
)
report.status = "FAIL"
report.status_extended = (
f"Anti-phishing policy {policy_name} is not properly configured."
)
if (
not policy.default
and policy_name in defender_client.antiphising_rules
and defender_client.antiphising_rules[policy_name].state.lower()
== "enabled"
) or policy.default:
if (
policy.spoof_intelligence
and policy.spoof_intelligence_action.lower() == "quarantine"
and policy.dmarc_reject_action.lower() == "quarantine"
and policy.dmarc_quarantine_action.lower() == "quarantine"
and policy.safety_tips
and policy.unauthenticated_sender_action
and policy.show_tag
and policy.honor_dmarc_policy
):
if defender_client.antiphishing_policies:
# Only Default Defender Anti-Phishing Policy exists since there are only anti phishing rules when there are custom policies
if not defender_client.antiphishing_rules:
# Get the only policy in the dictionary since there is only the default policy
policy = next(iter(defender_client.antiphishing_policies.values()))
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.name,
resource_id=policy.name,
)
if self._is_policy_properly_configured(policy):
# Case 1: Default policy exists and is properly configured
report.status = "PASS"
report.status_extended = f"Anti-phishing policy {policy_name} is properly configured and enabled."
report.status_extended = f"{policy.name} is the only policy and it's properly configured in the default Defender Anti-Phishing Policy."
else:
# Case 5: Default policy exists but is not properly configured
report.status = "FAIL"
report.status_extended = f"{policy.name} is the only policy and it's not properly configured in the default Defender Anti-Phishing Policy."
findings.append(report)
findings.append(report)
# Multiple Defender Anti-Phishing Policies
else:
default_policy_well_configured = False
for (
policy_name,
policy,
) in defender_client.antiphishing_policies.items():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy_name,
resource_id=policy_name,
)
if policy.default:
if not self._is_policy_properly_configured(policy):
# Case 4: Default policy is not properly configured and there are other policies
report.status = "FAIL"
report.status_extended = f"{policy_name} is not properly configured in the default Defender Anti-Phishing Policy, but could be overridden by another well-configured Custom Policy."
findings.append(report)
else:
# Case 2: Default policy is properly configured and there are other policies
report.status = "PASS"
report.status_extended = f"{policy_name} is properly configured in the default Defender Anti-Phishing Policy, but could be overridden by another bad-configured Custom Policy."
default_policy_well_configured = True
findings.append(report)
else:
if not self._is_policy_properly_configured(policy):
included_resources = []
if defender_client.antiphishing_rules[policy.name].users:
included_resources.append(
f"users: {', '.join(defender_client.antiphishing_rules[policy.name].users)}"
)
if defender_client.antiphishing_rules[policy.name].groups:
included_resources.append(
f"groups: {', '.join(defender_client.antiphishing_rules[policy.name].groups)}"
)
if defender_client.antiphishing_rules[policy.name].domains:
included_resources.append(
f"domains: {', '.join(defender_client.antiphishing_rules[policy.name].domains)}"
)
included_resources_str = "; ".join(included_resources)
# Case 3: Default policy is properly configured but other custom policies are not
if default_policy_well_configured:
report.status = "FAIL"
report.status_extended = (
f"Custom Anti-phishing policy {policy_name} is not properly configured and includes {included_resources_str}, "
f"with priority {defender_client.antiphishing_rules[policy.name].priority} (0 is the highest). "
"However, the default policy is properly configured, so entities not included by this custom policy could be correctly protected."
)
findings.append(report)
# Case 5: Default policy is not properly configured and other custom policies are not
else:
report.status = "FAIL"
report.status_extended = (
f"Custom Anti-phishing policy {policy_name} is not properly configured and includes {included_resources_str}, "
f"with priority {defender_client.antiphishing_rules[policy.name].priority} (0 is the highest). "
"Also, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
else:
included_resources = []
if defender_client.antiphishing_rules[policy.name].users:
included_resources.append(
f"users: {', '.join(defender_client.antiphishing_rules[policy.name].users)}"
)
if defender_client.antiphishing_rules[policy.name].groups:
included_resources.append(
f"groups: {', '.join(defender_client.antiphishing_rules[policy.name].groups)}"
)
if defender_client.antiphishing_rules[policy.name].domains:
included_resources.append(
f"domains: {', '.join(defender_client.antiphishing_rules[policy.name].domains)}"
)
included_resources_str = "; ".join(included_resources)
# Case 2: Default policy is properly configured and other custom policies are too
if default_policy_well_configured:
report.status = "PASS"
report.status_extended = (
f"Custom Anti-phishing policy {policy_name} is properly configured and includes {included_resources_str}, "
f"with priority {defender_client.antiphishing_rules[policy.name].priority} (0 is the highest). "
"Also, the default policy is properly configured, so entities not included by this custom policy could still be correctly protected."
)
findings.append(report)
# Case 6: Default policy is not properly configured but other custom policies are
else:
report.status = "PASS"
report.status_extended = (
f"Custom Anti-phishing policy {policy_name} is properly configured and includes {included_resources_str}, "
f"with priority {defender_client.antiphishing_rules[policy.name].priority} (0 is the highest). "
"However, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
return findings
def _is_policy_properly_configured(self, policy) -> bool:
"""
Check if a policy is properly configured according to best practices.
Args:
policy: The anti-phishing policy to check.
Returns:
bool: True if the policy is properly configured, False otherwise.
"""
return (
(
policy.default
or defender_client.antiphishing_rules[policy.name].state.lower()
== "enabled"
)
and policy.spoof_intelligence
and policy.spoof_intelligence_action.lower() == "quarantine"
and policy.dmarc_reject_action.lower() == "quarantine"
and policy.dmarc_quarantine_action.lower() == "quarantine"
and policy.safety_tips
and policy.unauthenticated_sender_action
and policy.show_tag
and policy.honor_dmarc_policy
)
@@ -6,8 +6,7 @@ from prowler.providers.m365.services.defender.defender_client import defender_cl
class defender_antispam_outbound_policy_configured(Check):
"""
Check if the Exchange Online Spam Policies are configured to notify administrators
when a sender is blocked for sending spam emails.
Check if the outbound spam policy is established and properly configured in the Defender service.
Attributes:
metadata: Metadata associated with the check (inherited from Check).
@@ -15,40 +14,159 @@ class defender_antispam_outbound_policy_configured(Check):
def execute(self) -> List[CheckReportM365]:
"""
Execute the check to verify if the Exchange Online Spam Policies notify administrators
when a sender is blocked for sending spam emails.
Execute the check to verify if an outbound spam policy is established and properly configured.
This method checks the Defender outbound spam policies to ensure they are configured
according to best practices.
Returns:
List[CheckReportM365]: A list of reports containing the result of the check.
"""
findings = []
for policy_name, policy in defender_client.outbound_spam_policies.items():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Defender Outbound Spam Policy",
resource_id=policy_name,
)
report.status = "FAIL"
report.status_extended = (
f"Outbound Spam Policy {policy_name} is not properly configured."
)
if (
not policy.default
and policy_name in defender_client.outbound_spam_rules
and defender_client.outbound_spam_rules[policy_name].state.lower()
== "enabled"
) or policy.default:
if (
policy.notify_limit_exceeded
and policy.notify_sender_blocked
and policy.notify_limit_exceeded_addresses
and policy.notify_sender_blocked_addresses
):
if defender_client.outbound_spam_policies:
# Only Default Defender Outbound Spam Policy
if not defender_client.outbound_spam_rules:
# Get the only policy in the dictionary
policy = next(iter(defender_client.outbound_spam_policies.values()))
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.name,
resource_id=policy.name,
)
if self._is_policy_properly_configured(policy):
# Case 1: Default policy exists and is properly configured
report.status = "PASS"
report.status_extended = f"Outbound Spam Policy {policy_name} is properly configured and enabled."
report.status_extended = f"{policy.name} is the only policy and it's properly configured in the default Defender Outbound Spam Policy."
else:
# Case 5: Default policy exists but is not properly configured
report.status = "FAIL"
report.status_extended = f"{policy.name} is the only policy and it's not properly configured in the default Defender Outbound Spam Policy."
findings.append(report)
findings.append(report)
# Multiple Defender Outbound Spam Policies
else:
default_policy_well_configured = False
for (
policy_name,
policy,
) in defender_client.outbound_spam_policies.items():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy_name,
resource_id=policy_name,
)
if policy.default:
if not self._is_policy_properly_configured(policy):
# Case 4: Default policy is not properly configured and there are other policies
report.status = "FAIL"
report.status_extended = f"{policy_name} is not properly configured in the default Defender Outbound Spam Policy, but could be overridden by another well-configured Custom Policy."
findings.append(report)
else:
# Case 2: Default policy is properly configured and there are other policies
report.status = "PASS"
report.status_extended = f"{policy_name} is properly configured in the default Defender Outbound Spam Policy, but could be overridden by another bad-configured Custom Policy."
default_policy_well_configured = True
findings.append(report)
else:
if not self._is_policy_properly_configured(policy):
included_resources = []
if defender_client.outbound_spam_rules[policy.name].users:
included_resources.append(
f"users: {', '.join(defender_client.outbound_spam_rules[policy.name].users)}"
)
if defender_client.outbound_spam_rules[policy.name].groups:
included_resources.append(
f"groups: {', '.join(defender_client.outbound_spam_rules[policy.name].groups)}"
)
if defender_client.outbound_spam_rules[policy.name].domains:
included_resources.append(
f"domains: {', '.join(defender_client.outbound_spam_rules[policy.name].domains)}"
)
included_resources_str = "; ".join(included_resources)
# Case 3: Default policy is properly configured but other custom policies are not
if default_policy_well_configured:
report.status = "FAIL"
report.status_extended = (
f"Custom Outbound Spam policy {policy_name} is not properly configured and includes {included_resources_str}, "
f"with priority {defender_client.outbound_spam_rules[policy.name].priority} (0 is the highest). "
"However, the default policy is properly configured, so entities not included by this custom policy could be correctly protected."
)
findings.append(report)
# Case 5: Default policy is not properly configured and other custom policies are not
else:
report.status = "FAIL"
report.status_extended = (
f"Custom Outbound Spam policy {policy_name} is not properly configured and includes {included_resources_str}, "
f"with priority {defender_client.outbound_spam_rules[policy.name].priority} (0 is the highest). "
"Also, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
else:
included_resources = []
if defender_client.outbound_spam_rules[policy.name].users:
included_resources.append(
f"users: {', '.join(defender_client.outbound_spam_rules[policy.name].users)}"
)
if defender_client.outbound_spam_rules[policy.name].groups:
included_resources.append(
f"groups: {', '.join(defender_client.outbound_spam_rules[policy.name].groups)}"
)
if defender_client.outbound_spam_rules[policy.name].domains:
included_resources.append(
f"domains: {', '.join(defender_client.outbound_spam_rules[policy.name].domains)}"
)
included_resources_str = "; ".join(included_resources)
# Case 2: Default policy is properly configured and other custom policies are too
if default_policy_well_configured:
report.status = "PASS"
report.status_extended = (
f"Custom Outbound Spam policy {policy_name} is properly configured and includes {included_resources_str}, "
f"with priority {defender_client.outbound_spam_rules[policy.name].priority} (0 is the highest). "
"Also, the default policy is properly configured, so entities not included by this custom policy could still be correctly protected."
)
findings.append(report)
# Case 6: Default policy is not properly configured but other custom policies are
else:
report.status = "PASS"
report.status_extended = (
f"Custom Outbound Spam policy {policy_name} is properly configured and includes {included_resources_str}, "
f"with priority {defender_client.outbound_spam_rules[policy.name].priority} (0 is the highest). "
"However, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
return findings
def _is_policy_properly_configured(self, policy) -> bool:
"""
Check if a policy is properly configured according to best practices.
Args:
policy: The outbound spam policy to check.
Returns:
bool: True if the policy is properly configured, False otherwise.
"""
return (
(
policy.default
or defender_client.outbound_spam_rules[policy.name].state.lower()
== "enabled"
)
and policy.notify_limit_exceeded
and policy.notify_sender_blocked
and policy.notify_limit_exceeded_addresses
and policy.notify_sender_blocked_addresses
)
@@ -20,28 +20,150 @@ class defender_antispam_outbound_policy_forwarding_disabled(Check):
List[CheckReportM365]: A list of reports containing the result of the check.
"""
findings = []
for policy_name, policy in defender_client.outbound_spam_policies.items():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Defender Outbound Spam Policy",
resource_id=policy_name,
)
report.status = "FAIL"
report.status_extended = (
f"Outbound Spam Policy {policy_name} does allow mail forwarding."
)
if (
not policy.default
and policy_name in defender_client.outbound_spam_rules
and defender_client.outbound_spam_rules[policy_name].state.lower()
== "enabled"
) or policy.default:
if not policy.auto_forwarding_mode:
if defender_client.outbound_spam_policies:
# Only Default Defender Outbound Spam Policy exists
if not defender_client.outbound_spam_rules:
policy = next(iter(defender_client.outbound_spam_policies.values()))
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.name,
resource_id=policy.name,
)
if self._is_forwarding_disabled(policy):
# Case 1: Default policy exists and has forwarding disabled
report.status = "PASS"
report.status_extended = f"Outbound Spam Policy {policy_name} does not allow mail forwarding."
report.status_extended = f"{policy.name} is the only policy and mail forwarding is disabled."
else:
# Case 5: Default policy exists but allows forwarding
report.status = "FAIL"
report.status_extended = f"{policy.name} is the only policy and mail forwarding is allowed."
findings.append(report)
findings.append(report)
# Multiple Defender Outbound Spam Policies exist
else:
default_policy_well_configured = False
for (
policy_name,
policy,
) in defender_client.outbound_spam_policies.items():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy_name,
resource_id=policy_name,
)
if policy.default:
if not self._is_forwarding_disabled(policy):
# Case 4: Default policy allows forwarding and there are other policies
report.status = "FAIL"
report.status_extended = (
f"{policy_name} is the default policy and mail forwarding is allowed, "
"but it could be overridden by another well-configured Custom Policy."
)
findings.append(report)
else:
# Case 2: Default policy disables forwarding and there are other policies
report.status = "PASS"
report.status_extended = (
f"{policy_name} is the default policy and mail forwarding is disabled, "
"but it could be overridden by another misconfigured Custom Policy."
)
default_policy_well_configured = True
findings.append(report)
else:
if not self._is_forwarding_disabled(policy):
included_resources = []
if defender_client.outbound_spam_rules[policy.name].users:
included_resources.append(
f"users: {', '.join(defender_client.outbound_spam_rules[policy.name].users)}"
)
if defender_client.outbound_spam_rules[policy.name].groups:
included_resources.append(
f"groups: {', '.join(defender_client.outbound_spam_rules[policy.name].groups)}"
)
if defender_client.outbound_spam_rules[policy.name].domains:
included_resources.append(
f"domains: {', '.join(defender_client.outbound_spam_rules[policy.name].domains)}"
)
included_resources_str = "; ".join(included_resources)
if default_policy_well_configured:
# Case 3: Default policy disables forwarding but custom one doesn't
report.status = "FAIL"
report.status_extended = (
f"Custom Outbound Spam policy {policy_name} allows mail forwarding and includes {included_resources_str}, "
f"with priority {defender_client.outbound_spam_rules[policy.name].priority} (0 is the highest). "
"However, the default policy disables mail forwarding, so entities not included by this custom policy could be correctly protected."
)
findings.append(report)
else:
# Case 5: Neither default nor custom policies disable forwarding
report.status = "FAIL"
report.status_extended = (
f"Custom Outbound Spam policy {policy_name} allows mail forwarding and includes {included_resources_str}, "
f"with priority {defender_client.outbound_spam_rules[policy.name].priority} (0 is the highest). "
"Also, the default policy allows mail forwarding, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
else:
included_resources = []
if defender_client.outbound_spam_rules[policy.name].users:
included_resources.append(
f"users: {', '.join(defender_client.outbound_spam_rules[policy.name].users)}"
)
if defender_client.outbound_spam_rules[policy.name].groups:
included_resources.append(
f"groups: {', '.join(defender_client.outbound_spam_rules[policy.name].groups)}"
)
if defender_client.outbound_spam_rules[policy.name].domains:
included_resources.append(
f"domains: {', '.join(defender_client.outbound_spam_rules[policy.name].domains)}"
)
included_resources_str = "; ".join(included_resources)
if default_policy_well_configured:
# Case 2: Both default and custom policies disable forwarding
report.status = "PASS"
report.status_extended = (
f"Custom Outbound Spam policy {policy_name} disables mail forwarding and includes {included_resources_str}, "
f"with priority {defender_client.outbound_spam_rules[policy.name].priority} (0 is the highest). "
"Also, the default policy disables mail forwarding, so entities not included by this custom policy could still be correctly protected."
)
findings.append(report)
else:
# Case 6: Default policy allows forwarding, custom policy disables it
report.status = "PASS"
report.status_extended = (
f"Custom Outbound Spam policy {policy_name} disables mail forwarding and includes {included_resources_str}, "
f"with priority {defender_client.outbound_spam_rules[policy.name].priority} (0 is the highest). "
"However, the default policy allows mail forwarding, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
return findings
def _is_forwarding_disabled(self, policy) -> bool:
"""
Check if mail forwarding is disabled in the policy.
Args:
policy: The outbound spam policy to check.
Returns:
bool: True if mail forwarding is disabled, False otherwise.
"""
return (
policy.default
or defender_client.outbound_spam_rules[policy.name].state.lower()
== "enabled"
) and not policy.auto_forwarding_mode
@@ -23,20 +23,163 @@ class defender_antispam_policy_inbound_no_allowed_domains(Check):
List[CheckReportM365]: A list of reports containing the result of the check.
"""
findings = []
for policy in defender_client.inbound_spam_policies:
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Defender Inbound Spam Policy",
resource_id=policy.identity,
)
report.status = "PASS"
report.status_extended = f"Inbound anti-spam policy {policy.identity} does not contain allowed domains."
if policy.allowed_sender_domains:
report.status = "FAIL"
report.status_extended = f"Inbound anti-spam policy {policy.identity} contains allowed domains: {policy.allowed_sender_domains}."
if defender_client.inbound_spam_policies:
# Only Default Defender Inbound Spam Policy exists
if not defender_client.inbound_spam_rules:
policy = defender_client.inbound_spam_policies[0]
findings.append(report)
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.identity,
resource_id=policy.identity,
)
if self._has_no_allowed_domains(policy):
# Case 1: Default policy exists and has no allowed domains
report.status = "PASS"
report.status_extended = f"{policy.identity} is the only policy and it does not contain allowed domains."
else:
# Case 5: Default policy exists but contains allowed domains
report.status = "FAIL"
report.status_extended = f"{policy.identity} is the only policy and it contains allowed domains: {', '.join(policy.allowed_sender_domains)}."
findings.append(report)
# Multiple Defender Inbound Spam Policies exist
else:
default_policy_well_configured = False
for policy in defender_client.inbound_spam_policies:
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.identity,
resource_id=policy.identity,
)
if policy.default:
if not self._has_no_allowed_domains(policy):
# Case 4: Default policy contains allowed domains
report.status = "FAIL"
report.status_extended = (
f"{policy.identity} is the default policy and it contains allowed domains: {', '.join(policy.allowed_sender_domains)}, "
"but it could be overridden by another well-configured Custom Policy."
)
findings.append(report)
else:
# Case 2: Default policy has no allowed domains and there are other policies
report.status = "PASS"
report.status_extended = (
f"{policy.identity} is the default policy and it does not contain allowed domains, "
"but it could be overridden by another misconfigured Custom Policy."
)
default_policy_well_configured = True
findings.append(report)
else:
if not self._has_no_allowed_domains(policy):
included_resources = []
if defender_client.inbound_spam_rules[
policy.identity
].users:
included_resources.append(
f"users: {', '.join(defender_client.inbound_spam_rules[policy.identity].users)}"
)
if defender_client.inbound_spam_rules[
policy.identity
].groups:
included_resources.append(
f"groups: {', '.join(defender_client.inbound_spam_rules[policy.identity].groups)}"
)
if defender_client.inbound_spam_rules[
policy.identity
].domains:
included_resources.append(
f"domains: {', '.join(defender_client.inbound_spam_rules[policy.identity].domains)}"
)
included_resources_str = "; ".join(included_resources)
priority = defender_client.inbound_spam_rules[
policy.identity
].priority
if default_policy_well_configured:
# Case 3: Default policy has no allowed domains but custom one does
report.status = "FAIL"
report.status_extended = (
f"Custom Inbound Spam policy {policy.identity} contains allowed domains and includes {included_resources_str}, "
f"with priority {priority} (0 is the highest). However, the default policy does not contain allowed domains, "
"so entities not included by this custom policy could be correctly protected."
)
else:
# Case 5: Neither default nor custom policies are correctly configured
report.status = "FAIL"
report.status_extended = (
f"Custom Inbound Spam policy {policy.identity} contains allowed domains and includes {included_resources_str}, "
f"with priority {priority} (0 is the highest). Also, the default policy contains allowed domains, "
"so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
else:
included_resources = []
if defender_client.inbound_spam_rules[
policy.identity
].users:
included_resources.append(
f"users: {', '.join(defender_client.inbound_spam_rules[policy.identity].users)}"
)
if defender_client.inbound_spam_rules[
policy.identity
].groups:
included_resources.append(
f"groups: {', '.join(defender_client.inbound_spam_rules[policy.identity].groups)}"
)
if defender_client.inbound_spam_rules[
policy.identity
].domains:
included_resources.append(
f"domains: {', '.join(defender_client.inbound_spam_rules[policy.identity].domains)}"
)
included_resources_str = "; ".join(included_resources)
priority = defender_client.inbound_spam_rules[
policy.identity
].priority
if default_policy_well_configured:
# Case 2: Both default and custom policies do not contain allowed domains
report.status = "PASS"
report.status_extended = (
f"Custom Inbound Spam policy {policy.identity} does not contain allowed domains and includes {included_resources_str}, "
f"with priority {priority} (0 is the highest). Also, the default policy does not contain allowed domains, "
"so entities not included by this custom policy could still be correctly protected."
)
else:
# Case 6: Default policy contains allowed domains, custom policy does not
report.status = "PASS"
report.status_extended = (
f"Custom Inbound Spam policy {policy.identity} does not contain allowed domains and includes {included_resources_str}, "
f"with priority {priority} (0 is the highest). However, the default policy contains allowed domains, "
"so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
return findings
def _has_no_allowed_domains(self, policy) -> bool:
"""
Check if the policy has no allowed domains.
Args:
policy: The inbound spam policy to check.
Returns:
bool: True if the policy has no allowed domains, False otherwise.
"""
return (
policy.default
or defender_client.inbound_spam_rules[policy.identity].state.lower()
== "enabled"
) and not policy.allowed_sender_domains
@@ -16,39 +16,138 @@ class defender_malware_policy_common_attachments_filter_enabled(Check):
"""
Execute the check to verify if the Common Attachment Types Filter is enabled.
This method checks the Defender anti-malware policy to determine if the
This method checks the Defender anti-malware policies to determine if the
Common Attachment Types Filter is enabled.
Returns:
List[CheckReportM365]: A list of reports containing the result of the check.
"""
findings = []
if not defender_client.malware_policies:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender Malware Policy",
resource_id="defenderMalwarePolicy",
)
report.status = "FAIL"
report.status_extended = "Common Attachment Types Filter is not enabled."
findings.append(report)
else:
for policy in defender_client.malware_policies:
if defender_client.malware_policies:
# Only Default Defender Malware Policy exists
if not defender_client.malware_rules:
policy = defender_client.malware_policies[0]
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Defender Malware Policy",
resource_id="defenderMalwarePolicy",
resource_name=policy.identity,
resource_id=policy.identity,
)
report.status = "FAIL"
report.status_extended = f"Common Attachment Types Filter is not enabled in anti-malware policy {policy.identity}."
if policy.enable_file_filter:
# Case 1: Default policy exists and has the setting enabled
report.status = "PASS"
report.status_extended = f"Common Attachment Types Filter is enabled in anti-malware policy {policy.identity}."
break
report.status_extended = f"{policy.identity} is the only policy and Common Attachment Types Filter is enabled."
else:
# Case 5: Default policy exists but doesn't have the setting enabled
report.status = "FAIL"
report.status_extended = f"{policy.identity} is the only policy and Common Attachment Types Filter is not enabled."
findings.append(report)
findings.append(report)
# Multiple Defender Malware Policies exist
else:
default_policy_well_configured = False
for policy in defender_client.malware_policies:
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.identity,
resource_id=policy.identity,
)
if policy.is_default:
if not policy.enable_file_filter:
# Case 4: Default policy doesn't have the setting enabled and there are other policies
report.status = "FAIL"
report.status_extended = (
f"{policy.identity} is the default policy and Common Attachment Types Filter is not enabled, "
"but it could be overridden by another well-configured Custom Policy."
)
findings.append(report)
else:
# Case 2: Default policy has the setting enabled and there are other policies
report.status = "PASS"
report.status_extended = (
f"{policy.identity} is the default policy and Common Attachment Types Filter is enabled, "
"but it could be overridden by another misconfigured Custom Policy."
)
default_policy_well_configured = True
findings.append(report)
else:
if not policy.enable_file_filter:
included_resources = []
if defender_client.malware_rules[policy.identity].users:
included_resources.append(
f"users: {', '.join(defender_client.malware_rules[policy.identity].users)}"
)
if defender_client.malware_rules[policy.identity].groups:
included_resources.append(
f"groups: {', '.join(defender_client.malware_rules[policy.identity].groups)}"
)
if defender_client.malware_rules[policy.identity].domains:
included_resources.append(
f"domains: {', '.join(defender_client.malware_rules[policy.identity].domains)}"
)
included_resources_str = "; ".join(included_resources)
if default_policy_well_configured:
# Case 3: Default policy enables the setting but custom one doesn't
report.status = "FAIL"
report.status_extended = (
f"Custom Malware policy {policy.identity} does not enable Common Attachment Types Filter and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
"However, the default policy enables the filter, so entities not included by this custom policy could be correctly protected."
)
findings.append(report)
else:
# Case 5: Neither default nor custom policies enable the setting
report.status = "FAIL"
report.status_extended = (
f"Custom Malware policy {policy.identity} does not enable Common Attachment Types Filter and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
"Also, the default policy does not enable the filter, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
else:
included_resources = []
if defender_client.malware_rules[policy.identity].users:
included_resources.append(
f"users: {', '.join(defender_client.malware_rules[policy.identity].users)}"
)
if defender_client.malware_rules[policy.identity].groups:
included_resources.append(
f"groups: {', '.join(defender_client.malware_rules[policy.identity].groups)}"
)
if defender_client.malware_rules[policy.identity].domains:
included_resources.append(
f"domains: {', '.join(defender_client.malware_rules[policy.identity].domains)}"
)
included_resources_str = "; ".join(included_resources)
if default_policy_well_configured:
# Case 2: Both default and custom policies enable the setting
report.status = "PASS"
report.status_extended = (
f"Custom Malware policy {policy.identity} enables Common Attachment Types Filter and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
"Also, the default policy enables the filter, so entities not included by this custom policy could still be correctly protected."
)
findings.append(report)
else:
# Case 6: Default policy doesn't enable the setting, but custom policy does
report.status = "PASS"
report.status_extended = (
f"Custom Malware policy {policy.identity} enables Common Attachment Types Filter and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
"However, the default policy does not enable the filter, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
return findings
@@ -14,17 +14,16 @@ class defender_malware_policy_comprehensive_attachments_filter_applied(Check):
def execute(self) -> List[CheckReportM365]:
"""
Executes the check to determine if the Common Attachment Types Filter is enabled.
Executes the check to determine if the Common Attachment Types Filter is properly configured.
This method evaluates the Defender anti-malware policy to ensure it is enabled and the
Common Attachment Types Filter is active and applied to the recommended file types.
This method evaluates the Defender anti-malware policies to ensure the filter is enabled and applied
to all recommended file types.
Returns:
List[CheckReportM365]: A list of reports with the results of the check.
"""
findings = []
# Default to Microsoft-recommended common file types
default_recommended_extensions = [
"ace",
"ani",
@@ -81,63 +80,175 @@ class defender_malware_policy_comprehensive_attachments_filter_applied(Check):
"z",
]
# Load extensions from audit_config (user config), fallback to default
recommended_extensions = defender_client.audit_config.get(
"recommended_blocked_file_types", default_recommended_extensions
)
if not defender_client.malware_policies:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender Malware Policy",
resource_id="defenderMalwarePolicy",
)
report.status = "FAIL"
report.status_extended = "Common Attachment Types Filter is not enabled."
findings.append(report)
else:
for policy in defender_client.malware_policies:
if defender_client.malware_policies:
# Only Default Defender Malware Policy exists
if not defender_client.malware_rules:
policy = defender_client.malware_policies[0]
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Defender Malware Policy",
resource_id="defenderMalwarePolicy",
resource_name=policy.identity,
resource_id=policy.identity,
)
report.status = "FAIL"
report.status_extended = f"Common Attachment Types Filter is not properly configured in anti-malware policy {policy.identity}."
if not policy.enable_file_filter:
report.status_extended = f"Common Attachment Types Filter is not enabled in anti-malware policy {policy.identity}."
break
if self._is_filter_properly_configured(policy, recommended_extensions):
# Case 1: Default policy exists and has filter properly configured
report.status = "PASS"
report.status_extended = f"{policy.identity} is the only policy and Common Attachment Types Filter is properly configured."
else:
# Case 5: Default policy exists but doesn't have filter properly configured
missing = self._get_missing_extensions(
policy, recommended_extensions
)
report.status = "FAIL"
report.status_extended = f"{policy.identity} is the only policy and Common Attachment Types Filter is not properly configured. Missing recommended file types: {', '.join(missing)}."
findings.append(report)
if (
not policy.is_default
and policy.identity in defender_client.malware_rules
) or policy.is_default:
if (
not policy.is_default
and defender_client.malware_rules[policy.identity].state.lower()
== "enabled"
) or policy.is_default:
blocked_extensions = [ext.lower() for ext in policy.file_types]
missing = [
ext
for ext in recommended_extensions
if ext.lower() not in blocked_extensions
]
# Multiple Defender Malware Policies exist
else:
default_policy_well_configured = False
if missing:
report.status_extended = f"Common Attachment Types Filter is enabled in anti-malware policy {policy.identity}, but the following recommended file types are missing: {', '.join(missing)}."
break
report.status = "PASS"
report.status_extended = f"Common Attachment Types Filter is enabled in anti-malware policy {policy.identity}, the policy is enabled and the filter is applied to the recommended file types."
for policy in defender_client.malware_policies:
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.identity,
resource_id=policy.identity,
)
if policy.is_default:
if not self._is_filter_properly_configured(
policy, recommended_extensions
):
# Case 4: Default policy is not properly configured
missing = self._get_missing_extensions(
policy, recommended_extensions
)
report.status = "FAIL"
report.status_extended = (
f"{policy.identity} is the default policy and Common Attachment Types Filter is not properly configured, "
f"but it could be overridden by another well-configured Custom Policy. Missing recommended file types: {', '.join(missing)}."
)
findings.append(report)
else:
# Case 2: Default policy is properly configured
report.status = "PASS"
report.status_extended = (
f"{policy.identity} is the default policy and Common Attachment Types Filter is properly configured, "
"but it could be overridden by another misconfigured Custom Policy."
)
default_policy_well_configured = True
findings.append(report)
else:
report.status_extended = f"Common Attachment Types Filter is enabled in anti-malware policy {policy.identity}, but the policy is disabled."
break
if not self._is_filter_properly_configured(
policy, recommended_extensions
):
included_resources = []
findings.append(report)
if defender_client.malware_rules[policy.identity].users:
included_resources.append(
f"users: {', '.join(defender_client.malware_rules[policy.identity].users)}"
)
if defender_client.malware_rules[policy.identity].groups:
included_resources.append(
f"groups: {', '.join(defender_client.malware_rules[policy.identity].groups)}"
)
if defender_client.malware_rules[policy.identity].domains:
included_resources.append(
f"domains: {', '.join(defender_client.malware_rules[policy.identity].domains)}"
)
included_resources_str = "; ".join(included_resources)
missing = self._get_missing_extensions(
policy, recommended_extensions
)
if default_policy_well_configured:
# Case 3: Default policy is configured, custom one isn't
report.status = "FAIL"
report.status_extended = (
f"Custom Malware policy {policy.identity} is not properly configured and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
f"Missing recommended file types: {', '.join(missing)}. "
"However, the default policy is properly configured, so entities not included by this custom policy could be correctly protected."
)
findings.append(report)
else:
# Case 5: Neither default nor custom policy is properly configured
report.status = "FAIL"
report.status_extended = (
f"Custom Malware policy {policy.identity} is not properly configured and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
f"Missing recommended file types: {', '.join(missing)}. "
"Also, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
else:
included_resources = []
if defender_client.malware_rules[policy.identity].users:
included_resources.append(
f"users: {', '.join(defender_client.malware_rules[policy.identity].users)}"
)
if defender_client.malware_rules[policy.identity].groups:
included_resources.append(
f"groups: {', '.join(defender_client.malware_rules[policy.identity].groups)}"
)
if defender_client.malware_rules[policy.identity].domains:
included_resources.append(
f"domains: {', '.join(defender_client.malware_rules[policy.identity].domains)}"
)
included_resources_str = "; ".join(included_resources)
if default_policy_well_configured:
# Case 2: Both default and custom policies are properly configured
report.status = "PASS"
report.status_extended = (
f"Custom Malware policy {policy.identity} is properly configured and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
"Also, the default policy is properly configured, so entities not included by this custom policy could still be correctly protected."
)
findings.append(report)
else:
# Case 6: Default policy not configured, custom policy is
report.status = "PASS"
report.status_extended = (
f"Custom Malware policy {policy.identity} is properly configured and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
"However, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
return findings
def _is_filter_properly_configured(self, policy, recommended_extensions) -> bool:
if not policy.enable_file_filter:
return False
if (
not policy.is_default
and policy.identity in defender_client.malware_rules
and defender_client.malware_rules[policy.identity].state.lower()
!= "enabled"
):
return False
blocked_extensions = [ext.lower() for ext in policy.file_types]
return all(ext.lower() in blocked_extensions for ext in recommended_extensions)
def _get_missing_extensions(self, policy, recommended_extensions) -> List[str]:
if not policy.enable_file_filter:
return recommended_extensions
blocked_extensions = [ext.lower() for ext in policy.file_types]
return [
ext
for ext in recommended_extensions
if ext.lower() not in blocked_extensions
]
@@ -16,47 +16,152 @@ class defender_malware_policy_notifications_internal_users_malware_enabled(Check
"""
Execute the check to verify if notifications for internal users sending malware are enabled.
This method checks the Defender anti-malware policy to determine if notifications for internal users
sending malware are enabled.
This method evaluates the Defender anti-malware policies to ensure internal sender notifications
are properly configured.
Returns:
List[CheckReportM365]: A list of reports containing the result of the check.
List[CheckReportM365]: A list of reports with the results of the check.
"""
findings = []
if not defender_client.malware_policies:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender Malware Policy",
resource_id="defenderMalwarePolicy",
)
report.status = "FAIL"
report.status_extended = (
"Notifications for internal users sending malware are not enabled."
)
findings.append(report)
else:
for policy in defender_client.malware_policies:
if defender_client.malware_policies:
# Only Default Defender Malware Policy exists
if not defender_client.malware_rules:
policy = defender_client.malware_policies[0]
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Defender Malware Policy",
resource_id="defenderMalwarePolicy",
)
report.status = "FAIL"
report.status_extended = (
"Notifications for internal users sending malware are not enabled."
resource_name=policy.identity,
resource_id=policy.identity,
)
if policy.enable_internal_sender_admin_notifications:
if policy.internal_sender_admin_address:
report.status = "PASS"
report.status_extended = "Notifications for internal users sending malware are enabled."
break
if self._are_notifications_enabled(policy):
# Case 1: Default policy exists and has notifications enabled
report.status = "PASS"
report.status_extended = f"{policy.identity} is the only policy and notifications for internal users sending malware are enabled."
else:
# Case 5: Default policy exists but doesn't have notifications enabled
report.status = "FAIL"
report.status_extended = f"{policy.identity} is the only policy and notifications for internal users sending malware are not enabled."
findings.append(report)
# Multiple Defender Malware Policies exist
else:
default_policy_well_configured = False
for policy in defender_client.malware_policies:
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.identity,
resource_id=policy.identity,
)
if policy.is_default:
if not self._are_notifications_enabled(policy):
# Case 4: Default policy not configured
report.status = "FAIL"
report.status_extended = (
f"{policy.identity} is the default policy and notifications for internal users sending malware are not enabled, "
"but it could be overridden by another well-configured Custom Policy."
)
findings.append(report)
else:
# Case 2: Default policy is properly configured
report.status = "PASS"
report.status_extended = (
f"{policy.identity} is the default policy and notifications for internal users sending malware are enabled, "
"but it could be overridden by another misconfigured Custom Policy."
)
default_policy_well_configured = True
findings.append(report)
else:
report.status = "FAIL"
report.status_extended = "Notifications for internal users sending malware are enabled, but no email addresses are configured."
if not self._are_notifications_enabled(policy):
included_resources = []
findings.append(report)
if defender_client.malware_rules[policy.identity].users:
included_resources.append(
f"users: {', '.join(defender_client.malware_rules[policy.identity].users)}"
)
if defender_client.malware_rules[policy.identity].groups:
included_resources.append(
f"groups: {', '.join(defender_client.malware_rules[policy.identity].groups)}"
)
if defender_client.malware_rules[policy.identity].domains:
included_resources.append(
f"domains: {', '.join(defender_client.malware_rules[policy.identity].domains)}"
)
included_resources_str = "; ".join(included_resources)
if default_policy_well_configured:
# Case 3: Default policy is configured, custom one isn't
report.status = "FAIL"
report.status_extended = (
f"Custom Malware policy {policy.identity} is not properly configured and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
"However, the default policy is properly configured, so entities not included by this custom policy could be correctly protected."
)
findings.append(report)
else:
# Case 5: Neither default nor custom policy is properly configured
report.status = "FAIL"
report.status_extended = (
f"Custom Malware policy {policy.identity} is not properly configured and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
"Also, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
else:
included_resources = []
if defender_client.malware_rules[policy.identity].users:
included_resources.append(
f"users: {', '.join(defender_client.malware_rules[policy.identity].users)}"
)
if defender_client.malware_rules[policy.identity].groups:
included_resources.append(
f"groups: {', '.join(defender_client.malware_rules[policy.identity].groups)}"
)
if defender_client.malware_rules[policy.identity].domains:
included_resources.append(
f"domains: {', '.join(defender_client.malware_rules[policy.identity].domains)}"
)
included_resources_str = "; ".join(included_resources)
if default_policy_well_configured:
# Case 2: Both default and custom policies are properly configured
report.status = "PASS"
report.status_extended = (
f"Custom Malware policy {policy.identity} is properly configured and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
"Also, the default policy is properly configured, so entities not included by this custom policy could still be correctly protected."
)
findings.append(report)
else:
# Case 6: Default policy not configured, custom policy is
report.status = "PASS"
report.status_extended = (
f"Custom Malware policy {policy.identity} is properly configured and includes {included_resources_str}, "
f"with priority {defender_client.malware_rules[policy.identity].priority} (0 is the highest). "
"However, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
findings.append(report)
return findings
def _are_notifications_enabled(self, policy) -> bool:
if (
not policy.is_default
and policy.identity in defender_client.malware_rules
and defender_client.malware_rules[policy.identity].state.lower()
!= "enabled"
):
return False
return (
policy.enable_internal_sender_admin_notifications
and policy.internal_sender_admin_address
)
@@ -1,5 +1,5 @@
from asyncio import gather, get_event_loop
from typing import List
from typing import List, Optional
from pydantic import BaseModel
@@ -15,10 +15,11 @@ class Defender(M365Service):
self.outbound_spam_policies = {}
self.outbound_spam_rules = {}
self.antiphishing_policies = {}
self.antiphising_rules = {}
self.antiphishing_rules = {}
self.connection_filter_policy = None
self.dkim_configurations = []
self.inbound_spam_policies = []
self.inbound_spam_rules = {}
self.report_submission_policy = None
if self.powershell:
self.powershell.connect_exchange_online()
@@ -26,11 +27,12 @@ class Defender(M365Service):
self.malware_rules = self._get_malware_filter_rule()
self.outbound_spam_policies = self._get_outbound_spam_filter_policy()
self.outbound_spam_rules = self._get_outbound_spam_filter_rule()
self.antiphishing_policies = self._get_antiphising_policy()
self.antiphising_rules = self._get_antiphising_rules()
self.antiphishing_policies = self._get_antiphishing_policy()
self.antiphishing_rules = self._get_antiphishing_rules()
self.connection_filter_policy = self._get_connection_filter_policy()
self.dkim_configurations = self._get_dkim_config()
self.inbound_spam_policies = self._get_inbound_spam_filter_policy()
self.inbound_spam_rules = self._get_inbound_spam_filter_rule()
self.report_submission_policy = self._get_report_submission_policy()
self.powershell.close()
@@ -55,7 +57,7 @@ class Defender(M365Service):
if policy:
malware_policies.append(
MalwarePolicy(
enable_file_filter=policy.get("EnableFileFilter", True),
enable_file_filter=policy.get("EnableFileFilter", False),
identity=policy.get("Identity", ""),
enable_internal_sender_admin_notifications=policy.get(
"EnableInternalSenderAdminNotifications", False
@@ -67,6 +69,7 @@ class Defender(M365Service):
is_default=policy.get("IsDefault", False),
)
)
malware_policies.sort(key=lambda x: x.is_default, reverse=True)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -84,6 +87,10 @@ class Defender(M365Service):
if rule:
malware_rules[rule.get("Name", "")] = MalwareRule(
state=rule.get("State", ""),
priority=rule.get("Priority", 0),
users=rule.get("SentTo", None),
groups=rule.get("SentToMemberOf", None),
domains=rule.get("RecipientDomainIs", None),
)
except Exception as error:
logger.error(
@@ -91,7 +98,7 @@ class Defender(M365Service):
)
return malware_rules
def _get_antiphising_policy(self):
def _get_antiphishing_policy(self):
logger.info("Microsoft365 - Getting Defender antiphishing policy...")
antiphishing_policies = {}
try:
@@ -101,6 +108,7 @@ class Defender(M365Service):
for policy in antiphishing_policy:
if policy:
antiphishing_policies[policy.get("Name", "")] = AntiphishingPolicy(
name=policy.get("Name", ""),
spoof_intelligence=policy.get("EnableSpoofIntelligence", True),
spoof_intelligence_action=policy.get(
"AuthenticationFailAction", ""
@@ -115,13 +123,21 @@ class Defender(M365Service):
honor_dmarc_policy=policy.get("HonorDmarcPolicy", True),
default=policy.get("IsDefault", False),
)
antiphishing_policies = dict(
sorted(
antiphishing_policies.items(),
key=lambda item: item[1].default,
reverse=True,
)
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return antiphishing_policies
def _get_antiphising_rules(self):
def _get_antiphishing_rules(self):
logger.info("Microsoft365 - Getting Defender antiphishing rules...")
antiphishing_rules = {}
try:
@@ -132,6 +148,10 @@ class Defender(M365Service):
if rule:
antiphishing_rules[rule.get("Name", "")] = AntiphishingRule(
state=rule.get("State", ""),
priority=rule.get("Priority", 0),
users=rule.get("SentTo", None),
groups=rule.get("SentToMemberOf", None),
domains=rule.get("RecipientDomainIs", None),
)
except Exception as error:
logger.error(
@@ -187,6 +207,7 @@ class Defender(M365Service):
for policy in outbound_spam_policy:
if policy:
outbound_spam_policies[policy.get("Name", "")] = OutboundSpamPolicy(
name=policy.get("Name", ""),
notify_sender_blocked=policy.get("NotifyOutboundSpam", True),
notify_limit_exceeded=policy.get(
"BccSuspiciousOutboundMail", True
@@ -200,6 +221,14 @@ class Defender(M365Service):
auto_forwarding_mode=policy.get("AutoForwardingMode", True),
default=policy.get("IsDefault", False),
)
outbound_spam_policies = dict(
sorted(
outbound_spam_policies.items(),
key=lambda item: item[1].default,
reverse=True,
)
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -217,6 +246,10 @@ class Defender(M365Service):
if rule:
outbound_spam_rules[rule.get("Name", "")] = OutboundSpamRule(
state=rule.get("State", "Disabled"),
priority=rule.get("Priority", 0),
users=rule.get("From", None),
groups=rule.get("FromMemberOf", None),
domains=rule.get("SenderDomainIs", None),
)
except Exception as error:
logger.error(
@@ -241,14 +274,38 @@ class Defender(M365Service):
allowed_sender_domains=policy.get(
"AllowedSenderDomains", []
),
default=policy.get("IsDefault", False),
)
)
inbound_spam_policies.sort(key=lambda x: x.default, reverse=True)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return inbound_spam_policies
def _get_inbound_spam_filter_rule(self):
logger.info("Microsoft365 - Getting Defender inbound spam filter rule...")
inbound_spam_rules = {}
try:
inbound_spam_rule = self.powershell.get_inbound_spam_filter_rule()
if isinstance(inbound_spam_rule, dict):
inbound_spam_rule = [inbound_spam_rule]
for rule in inbound_spam_rule:
if rule:
inbound_spam_rules[rule.get("Name", "")] = InboundSpamRule(
state=rule.get("State", "Disabled"),
priority=rule.get("Priority", 0),
users=rule.get("SentTo", None),
groups=rule.get("SentToMemberOf", None),
domains=rule.get("RecipientDomainIs", None),
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return inbound_spam_rules
def _get_report_submission_policy(self):
logger.info("Microsoft365 - Getting Defender report submission policy...")
report_submission_policy = None
@@ -328,9 +385,14 @@ class MalwarePolicy(BaseModel):
class MalwareRule(BaseModel):
state: str
priority: int
users: Optional[list[str]]
groups: Optional[list[str]]
domains: Optional[list[str]]
class AntiphishingPolicy(BaseModel):
name: str
spoof_intelligence: bool
spoof_intelligence_action: str
dmarc_reject_action: str
@@ -344,6 +406,10 @@ class AntiphishingPolicy(BaseModel):
class AntiphishingRule(BaseModel):
state: str
priority: int
users: Optional[list[str]]
groups: Optional[list[str]]
domains: Optional[list[str]]
class ConnectionFilterPolicy(BaseModel):
@@ -358,6 +424,7 @@ class DkimConfig(BaseModel):
class OutboundSpamPolicy(BaseModel):
name: str
notify_sender_blocked: bool
notify_limit_exceeded: bool
notify_limit_exceeded_addresses: List[str]
@@ -368,11 +435,24 @@ class OutboundSpamPolicy(BaseModel):
class OutboundSpamRule(BaseModel):
state: str
priority: int
users: Optional[list[str]]
groups: Optional[list[str]]
domains: Optional[list[str]]
class DefenderInboundSpamPolicy(BaseModel):
identity: str
allowed_sender_domains: list[str] = []
default: bool
class InboundSpamRule(BaseModel):
state: str
priority: int
users: Optional[list[str]]
groups: Optional[list[str]]
domains: Optional[list[str]]
class ReportSubmissionPolicy(BaseModel):
@@ -3,10 +3,10 @@
"CheckID": "entra_legacy_authentication_blocked",
"CheckTitle": "Ensure that Conditional Access policy blocks legacy authentication",
"CheckType": [],
"ServiceName": "critical",
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"Severity": "critical",
"ResourceType": "Conditional Access Policy",
"Description": "Ensure that Conditional Access policy blocks legacy authentication in Microsoft Entra ID to enforce modern authentication methods and protect against credential-stuffing and brute-force attacks.",
"Risk": "Legacy authentication protocols do not support MFA, making them vulnerable to credential-stuffing and brute-force attacks. Attackers commonly exploit these protocols to bypass security controls and gain unauthorized access.",
+1 -1
View File
@@ -64,7 +64,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">3.9.1,<3.13"
version = "5.6.0"
version = "5.7.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
+43
View File
@@ -12,6 +12,7 @@ from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_prov
from tests.providers.kubernetes.kubernetes_fixtures import (
set_mocked_kubernetes_provider,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
html_stats = {
"total_pass": 25,
@@ -222,6 +223,38 @@ kubernetes_html_assessment_summary = """
</div>
</div>"""
m365_html_assessment_summary = """
<div class="col-md-2">
<div class="card">
<div class="card-header">
M365 Assessment Summary
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>M365 Tenant Domain:</b> user.onmicrosoft.com
</li>
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
M365 Credentials
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>M365 Identity Type:</b> Application
</li>
<li class="list-group-item">
<b>M365 Identity ID:</b> 00000000-0000-0000-0000-000000000000
</li>
<li class="list-group-item">
<b>M365 User:</b> user@email.com
</li>
</ul>
</div>
</div>"""
def get_aws_html_header(args: list) -> str:
"""
@@ -554,3 +587,13 @@ class TestHTML:
summary = output.get_assessment_summary(provider)
assert summary == kubernetes_html_assessment_summary
def test_m365_get_assessment_summary(self):
findings = [generate_finding_output()]
output = HTML(findings)
provider = set_mocked_m365_provider()
summary = output.get_assessment_summary(provider)
expected_summary = m365_html_assessment_summary
assert summary == expected_summary
@@ -1,6 +1,8 @@
from argparse import Namespace
from unittest.mock import patch
from kubernetes.config.config_exception import ConfigException
from kubernetes import client
from prowler.config.config import (
default_config_file_path,
@@ -305,3 +307,49 @@ class TestKubernetesProvider:
assert connection.is_connected
assert connection.error is None
def test_kubernetes_provider_incluster_with_env_var(self, monkeypatch):
monkeypatch.setenv("CLUSTER_NAME", "env-cluster-name")
with (
patch(
"kubernetes.config.load_kube_config",
side_effect=ConfigException("No kubeconfig"),
),
patch("kubernetes.config.load_incluster_config", return_value=None),
patch("prowler.providers.kubernetes.kubernetes_provider.client.ApiClient"),
patch(
"prowler.providers.kubernetes.kubernetes_provider.KubernetesProvider.get_all_namespaces",
return_value=["default"],
),
):
session = KubernetesProvider.setup_session(
kubeconfig_file=None,
kubeconfig_content=None,
context=None,
cluster_name=None,
)
assert isinstance(session, KubernetesSession)
assert session.context["context"]["cluster"] == "env-cluster-name"
def test_kubernetes_provider_incluster_with_cli_flag(self):
with (
patch(
"kubernetes.config.load_kube_config",
side_effect=ConfigException("No kubeconfig"),
),
patch("kubernetes.config.load_incluster_config", return_value=None),
patch("prowler.providers.kubernetes.kubernetes_provider.client.ApiClient"),
patch(
"prowler.providers.kubernetes.kubernetes_provider.KubernetesProvider.get_all_namespaces",
return_value=["default"],
),
):
session = KubernetesProvider.setup_session(
kubeconfig_file=None,
kubeconfig_content=None,
context=None,
cluster_name="cli-cluster-name",
)
assert isinstance(session, KubernetesSession)
assert session.context["context"]["cluster"] == "cli-cluster-name"
+1
View File
@@ -29,6 +29,7 @@ def set_mocked_m365_provider(
identity_type=IDENTITY_TYPE,
tenant_id=TENANT_ID,
tenant_domain=DOMAIN,
user="user@email.com",
),
audit_config: dict = None,
azure_region_config: M365RegionConfig = M365RegionConfig(),
+6 -7
View File
@@ -522,7 +522,7 @@ class TestM365Provider:
user="test@example.com",
encrypted_password="test_password",
)
assert "The provided M365 Tenant ID is not valid." in str(exception.value)
assert "The provided Tenant ID is not valid." in str(exception.value)
def test_validate_static_credentials_missing_client_id(self):
with pytest.raises(M365NotValidClientIdError) as exception:
@@ -533,7 +533,7 @@ class TestM365Provider:
user="test@example.com",
encrypted_password="test_password",
)
assert "The provided M365 Client ID is not valid." in str(exception.value)
assert "The provided Client ID is not valid." in str(exception.value)
def test_validate_static_credentials_missing_client_secret(self):
with pytest.raises(M365NotValidClientSecretError) as exception:
@@ -544,7 +544,7 @@ class TestM365Provider:
user="test@example.com",
encrypted_password="test_password",
)
assert "The provided M365 Client Secret is not valid." in str(exception.value)
assert "The provided Client Secret is not valid." in str(exception.value)
def test_validate_static_credentials_missing_user(self):
with pytest.raises(M365NotValidUserError) as exception:
@@ -555,7 +555,7 @@ class TestM365Provider:
user="",
encrypted_password="test_password",
)
assert "The provided M365 User is not valid." in str(exception.value)
assert "The provided User is not valid." in str(exception.value)
def test_validate_static_credentials_missing_encrypted_password(self):
with pytest.raises(M365NotValidEncryptedPasswordError) as exception:
@@ -566,9 +566,7 @@ class TestM365Provider:
user="test@example.com",
encrypted_password="",
)
assert "The provided M365 Encrypted Password is not valid." in str(
exception.value
)
assert "The provided Encrypted Password is not valid." in str(exception.value)
def test_validate_arguments_missing_env_credentials(self):
with pytest.raises(M365MissingEnvironmentCredentialsError) as exception:
@@ -583,6 +581,7 @@ class TestM365Provider:
user=None,
encrypted_password=None,
)
assert (
"M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_ENCRYPTED_PASSWORD environment variables to be set when using --env-auth"
in str(exception.value)
@@ -4,7 +4,69 @@ from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_defender_antiphishing_policy_configured:
def test_properly_configured_custom_policy(self):
def test_case_1_default_policy_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured import (
defender_antiphishing_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
AntiphishingPolicy,
)
defender_client.antiphishing_policies = {
"Default": AntiphishingPolicy(
name="Default",
spoof_intelligence=True,
spoof_intelligence_action="Quarantine",
dmarc_reject_action="Quarantine",
dmarc_quarantine_action="Quarantine",
safety_tips=True,
unauthenticated_sender_action=True,
show_tag=True,
honor_dmarc_policy=True,
default=True,
)
}
defender_client.antiphishing_rules = {}
check = defender_antiphishing_policy_configured()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the only policy and it's properly configured in the default Defender Anti-Phishing Policy."
)
assert (
result[0].resource_name
== defender_client.antiphishing_policies["Default"].name
)
assert (
result[0].resource_id
== defender_client.antiphishing_policies["Default"].name
)
assert (
result[0].resource
== defender_client.antiphishing_policies["Default"].dict()
)
def test_case_2_all_policies_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -31,7 +93,20 @@ class Test_defender_antiphishing_policy_configured:
)
defender_client.antiphishing_policies = {
"Default": AntiphishingPolicy(
name="Default",
spoof_intelligence=True,
spoof_intelligence_action="Quarantine",
dmarc_reject_action="Quarantine",
dmarc_quarantine_action="Quarantine",
safety_tips=True,
unauthenticated_sender_action=True,
show_tag=True,
honor_dmarc_policy=True,
default=True,
),
"Policy1": AntiphishingPolicy(
name="Policy1",
spoof_intelligence=True,
spoof_intelligence_action="Quarantine",
dmarc_reject_action="Quarantine",
@@ -41,29 +116,59 @@ class Test_defender_antiphishing_policy_configured:
show_tag=True,
honor_dmarc_policy=True,
default=False,
)
),
}
defender_client.antiphising_rules = {
"Policy1": AntiphishingRule(state="Enabled")
defender_client.antiphishing_rules = {
"Policy1": AntiphishingRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
)
}
check = defender_antiphishing_policy_configured()
result = check.execute()
assert len(result) == 1
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Anti-phishing policy Policy1 is properly configured and enabled."
== "Default is properly configured in the default Defender Anti-Phishing Policy, but could be overridden by another bad-configured Custom Policy."
)
assert (
result[0].resource_name
== defender_client.antiphishing_policies["Default"].name
)
assert (
result[0].resource_id
== defender_client.antiphishing_policies["Default"].name
)
assert (
result[0].resource
== defender_client.antiphishing_policies["Default"].dict()
)
assert result[1].status == "PASS"
assert (
result[1].status_extended
== f"Custom Anti-phishing policy {defender_client.antiphishing_policies['Policy1'].name} is properly configured and includes users: {', '.join(defender_client.antiphishing_rules['Policy1'].users)}; groups: {', '.join(defender_client.antiphishing_rules['Policy1'].groups)}; domains: {', '.join(defender_client.antiphishing_rules['Policy1'].domains)}, "
f"with priority {defender_client.antiphishing_rules['Policy1'].priority} (0 is the highest). "
"Also, the default policy is properly configured, so entities not included by this custom policy could still be correctly protected."
)
assert (
result[1].resource_name
== defender_client.antiphishing_policies["Policy1"].name
)
assert (
result[1].resource_id
== defender_client.antiphishing_policies["Policy1"].name
)
assert (
result[1].resource
== defender_client.antiphishing_policies["Policy1"].dict()
)
assert result[0].resource_name == "Defender Anti-Phishing Policy"
assert result[0].resource_id == "Policy1"
assert result[0].location == "global"
def test_not_properly_configured_policy(self):
def test_case_3_default_ok_others_not(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -89,66 +194,9 @@ class Test_defender_antiphishing_policy_configured:
AntiphishingRule,
)
defender_client.antiphishing_policies = {
"Policy2": AntiphishingPolicy(
spoof_intelligence=False,
spoof_intelligence_action="None",
dmarc_reject_action="None",
dmarc_quarantine_action="None",
safety_tips=False,
unauthenticated_sender_action=False,
show_tag=False,
honor_dmarc_policy=False,
default=False,
)
}
defender_client.antiphising_rules = {
"Policy2": AntiphishingRule(state="Enabled")
}
check = defender_antiphishing_policy_configured()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Anti-phishing policy Policy2 is not properly configured."
)
assert (
result[0].resource
== defender_client.antiphishing_policies["Policy2"].dict()
)
assert result[0].resource_name == "Defender Anti-Phishing Policy"
assert result[0].resource_id == "Policy2"
assert result[0].location == "global"
def test_properly_configured_default_policy(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured import (
defender_antiphishing_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
AntiphishingPolicy,
)
defender_client.antiphishing_policies = {
"Default": AntiphishingPolicy(
name="Default",
spoof_intelligence=True,
spoof_intelligence_action="Quarantine",
dmarc_reject_action="Quarantine",
@@ -158,27 +206,149 @@ class Test_defender_antiphishing_policy_configured:
show_tag=True,
honor_dmarc_policy=True,
default=True,
),
"Policy1": AntiphishingPolicy(
name="Policy1",
spoof_intelligence=False,
spoof_intelligence_action="None",
dmarc_reject_action="None",
dmarc_quarantine_action="None",
safety_tips=False,
unauthenticated_sender_action=False,
show_tag=False,
honor_dmarc_policy=False,
default=False,
),
}
defender_client.antiphishing_rules = {
"Policy1": AntiphishingRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
)
}
defender_client.antiphising_rules = {}
check = defender_antiphishing_policy_configured()
result = check.execute()
assert len(result) == 1
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Anti-phishing policy Default is properly configured and enabled."
== "Default is properly configured in the default Defender Anti-Phishing Policy, but could be overridden by another bad-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.antiphishing_policies["Default"].dict()
)
assert result[0].resource_name == "Defender Anti-Phishing Policy"
assert result[0].resource_id == "Default"
assert result[0].location == "global"
def test_default_policy_not_properly_configured(self):
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Anti-phishing policy Policy1 is not properly configured and includes users: test@example.com; groups: example_group; domains: example.com, "
"with priority 1 (0 is the highest). However, the default policy is properly configured, so entities not included by this custom policy could be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.antiphishing_policies["Policy1"].dict()
)
def test_case_4_default_not_ok_potential_false_positive(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured import (
defender_antiphishing_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
AntiphishingPolicy,
AntiphishingRule,
)
defender_client.antiphishing_policies = {
"Default": AntiphishingPolicy(
name="Default",
spoof_intelligence=False,
spoof_intelligence_action="None",
dmarc_reject_action="None",
dmarc_quarantine_action="None",
safety_tips=False,
unauthenticated_sender_action=False,
show_tag=False,
honor_dmarc_policy=False,
default=True,
),
"Policy1": AntiphishingPolicy(
name="Policy1",
spoof_intelligence=True,
spoof_intelligence_action="Quarantine",
dmarc_reject_action="Quarantine",
dmarc_quarantine_action="Quarantine",
safety_tips=True,
unauthenticated_sender_action=True,
show_tag=True,
honor_dmarc_policy=True,
default=False,
),
}
defender_client.antiphishing_rules = {
"Policy1": AntiphishingRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
)
}
check = defender_antiphishing_policy_configured()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is not properly configured in the default Defender Anti-Phishing Policy, but could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.antiphishing_policies["Default"].dict()
)
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Anti-phishing policy Policy1 is properly configured and includes users: test@example.com; groups: example_group; domains: example.com, "
f"with priority {defender_client.antiphishing_rules['Policy1'].priority} (0 is the highest). "
"However, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.antiphishing_policies["Policy1"].dict()
)
def test_case_5_default_policy_not_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -205,6 +375,7 @@ class Test_defender_antiphishing_policy_configured:
defender_client.antiphishing_policies = {
"Default": AntiphishingPolicy(
name="Default",
spoof_intelligence=False,
spoof_intelligence_action="None",
dmarc_reject_action="None",
@@ -216,7 +387,7 @@ class Test_defender_antiphishing_policy_configured:
default=True,
)
}
defender_client.antiphising_rules = {}
defender_client.antiphishing_rules = {}
check = defender_antiphishing_policy_configured()
result = check.execute()
@@ -224,15 +395,103 @@ class Test_defender_antiphishing_policy_configured:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Anti-phishing policy Default is not properly configured."
== "Default is the only policy and it's not properly configured in the default Defender Anti-Phishing Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.antiphishing_policies["Default"].dict()
)
assert result[0].resource_name == "Defender Anti-Phishing Policy"
def test_case_6_both_policies_not_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured import (
defender_antiphishing_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
AntiphishingPolicy,
AntiphishingRule,
)
defender_client.antiphishing_policies = {
"Default": AntiphishingPolicy(
name="Default",
spoof_intelligence=False,
spoof_intelligence_action="None",
dmarc_reject_action="None",
dmarc_quarantine_action="None",
safety_tips=False,
unauthenticated_sender_action=False,
show_tag=False,
honor_dmarc_policy=False,
default=True,
),
"Policy1": AntiphishingPolicy(
name="Policy1",
spoof_intelligence=False,
spoof_intelligence_action="None",
dmarc_reject_action="None",
dmarc_quarantine_action="None",
safety_tips=False,
unauthenticated_sender_action=False,
show_tag=False,
honor_dmarc_policy=False,
default=False,
),
}
defender_client.antiphishing_rules = {
"Policy1": AntiphishingRule(
state="Enabled",
priority=1,
users=[],
groups=[],
domains=["example.com"],
)
}
check = defender_antiphishing_policy_configured()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is not properly configured in the default Defender Anti-Phishing Policy, but could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].location == "global"
assert (
result[0].resource
== defender_client.antiphishing_policies["Default"].dict()
)
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Anti-phishing policy Policy1 is not properly configured and includes domains: example.com, "
"with priority 1 (0 is the highest). Also, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.antiphishing_policies["Policy1"].dict()
)
def test_no_antiphishing_policies(self):
defender_client = mock.MagicMock()
@@ -257,64 +516,8 @@ class Test_defender_antiphishing_policy_configured:
)
defender_client.antiphishing_policies = {}
defender_client.antiphising_rules = {}
defender_client.antiphishing_rules = {}
check = defender_antiphishing_policy_configured()
result = check.execute()
assert result == []
def test_custom_policy_without_rule(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured import (
defender_antiphishing_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
AntiphishingPolicy,
)
defender_client.antiphishing_policies = {
"PolicyX": AntiphishingPolicy(
spoof_intelligence=True,
spoof_intelligence_action="Quarantine",
dmarc_reject_action="Quarantine",
dmarc_quarantine_action="Quarantine",
safety_tips=True,
unauthenticated_sender_action=True,
show_tag=True,
honor_dmarc_policy=True,
default=False,
)
}
defender_client.antiphising_rules = {}
check = defender_antiphishing_policy_configured()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Anti-phishing policy PolicyX is not properly configured."
)
assert (
result[0].resource
== defender_client.antiphishing_policies["PolicyX"].dict()
)
assert result[0].resource_name == "Defender Anti-Phishing Policy"
assert result[0].resource_id == "PolicyX"
assert result[0].location == "global"
assert len(result) == 0
@@ -4,119 +4,7 @@ from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_defender_antispam_outbound_policy_configured:
def test_properly_configured_custom_policy(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured import (
defender_antispam_outbound_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Policy1": OutboundSpamPolicy(
notify_sender_blocked=True,
notify_limit_exceeded=True,
notify_limit_exceeded_addresses=["test@correo.com"],
notify_sender_blocked_addresses=["test@correo.com"],
default=False,
auto_forwarding_mode=False,
)
}
defender_client.outbound_spam_rules = {
"Policy1": OutboundSpamRule(state="Enabled")
}
check = defender_antispam_outbound_policy_configured()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Outbound Spam Policy Policy1 is properly configured and enabled."
)
assert (
result[0].resource
== defender_client.outbound_spam_policies["Policy1"].dict()
)
assert result[0].resource_name == "Defender Outbound Spam Policy"
assert result[0].resource_id == "Policy1"
assert result[0].location == "global"
def test_not_properly_configured_policy(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured import (
defender_antispam_outbound_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Policy2": OutboundSpamPolicy(
notify_sender_blocked=False,
notify_limit_exceeded=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
default=False,
auto_forwarding_mode=False,
)
}
defender_client.outbound_spam_rules = {
"Policy2": OutboundSpamRule(state="Enabled")
}
check = defender_antispam_outbound_policy_configured()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Outbound Spam Policy Policy2 is not properly configured."
)
assert (
result[0].resource
== defender_client.outbound_spam_policies["Policy2"].dict()
)
assert result[0].resource_name == "Defender Outbound Spam Policy"
assert result[0].resource_id == "Policy2"
assert result[0].location == "global"
def test_properly_configured_default_policy(self):
def test_case_1_default_policy_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -143,12 +31,13 @@ class Test_defender_antispam_outbound_policy_configured:
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
notify_sender_blocked=True,
name="Default",
notify_limit_exceeded=True,
notify_limit_exceeded_addresses=["test@correo.com"],
notify_sender_blocked_addresses=["test@correo.com"],
default=True,
notify_sender_blocked=True,
notify_limit_exceeded_addresses=["admin@example.com"],
notify_sender_blocked_addresses=["admin@example.com"],
auto_forwarding_mode=False,
default=True,
)
}
defender_client.outbound_spam_rules = {}
@@ -159,17 +48,271 @@ class Test_defender_antispam_outbound_policy_configured:
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Outbound Spam Policy Default is properly configured and enabled."
== "Default is the only policy and it's properly configured in the default Defender Outbound Spam Policy."
)
assert (
result[0].resource_name
== defender_client.outbound_spam_policies["Default"].name
)
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
assert result[0].resource_name == "Defender Outbound Spam Policy"
assert result[0].resource_id == "Default"
assert result[0].location == "global"
def test_policy_without_rule(self):
def test_case_2_all_policies_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured import (
defender_antispam_outbound_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
name="Default",
notify_limit_exceeded=True,
notify_sender_blocked=True,
notify_limit_exceeded_addresses=["admin@example.com"],
notify_sender_blocked_addresses=["admin@example.com"],
auto_forwarding_mode=False,
default=True,
),
"Policy1": OutboundSpamPolicy(
name="Policy1",
notify_limit_exceeded=True,
notify_sender_blocked=True,
notify_limit_exceeded_addresses=["admin@example.com"],
notify_sender_blocked_addresses=["admin@example.com"],
auto_forwarding_mode=False,
default=False,
),
}
defender_client.outbound_spam_rules = {
"Policy1": OutboundSpamRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["group1"],
domains=["example.com"],
)
}
check = defender_antispam_outbound_policy_configured()
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is properly configured in the default Defender Outbound Spam Policy, but could be overridden by another bad-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Outbound Spam policy Policy1 is properly configured and includes users: test@example.com; groups: group1; domains: example.com, "
"with priority 1 (0 is the highest). Also, the default policy is properly configured, so entities not included by this custom policy could still be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.outbound_spam_policies["Policy1"].dict()
)
def test_case_3_default_ok_others_not(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured import (
defender_antispam_outbound_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
name="Default",
notify_limit_exceeded=True,
notify_sender_blocked=True,
notify_limit_exceeded_addresses=["admin@example.com"],
notify_sender_blocked_addresses=["admin@example.com"],
auto_forwarding_mode=False,
default=True,
),
"Policy1": OutboundSpamPolicy(
name="Policy1",
notify_limit_exceeded=False,
notify_sender_blocked=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
auto_forwarding_mode=False,
default=False,
),
}
defender_client.outbound_spam_rules = {
"Policy1": OutboundSpamRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["group1"],
domains=["example.com"],
)
}
check = defender_antispam_outbound_policy_configured()
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is properly configured in the default Defender Outbound Spam Policy, but could be overridden by another bad-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Outbound Spam policy Policy1 is not properly configured and includes users: test@example.com; groups: group1; domains: example.com, "
"with priority 1 (0 is the highest). However, the default policy is properly configured, so entities not included by this custom policy could be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.outbound_spam_policies["Policy1"].dict()
)
def test_case_4_default_not_ok_custom_good(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured import (
defender_antispam_outbound_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
name="Default",
notify_limit_exceeded=False,
notify_sender_blocked=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
auto_forwarding_mode=False,
default=True,
),
"Policy1": OutboundSpamPolicy(
name="Policy1",
notify_limit_exceeded=True,
notify_sender_blocked=True,
notify_limit_exceeded_addresses=["admin@example.com"],
notify_sender_blocked_addresses=["admin@example.com"],
auto_forwarding_mode=False,
default=False,
),
}
defender_client.outbound_spam_rules = {
"Policy1": OutboundSpamRule(
state="Enabled",
priority=0,
users=["user1@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_antispam_outbound_policy_configured()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is not properly configured in the default Defender Outbound Spam Policy, but could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Outbound Spam policy Policy1 is properly configured and includes users: user1@example.com; groups: group1; domains: domain.com, "
"with priority 0 (0 is the highest). However, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.outbound_spam_policies["Policy1"].dict()
)
def test_case_5_only_default_not_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -195,13 +338,14 @@ class Test_defender_antispam_outbound_policy_configured:
)
defender_client.outbound_spam_policies = {
"PolicyX": OutboundSpamPolicy(
notify_sender_blocked=True,
notify_limit_exceeded=True,
notify_limit_exceeded_addresses=["admin@org.com"],
notify_sender_blocked_addresses=["admin@org.com"],
default=False,
"Default": OutboundSpamPolicy(
name="Default",
notify_limit_exceeded=False,
notify_sender_blocked=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
auto_forwarding_mode=False,
default=True,
)
}
defender_client.outbound_spam_rules = {}
@@ -212,15 +356,98 @@ class Test_defender_antispam_outbound_policy_configured:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Outbound Spam Policy PolicyX is not properly configured."
== "Default is the only policy and it's not properly configured in the default Defender Outbound Spam Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["PolicyX"].dict()
== defender_client.outbound_spam_policies["Default"].dict()
)
def test_case_6_default_and_custom_not_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_configured.defender_antispam_outbound_policy_configured import (
defender_antispam_outbound_policy_configured,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
name="Default",
notify_limit_exceeded=False,
notify_sender_blocked=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
auto_forwarding_mode=False,
default=True,
),
"Policy1": OutboundSpamPolicy(
name="Policy1",
notify_limit_exceeded=False,
notify_sender_blocked=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
auto_forwarding_mode=False,
default=False,
),
}
defender_client.outbound_spam_rules = {
"Policy1": OutboundSpamRule(
state="Enabled",
priority=5,
users=["user@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_antispam_outbound_policy_configured()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is not properly configured in the default Defender Outbound Spam Policy, but could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Outbound Spam policy Policy1 is not properly configured and includes users: user@example.com; groups: group1; domains: domain.com, "
"with priority 5 (0 is the highest). Also, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.outbound_spam_policies["Policy1"].dict()
)
assert result[0].resource_name == "Defender Outbound Spam Policy"
assert result[0].resource_id == "PolicyX"
assert result[0].location == "global"
def test_no_outbound_spam_policies(self):
defender_client = mock.MagicMock()
@@ -4,6 +4,456 @@ from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_defender_antispam_outbound_policy_forwarding_disabled:
def test_case_1_default_policy_forwarding_disabled(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled import (
defender_antispam_outbound_policy_forwarding_disabled,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
name="Default",
auto_forwarding_mode=False,
notify_limit_exceeded=True,
notify_sender_blocked=True,
notify_limit_exceeded_addresses=["admin@example.com"],
notify_sender_blocked_addresses=["admin@example.com"],
default=True,
)
}
defender_client.outbound_spam_rules = {}
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the only policy and mail forwarding is disabled."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
def test_case_2_all_policies_forwarding_disabled(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled import (
defender_antispam_outbound_policy_forwarding_disabled,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
name="Default",
auto_forwarding_mode=False,
notify_limit_exceeded=True,
notify_sender_blocked=True,
notify_limit_exceeded_addresses=["admin@example.com"],
notify_sender_blocked_addresses=["admin@example.com"],
default=True,
),
"Policy1": OutboundSpamPolicy(
name="Policy1",
auto_forwarding_mode=False,
notify_limit_exceeded=True,
notify_sender_blocked=True,
notify_limit_exceeded_addresses=["admin@example.com"],
notify_sender_blocked_addresses=["admin@example.com"],
default=False,
),
}
defender_client.outbound_spam_rules = {
"Policy1": OutboundSpamRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["group1"],
domains=["example.com"],
)
}
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the default policy and mail forwarding is disabled, but it could be overridden by another misconfigured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Outbound Spam policy Policy1 disables mail forwarding and includes users: test@example.com; groups: group1; domains: example.com, "
"with priority 1 (0 is the highest). Also, the default policy disables mail forwarding, so entities not included by this custom policy could still be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.outbound_spam_policies["Policy1"].dict()
)
def test_case_3_default_ok_custom_not(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled import (
defender_antispam_outbound_policy_forwarding_disabled,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
name="Default",
auto_forwarding_mode=False,
notify_limit_exceeded=True,
notify_sender_blocked=True,
notify_limit_exceeded_addresses=["admin@example.com"],
notify_sender_blocked_addresses=["admin@example.com"],
default=True,
),
"Policy1": OutboundSpamPolicy(
name="Policy1",
auto_forwarding_mode=True,
notify_limit_exceeded=False,
notify_sender_blocked=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
default=False,
),
}
defender_client.outbound_spam_rules = {
"Policy1": OutboundSpamRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["group1"],
domains=["example.com"],
)
}
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the default policy and mail forwarding is disabled, but it could be overridden by another misconfigured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Outbound Spam policy Policy1 allows mail forwarding and includes users: test@example.com; groups: group1; domains: example.com, "
"with priority 1 (0 is the highest). However, the default policy disables mail forwarding, so entities not included by this custom policy could be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.outbound_spam_policies["Policy1"].dict()
)
def test_case_4_default_not_ok_custom_good(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled import (
defender_antispam_outbound_policy_forwarding_disabled,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
name="Default",
auto_forwarding_mode=True,
notify_limit_exceeded=False,
notify_sender_blocked=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
default=True,
),
"Policy1": OutboundSpamPolicy(
name="Policy1",
auto_forwarding_mode=False,
notify_limit_exceeded=True,
notify_sender_blocked=True,
notify_limit_exceeded_addresses=["admin@example.com"],
notify_sender_blocked_addresses=["admin@example.com"],
default=False,
),
}
defender_client.outbound_spam_rules = {
"Policy1": OutboundSpamRule(
state="Enabled",
priority=0,
users=["user1@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the default policy and mail forwarding is allowed, but it could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Outbound Spam policy Policy1 disables mail forwarding and includes users: user1@example.com; groups: group1; domains: domain.com, "
"with priority 0 (0 is the highest). However, the default policy allows mail forwarding, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.outbound_spam_policies["Policy1"].dict()
)
def test_case_5_only_default_not_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled import (
defender_antispam_outbound_policy_forwarding_disabled,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
name="Default",
auto_forwarding_mode=True,
notify_limit_exceeded=False,
notify_sender_blocked=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
default=True,
)
}
defender_client.outbound_spam_rules = {}
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the only policy and mail forwarding is allowed."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
def test_case_6_default_and_custom_not_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled import (
defender_antispam_outbound_policy_forwarding_disabled,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
name="Default",
auto_forwarding_mode=True,
notify_limit_exceeded=False,
notify_sender_blocked=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
default=True,
),
"Policy1": OutboundSpamPolicy(
name="Policy1",
auto_forwarding_mode=True,
notify_limit_exceeded=False,
notify_sender_blocked=False,
notify_limit_exceeded_addresses=[],
notify_sender_blocked_addresses=[],
default=False,
),
}
defender_client.outbound_spam_rules = {
"Policy1": OutboundSpamRule(
state="Enabled",
priority=1,
users=["user@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the default policy and mail forwarding is allowed, but it could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Outbound Spam policy Policy1 allows mail forwarding and includes users: user@example.com; groups: group1; domains: domain.com, "
"with priority 1 (0 is the highest). Also, the default policy allows mail forwarding, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert (
result[1].resource
== defender_client.outbound_spam_policies["Policy1"].dict()
)
def test_no_outbound_spam_policies(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
@@ -32,221 +482,3 @@ class Test_defender_antispam_outbound_policy_forwarding_disabled:
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 0
def test_forwarding_disabled_custom_policy(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled import (
defender_antispam_outbound_policy_forwarding_disabled,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Policy1": OutboundSpamPolicy(
default=False,
notify_sender_blocked=True,
notify_limit_exceeded=True,
notify_limit_exceeded_addresses=["test@correo.com"],
notify_sender_blocked_addresses=["test@correo.com"],
auto_forwarding_mode=False,
)
}
defender_client.outbound_spam_rules = {
"Policy1": OutboundSpamRule(state="Enabled")
}
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Outbound Spam Policy Policy1 does not allow mail forwarding."
)
assert (
result[0].resource
== defender_client.outbound_spam_policies["Policy1"].dict()
)
assert result[0].resource_name == "Defender Outbound Spam Policy"
assert result[0].resource_id == "Policy1"
assert result[0].location == "global"
def test_forwarding_enabled_policy(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled import (
defender_antispam_outbound_policy_forwarding_disabled,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
OutboundSpamRule,
)
defender_client.outbound_spam_policies = {
"Policy2": OutboundSpamPolicy(
default=False,
notify_sender_blocked=True,
notify_limit_exceeded=True,
notify_limit_exceeded_addresses=["test@correo.com"],
notify_sender_blocked_addresses=["test@correo.com"],
auto_forwarding_mode=True,
)
}
defender_client.outbound_spam_rules = {
"Policy2": OutboundSpamRule(state="Enabled")
}
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Outbound Spam Policy Policy2 does allow mail forwarding."
)
assert (
result[0].resource
== defender_client.outbound_spam_policies["Policy2"].dict()
)
assert result[0].resource_name == "Defender Outbound Spam Policy"
assert result[0].resource_id == "Policy2"
assert result[0].location == "global"
def test_forwarding_disabled_default_policy(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled import (
defender_antispam_outbound_policy_forwarding_disabled,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
)
defender_client.outbound_spam_policies = {
"Default": OutboundSpamPolicy(
default=True,
notify_sender_blocked=True,
notify_limit_exceeded=True,
notify_limit_exceeded_addresses=["test@correo.com"],
notify_sender_blocked_addresses=["test@correo.com"],
auto_forwarding_mode=False,
)
}
defender_client.outbound_spam_rules = {}
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Outbound Spam Policy Default does not allow mail forwarding."
)
assert (
result[0].resource
== defender_client.outbound_spam_policies["Default"].dict()
)
assert result[0].resource_name == "Defender Outbound Spam Policy"
assert result[0].resource_id == "Default"
assert result[0].location == "global"
def test_policy_without_rule(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_outbound_policy_forwarding_disabled.defender_antispam_outbound_policy_forwarding_disabled import (
defender_antispam_outbound_policy_forwarding_disabled,
)
from prowler.providers.m365.services.defender.defender_service import (
OutboundSpamPolicy,
)
defender_client.outbound_spam_policies = {
"PolicyX": OutboundSpamPolicy(
default=False,
notify_sender_blocked=True,
notify_limit_exceeded=True,
notify_limit_exceeded_addresses=["test@correo.com"],
notify_sender_blocked_addresses=["test@correo.com"],
auto_forwarding_mode=False,
)
}
defender_client.outbound_spam_rules = {}
check = defender_antispam_outbound_policy_forwarding_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Outbound Spam Policy PolicyX does allow mail forwarding."
)
assert (
result[0].resource
== defender_client.outbound_spam_policies["PolicyX"].dict()
)
assert result[0].resource_name == "Defender Outbound Spam Policy"
assert result[0].resource_id == "PolicyX"
assert result[0].location == "global"
@@ -4,7 +4,7 @@ from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_defender_antispam_policy_inbound_no_allowed_domains:
def test_policy_without_allowed_domains(self):
def test_case_1_default_policy_no_allowed_domains(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -31,26 +31,240 @@ class Test_defender_antispam_policy_inbound_no_allowed_domains:
defender_client.inbound_spam_policies = [
DefenderInboundSpamPolicy(
identity="Policy1",
identity="Default",
default=True,
allowed_sender_domains=[],
)
]
defender_client.inbound_spam_rules = {}
check = defender_antispam_policy_inbound_no_allowed_domains()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Inbound anti-spam policy Policy1 does not contain allowed domains."
== "Default is the only policy and it does not contain allowed domains."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.inbound_spam_policies[0].dict()
assert result[0].resource_name == "Defender Inbound Spam Policy"
assert result[0].resource_id == "Policy1"
assert result[0].location == "global"
def test_policy_with_allowed_domains(self):
def test_case_2_all_policies_no_allowed_domains(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains import (
defender_antispam_policy_inbound_no_allowed_domains,
)
from prowler.providers.m365.services.defender.defender_service import (
DefenderInboundSpamPolicy,
InboundSpamRule,
)
defender_client.inbound_spam_policies = [
DefenderInboundSpamPolicy(
identity="Default",
default=True,
allowed_sender_domains=[],
),
DefenderInboundSpamPolicy(
identity="Policy1",
default=False,
allowed_sender_domains=[],
),
]
defender_client.inbound_spam_rules = {
"Policy1": InboundSpamRule(
state="Enabled",
priority=1,
users=["user1@example.com"],
groups=["group1"],
domains=["example.com"],
)
}
check = defender_antispam_policy_inbound_no_allowed_domains()
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the default policy and it does not contain allowed domains, but it could be overridden by another misconfigured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.inbound_spam_policies[0].dict()
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Inbound Spam policy Policy1 does not contain allowed domains and includes users: user1@example.com; groups: group1; domains: example.com, "
"with priority 1 (0 is the highest). Also, the default policy does not contain allowed domains, so entities not included by this custom policy could still be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.inbound_spam_policies[1].dict()
def test_case_3_default_ok_custom_with_allowed_domains(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains import (
defender_antispam_policy_inbound_no_allowed_domains,
)
from prowler.providers.m365.services.defender.defender_service import (
DefenderInboundSpamPolicy,
InboundSpamRule,
)
defender_client.inbound_spam_policies = [
DefenderInboundSpamPolicy(
identity="Default",
default=True,
allowed_sender_domains=[],
),
DefenderInboundSpamPolicy(
identity="Policy1",
default=False,
allowed_sender_domains=["spam.com"],
),
]
defender_client.inbound_spam_rules = {
"Policy1": InboundSpamRule(
state="Enabled",
priority=2,
users=["user@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_antispam_policy_inbound_no_allowed_domains()
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the default policy and it does not contain allowed domains, but it could be overridden by another misconfigured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.inbound_spam_policies[0].dict()
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Inbound Spam policy Policy1 contains allowed domains and includes users: user@example.com; groups: group1; domains: domain.com, "
"with priority 2 (0 is the highest). However, the default policy does not contain allowed domains, so entities not included by this custom policy could be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.inbound_spam_policies[1].dict()
def test_case_4_default_with_allowed_domains_custom_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains import (
defender_antispam_policy_inbound_no_allowed_domains,
)
from prowler.providers.m365.services.defender.defender_service import (
DefenderInboundSpamPolicy,
InboundSpamRule,
)
defender_client.inbound_spam_policies = [
DefenderInboundSpamPolicy(
identity="Default",
default=True,
allowed_sender_domains=["example.org"],
),
DefenderInboundSpamPolicy(
identity="Policy1",
default=False,
allowed_sender_domains=[],
),
]
defender_client.inbound_spam_rules = {
"Policy1": InboundSpamRule(
state="Enabled",
priority=0,
users=["user@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_antispam_policy_inbound_no_allowed_domains()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the default policy and it contains allowed domains: example.org, but it could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.inbound_spam_policies[0].dict()
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Inbound Spam policy Policy1 does not contain allowed domains and includes users: user@example.com; groups: group1; domains: domain.com, "
"with priority 0 (0 is the highest). However, the default policy contains allowed domains, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.inbound_spam_policies[1].dict()
def test_case_5_default_with_allowed_domains(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -77,24 +291,96 @@ class Test_defender_antispam_policy_inbound_no_allowed_domains:
defender_client.inbound_spam_policies = [
DefenderInboundSpamPolicy(
identity="Policy2",
allowed_sender_domains=["bad-domain.com"],
identity="Default",
default=True,
allowed_sender_domains=["example.com"],
)
]
defender_client.inbound_spam_rules = {}
check = defender_antispam_policy_inbound_no_allowed_domains()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Inbound anti-spam policy Policy2 contains allowed domains: ['bad-domain.com']."
== "Default is the only policy and it contains allowed domains: example.com."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.inbound_spam_policies[0].dict()
assert result[0].resource_name == "Defender Inbound Spam Policy"
assert result[0].resource_id == "Policy2"
assert result[0].location == "global"
def test_case_6_both_default_and_custom_with_allowed_domains(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains import (
defender_antispam_policy_inbound_no_allowed_domains,
)
from prowler.providers.m365.services.defender.defender_service import (
DefenderInboundSpamPolicy,
InboundSpamRule,
)
defender_client.inbound_spam_policies = [
DefenderInboundSpamPolicy(
identity="Default",
default=True,
allowed_sender_domains=["example.com"],
),
DefenderInboundSpamPolicy(
identity="Policy1",
default=False,
allowed_sender_domains=["spam.com"],
),
]
defender_client.inbound_spam_rules = {
"Policy1": InboundSpamRule(
state="Enabled",
priority=5,
users=["user@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_antispam_policy_inbound_no_allowed_domains()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the default policy and it contains allowed domains: example.com, but it could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.inbound_spam_policies[0].dict()
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Inbound Spam policy Policy1 contains allowed domains and includes users: user@example.com; groups: group1; domains: domain.com, "
"with priority 5 (0 is the highest). Also, the default policy contains allowed domains, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.inbound_spam_policies[1].dict()
def test_no_inbound_spam_policies(self):
defender_client = mock.MagicMock()
@@ -119,8 +405,8 @@ class Test_defender_antispam_policy_inbound_no_allowed_domains:
)
defender_client.inbound_spam_policies = []
defender_client.inbound_spam_rules = {}
check = defender_antispam_policy_inbound_no_allowed_domains()
result = check.execute()
assert len(result) == 0
@@ -4,7 +4,7 @@ from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_defender_malware_policy_common_attachments_filter_enabled:
def test_enable_file_filter_disabled(self):
def test_case_1_only_default_policy_enabled(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -31,30 +31,29 @@ class Test_defender_malware_policy_common_attachments_filter_enabled:
defender_client.malware_policies = [
MalwarePolicy(
enable_file_filter=False,
identity="Policy1",
enable_internal_sender_admin_notifications=False,
internal_sender_admin_address="",
file_types=[],
identity="Default",
enable_file_filter=True,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe", "js"],
is_default=True,
),
)
]
defender_client.malware_rules = {}
check = defender_malware_policy_common_attachments_filter_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Common Attachment Types Filter is not enabled in anti-malware policy Policy1."
== "Default is the only policy and Common Attachment Types Filter is enabled."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
def test_enable_file_filter_enabled(self):
def test_case_2_all_policies_enabled(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -77,34 +76,345 @@ class Test_defender_malware_policy_common_attachments_filter_enabled:
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=True,
identity="Policy1",
enable_internal_sender_admin_notifications=False,
internal_sender_admin_address="",
file_types=[],
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe", "js"],
is_default=True,
),
MalwarePolicy(
identity="Policy1",
enable_file_filter=True,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe"],
is_default=False,
),
]
defender_client.malware_rules = {
"Policy1": MalwareRule(
state="Enabled",
priority=1,
users=["user1@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_malware_policy_common_attachments_filter_enabled()
result = check.execute()
assert len(result) == 1
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Common Attachment Types Filter is enabled in anti-malware policy Policy1."
== "Default is the default policy and Common Attachment Types Filter is enabled, but it could be overridden by another misconfigured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
def test_no_policy(self):
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Malware policy Policy1 enables Common Attachment Types Filter and includes users: user1@example.com; groups: group1; domains: domain.com, "
"with priority 1 (0 is the highest). Also, the default policy enables the filter, so entities not included by this custom policy could still be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_case_3_default_ok_custom_not_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled import (
defender_malware_policy_common_attachments_filter_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=True,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe"],
is_default=True,
),
MalwarePolicy(
identity="Policy1",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["js"],
is_default=False,
),
]
defender_client.malware_rules = {
"Policy1": MalwareRule(
state="Enabled",
priority=2,
users=["user@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_malware_policy_common_attachments_filter_enabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the default policy and Common Attachment Types Filter is enabled, but it could be overridden by another misconfigured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Malware policy Policy1 does not enable Common Attachment Types Filter and includes users: user@example.com; groups: group1; domains: domain.com, "
"with priority 2 (0 is the highest). However, the default policy enables the filter, so entities not included by this custom policy could be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_case_4_default_not_ok_custom_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled import (
defender_malware_policy_common_attachments_filter_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=True,
),
MalwarePolicy(
identity="Policy1",
enable_file_filter=True,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe"],
is_default=False,
),
]
defender_client.malware_rules = {
"Policy1": MalwareRule(
state="Enabled",
priority=0,
users=["user@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_malware_policy_common_attachments_filter_enabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the default policy and Common Attachment Types Filter is not enabled, but it could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Malware policy Policy1 enables Common Attachment Types Filter and includes users: user@example.com; groups: group1; domains: domain.com, "
"with priority 0 (0 is the highest). However, the default policy does not enable the filter, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_case_5_only_default_not_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled import (
defender_malware_policy_common_attachments_filter_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
)
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=True,
)
]
defender_client.malware_rules = {}
check = defender_malware_policy_common_attachments_filter_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the only policy and Common Attachment Types Filter is not enabled."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
def test_case_6_default_and_custom_not_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled import (
defender_malware_policy_common_attachments_filter_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=True,
),
MalwarePolicy(
identity="Policy1",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe"],
is_default=False,
),
]
defender_client.malware_rules = {
"Policy1": MalwareRule(
state="Enabled",
priority=5,
users=["user1@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_malware_policy_common_attachments_filter_enabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the default policy and Common Attachment Types Filter is not enabled, but it could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Malware policy Policy1 does not enable Common Attachment Types Filter and includes users: user1@example.com; groups: group1; domains: domain.com, "
"with priority 5 (0 is the highest). Also, the default policy does not enable the filter, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_no_malware_policies(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -127,16 +437,8 @@ class Test_defender_malware_policy_common_attachments_filter_enabled:
)
defender_client.malware_policies = []
defender_client.malware_rules = {}
check = defender_malware_policy_common_attachments_filter_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Common Attachment Types Filter is not enabled."
)
assert result[0].resource == {}
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
assert len(result) == 0
@@ -1,53 +1,10 @@
from unittest import mock
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_defender_malware_policy_comprehensive_attachments_filter_applied:
def test_no_policy(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
defender_client.malware_policies = []
defender_client.audit_config = {}
defender_client.malware_rules = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
)
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Common Attachment Types Filter is not enabled."
)
assert result[0].resource == {}
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
def test_policy_enabled_all_extensions_blocked(self):
def test_case_1_default_policy_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -68,192 +25,430 @@ class Test_defender_malware_policy_comprehensive_attachments_filter_applied:
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
)
valid_extensions = ["exe", "bat", "js"]
defender_client.audit_config = {
"recommended_blocked_file_types": valid_extensions
}
defender_client.malware_rules = {"PolicyGood": MalwareRule(state="Enabled")}
defender_client.malware_policies = [
MalwarePolicy(
enable_file_filter=True,
identity="PolicyGood",
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=valid_extensions,
is_default=True,
)
]
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Common Attachment Types Filter is enabled in anti-malware policy PolicyGood, the policy is enabled and the filter is applied to the recommended file types."
)
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
def test_policy_enabled_missing_extensions(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
)
defender_client.audit_config = {
"recommended_blocked_file_types": ["exe", "bat", "js"]
}
defender_client.malware_rules = {
"PolicyPartial": MalwareRule(state="Enabled")
}
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=True,
identity="PolicyPartial",
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe"],
file_types=["exe", "bat", "js"],
is_default=True,
)
]
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Common Attachment Types Filter is enabled in anti-malware policy PolicyPartial, but the following recommended file types are missing: bat, js."
)
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
def test_policy_enabled_but_rule_disabled(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
)
valid_extensions = ["exe"]
defender_client.audit_config = {
"recommended_blocked_file_types": valid_extensions
}
defender_client.malware_rules = {
"PolicyDisabled": MalwareRule(state="Disabled")
}
defender_client.malware_policies = [
MalwarePolicy(
enable_file_filter=True,
identity="PolicyDisabled",
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=valid_extensions,
is_default=False,
)
]
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Common Attachment Types Filter is enabled in anti-malware policy PolicyDisabled, but the policy is disabled."
)
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
def test_policy_enabled_but_no_rule_and_not_default(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
)
valid_extensions = ["exe"]
defender_client.audit_config = {
"recommended_blocked_file_types": valid_extensions
}
defender_client.malware_rules = {}
defender_client.malware_policies = [
MalwarePolicy(
enable_file_filter=True,
identity="PolicyNoRule",
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=valid_extensions,
is_default=False,
)
]
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the only policy and Common Attachment Types Filter is properly configured."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
def test_case_2_all_policies_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.audit_config = {
"recommended_blocked_file_types": ["exe", "bat"]
}
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=True,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe", "bat"],
is_default=True,
),
MalwarePolicy(
identity="Custom1",
enable_file_filter=True,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe", "bat"],
is_default=False,
),
]
defender_client.malware_rules = {
"Custom1": MalwareRule(
state="Enabled",
priority=1,
users=["user1@example.com"],
groups=["group1"],
domains=["example.com"],
)
}
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the default policy and Common Attachment Types Filter is properly configured, but it could be overridden by another misconfigured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Malware policy Custom1 is properly configured and includes users: user1@example.com; groups: group1; domains: example.com, "
"with priority 1 (0 is the highest). Also, the default policy is properly configured, so entities not included by this custom policy could still be correctly protected."
)
assert result[1].resource_name == "Custom1"
assert result[1].resource_id == "Custom1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_case_3_default_ok_custom_not(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.audit_config = {
"recommended_blocked_file_types": ["exe", "bat"]
}
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=True,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe", "bat"],
is_default=True,
),
MalwarePolicy(
identity="Custom1",
enable_file_filter=True,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe"], # missing bat
is_default=False,
),
]
defender_client.malware_rules = {
"Custom1": MalwareRule(
state="Enabled",
priority=1,
users=["user1@example.com"],
groups=["group1"],
domains=["example.com"],
)
}
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the default policy and Common Attachment Types Filter is properly configured, but it could be overridden by another misconfigured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Malware policy Custom1 is not properly configured and includes users: user1@example.com; groups: group1; domains: example.com, "
"with priority 1 (0 is the highest). Missing recommended file types: bat. However, the default policy is properly configured, so entities not included by this custom policy could be correctly protected."
)
assert result[1].resource_name == "Custom1"
assert result[1].resource_id == "Custom1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_case_4_default_not_ok_custom_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.audit_config = {
"recommended_blocked_file_types": ["exe", "bat"]
}
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=True,
),
MalwarePolicy(
identity="Custom1",
enable_file_filter=True,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe", "bat"],
is_default=False,
),
]
defender_client.malware_rules = {
"Custom1": MalwareRule(
state="Enabled",
priority=0,
users=["user1@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the default policy and Common Attachment Types Filter is not properly configured, but it could be overridden by another well-configured Custom Policy. Missing recommended file types: exe, bat."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Malware policy Custom1 is properly configured and includes users: user1@example.com; groups: group1; domains: domain.com, "
"with priority 0 (0 is the highest). However, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Custom1"
assert result[1].resource_id == "Custom1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_case_5_only_default_not_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
)
defender_client.audit_config = {
"recommended_blocked_file_types": ["exe", "bat"]
}
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=True,
)
]
defender_client.malware_rules = {}
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Common Attachment Types Filter is not properly configured in anti-malware policy PolicyNoRule."
== "Default is the only policy and Common Attachment Types Filter is not properly configured. Missing recommended file types: exe, bat."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
def test_case_6_default_and_custom_not_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.audit_config = {
"recommended_blocked_file_types": ["exe", "bat"]
}
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=True,
),
MalwarePolicy(
identity="Custom1",
enable_file_filter=True,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=["exe"], # missing bat
is_default=False,
),
]
defender_client.malware_rules = {
"Custom1": MalwareRule(
state="Enabled",
priority=2,
users=["user@example.com"],
groups=["group1"],
domains=["example.com"],
)
}
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the default policy and Common Attachment Types Filter is not properly configured, but it could be overridden by another well-configured Custom Policy. Missing recommended file types: exe, bat."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Malware policy Custom1 is not properly configured and includes users: user@example.com; groups: group1; domains: example.com, "
"with priority 2 (0 is the highest). Missing recommended file types: bat. Also, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Custom1"
assert result[1].resource_id == "Custom1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_no_malware_policies(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
defender_client.malware_policies = []
defender_client.malware_rules = {}
defender_client.audit_config = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import (
defender_malware_policy_comprehensive_attachments_filter_applied,
)
check = defender_malware_policy_comprehensive_attachments_filter_applied()
result = check.execute()
assert len(result) == 0
@@ -4,7 +4,7 @@ from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_defender_malware_policy_notifications_internal_users_malware_enabled:
def test_notifications_disabled(self):
def test_case_1_default_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -31,32 +31,322 @@ class Test_defender_malware_policy_notifications_internal_users_malware_enabled:
defender_client.malware_policies = [
MalwarePolicy(
enable_file_filter=True,
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=True,
)
]
defender_client.malware_rules = {}
check = (
defender_malware_policy_notifications_internal_users_malware_enabled()
)
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the only policy and notifications for internal users sending malware are enabled."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
def test_case_2_default_and_custom_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled import (
defender_malware_policy_notifications_internal_users_malware_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=True,
),
MalwarePolicy(
identity="Policy1",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=False,
),
]
defender_client.malware_rules = {
"Policy1": MalwareRule(
state="Enabled",
priority=1,
users=["user@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = (
defender_malware_policy_notifications_internal_users_malware_enabled()
)
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the default policy and notifications for internal users sending malware are enabled, but it could be overridden by another misconfigured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Malware policy Policy1 is properly configured and includes users: user@example.com; groups: group1; domains: domain.com, "
"with priority 1 (0 is the highest). Also, the default policy is properly configured, so entities not included by this custom policy could still be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_case_3_default_ok_custom_not_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled import (
defender_malware_policy_notifications_internal_users_malware_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=True,
),
MalwarePolicy(
identity="Policy1",
enable_file_filter=False,
enable_internal_sender_admin_notifications=False,
internal_sender_admin_address="",
file_types=[],
is_default=False,
),
]
defender_client.malware_rules = {
"Policy1": MalwareRule(
state="Enabled",
priority=2,
users=["internal@example.com"],
groups=["group2"],
domains=["example.org"],
)
}
check = (
defender_malware_policy_notifications_internal_users_malware_enabled()
)
result = check.execute()
assert len(result) == 2
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Default is the default policy and notifications for internal users sending malware are enabled, but it could be overridden by another misconfigured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Malware policy Policy1 is not properly configured and includes users: internal@example.com; groups: group2; domains: example.org, "
"with priority 2 (0 is the highest). However, the default policy is properly configured, so entities not included by this custom policy could be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_case_4_default_not_ok_custom_properly_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled import (
defender_malware_policy_notifications_internal_users_malware_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=False,
internal_sender_admin_address="",
file_types=[],
is_default=True,
),
MalwarePolicy(
identity="Policy1",
enable_file_filter=False,
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="admin@example.com",
file_types=[],
is_default=False,
),
]
defender_client.malware_rules = {
"Policy1": MalwareRule(
state="Enabled",
priority=0,
users=["user@example.com"],
groups=["group1"],
domains=["domain.com"],
)
}
check = (
defender_malware_policy_notifications_internal_users_malware_enabled()
)
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Default is the default policy and notifications for internal users sending malware are not enabled, but it could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[1].status == "PASS"
assert (
result[1].status_extended
== "Custom Malware policy Policy1 is properly configured and includes users: user@example.com; groups: group1; domains: domain.com, "
"with priority 0 (0 is the highest). However, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_case_5_only_default_not_ok(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled import (
defender_malware_policy_notifications_internal_users_malware_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
)
defender_client.malware_policies = [
MalwarePolicy(
identity="Default",
enable_file_filter=False,
enable_internal_sender_admin_notifications=False,
internal_sender_admin_address="",
file_types=[],
is_default=True,
)
]
defender_client.malware_rules = {}
check = (
defender_malware_policy_notifications_internal_users_malware_enabled()
)
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Notifications for internal users sending malware are not enabled."
== "Default is the only policy and notifications for internal users sending malware are not enabled."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
def test_notifications_enabled_without_email(self):
def test_case_6_default_and_custom_not_configured(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -79,36 +369,64 @@ class Test_defender_malware_policy_notifications_internal_users_malware_enabled:
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
MalwareRule,
)
defender_client.malware_policies = [
MalwarePolicy(
enable_file_filter=True,
identity="Default",
enable_internal_sender_admin_notifications=True,
enable_file_filter=False,
enable_internal_sender_admin_notifications=False,
internal_sender_admin_address="",
file_types=[],
is_default=True,
)
),
MalwarePolicy(
identity="Policy1",
enable_file_filter=False,
enable_internal_sender_admin_notifications=False,
internal_sender_admin_address="",
file_types=[],
is_default=False,
),
]
defender_client.malware_rules = {
"Policy1": MalwareRule(
state="Enabled",
priority=3,
users=["u@example.com"],
groups=["g1"],
domains=["d.com"],
)
}
check = (
defender_malware_policy_notifications_internal_users_malware_enabled()
)
result = check.execute()
assert len(result) == 2
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Notifications for internal users sending malware are enabled, but no email addresses are configured."
== "Default is the default policy and notifications for internal users sending malware are not enabled, but it could be overridden by another well-configured Custom Policy."
)
assert result[0].resource_name == "Default"
assert result[0].resource_id == "Default"
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
def test_notifications_enabled_with_email(self):
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Custom Malware policy Policy1 is not properly configured and includes users: u@example.com; groups: g1; domains: d.com, "
"with priority 3 (0 is the highest). Also, the default policy is not properly configured, so entities not included by this custom policy could not be correctly protected."
)
assert result[1].resource_name == "Policy1"
assert result[1].resource_id == "Policy1"
assert result[1].resource == defender_client.malware_policies[1].dict()
def test_no_malware_policies(self):
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_client.audited_domain = DOMAIN
@@ -129,33 +447,12 @@ class Test_defender_malware_policy_notifications_internal_users_malware_enabled:
from prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled import (
defender_malware_policy_notifications_internal_users_malware_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
MalwarePolicy,
)
defender_client.malware_policies = [
MalwarePolicy(
enable_file_filter=True,
identity="Default",
enable_internal_sender_admin_notifications=True,
internal_sender_admin_address="security@example.com",
file_types=[],
is_default=True,
)
]
defender_client.malware_policies = []
defender_client.malware_rules = {}
check = (
defender_malware_policy_notifications_internal_users_malware_enabled()
)
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Notifications for internal users sending malware are enabled."
)
assert result[0].resource == defender_client.malware_policies[0].dict()
assert result[0].resource_name == "Defender Malware Policy"
assert result[0].resource_id == "defenderMalwarePolicy"
assert result[0].location == "global"
assert len(result) == 0
@@ -10,6 +10,7 @@ from prowler.providers.m365.services.defender.defender_service import (
DefenderInboundSpamPolicy,
DkimConfig,
DomainServiceConfiguration,
InboundSpamRule,
MalwarePolicy,
MalwareRule,
OutboundSpamPolicy,
@@ -42,14 +43,27 @@ def mock_defender_get_malware_filter_policy(_):
def mock_defender_get_malware_filter_rule(_):
return {
"Policy1": MalwareRule(state="Enabled"),
"Policy2": MalwareRule(state="Disabled"),
"Policy1": MalwareRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
),
"Policy2": MalwareRule(
state="Disabled",
priority=2,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
),
}
def mock_defender_get_antiphising_policy(_):
def mock_defender_get_antiphishing_policy(_):
return {
"Policy1": AntiphishingPolicy(
name="Policy1",
spoof_intelligence=True,
spoof_intelligence_action="Quarantine",
dmarc_reject_action="Reject",
@@ -61,6 +75,7 @@ def mock_defender_get_antiphising_policy(_):
default=False,
),
"Policy2": AntiphishingPolicy(
name="Policy2",
spoof_intelligence=False,
spoof_intelligence_action="None",
dmarc_reject_action="None",
@@ -74,13 +89,21 @@ def mock_defender_get_antiphising_policy(_):
}
def mock_defender_get_antiphising_rules(_):
def mock_defender_get_antiphishing_rules(_):
return {
"Policy1": AntiphishingRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
),
"Policy2": AntiphishingRule(
state="Disabled",
priority=2,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
),
}
@@ -90,14 +113,35 @@ def mock_defender_get_inbound_spam_policy(_):
DefenderInboundSpamPolicy(
identity="Policy1",
allowed_sender_domains=[],
default=False,
),
DefenderInboundSpamPolicy(
identity="Policy2",
allowed_sender_domains=["example.com"],
default=True,
),
]
def mock_defender_get_inbound_spam_rule(_):
return {
"Policy1": InboundSpamRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
),
"Policy2": InboundSpamRule(
state="Disabled",
priority=2,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
),
}
def mock_defender_get_connection_filter_policy(_):
return ConnectionFilterPolicy(
ip_allow_list=[],
@@ -132,6 +176,7 @@ def mock_defender_get_report_submission_policy(_):
def mock_defender_get_outbound_spam_filter_policy(_):
return {
"Policy1": OutboundSpamPolicy(
name="Policy1",
notify_sender_blocked=True,
notify_limit_exceeded=True,
notify_limit_exceeded_addresses=["security@example.com"],
@@ -140,6 +185,7 @@ def mock_defender_get_outbound_spam_filter_policy(_):
default=False,
),
"Policy2": OutboundSpamPolicy(
name="Policy2",
notify_sender_blocked=False,
notify_limit_exceeded=False,
notify_limit_exceeded_addresses=[],
@@ -154,9 +200,17 @@ def mock_defender_get_outbound_spam_filter_rule(_):
return {
"Policy1": OutboundSpamRule(
state="Enabled",
priority=1,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
),
"Policy2": OutboundSpamRule(
state="Disabled",
priority=2,
users=["test@example.com"],
groups=["example_group"],
domains=["example.com"],
),
}
@@ -251,14 +305,22 @@ class Test_Defender_Service:
)
malware_rules = defender_client.malware_rules
assert malware_rules["Policy1"].state == "Enabled"
assert malware_rules["Policy1"].priority == 1
assert malware_rules["Policy1"].users == ["test@example.com"]
assert malware_rules["Policy1"].groups == ["example_group"]
assert malware_rules["Policy1"].domains == ["example.com"]
assert malware_rules["Policy2"].state == "Disabled"
assert malware_rules["Policy2"].priority == 2
assert malware_rules["Policy2"].users == ["test@example.com"]
assert malware_rules["Policy2"].groups == ["example_group"]
assert malware_rules["Policy2"].domains == ["example.com"]
defender_client.powershell.close()
@patch(
"prowler.providers.m365.services.defender.defender_service.Defender._get_antiphising_policy",
new=mock_defender_get_antiphising_policy,
"prowler.providers.m365.services.defender.defender_service.Defender._get_antiphishing_policy",
new=mock_defender_get_antiphishing_policy,
)
def test_get_antiphising_policy(self):
def test_get_antiphishing_policy(self):
with (
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
@@ -270,6 +332,7 @@ class Test_Defender_Service:
)
)
antiphishing_policies = defender_client.antiphishing_policies
assert antiphishing_policies["Policy1"].name == "Policy1"
assert antiphishing_policies["Policy1"].spoof_intelligence is True
assert (
antiphishing_policies["Policy1"].spoof_intelligence_action
@@ -286,6 +349,7 @@ class Test_Defender_Service:
assert antiphishing_policies["Policy1"].show_tag is True
assert antiphishing_policies["Policy1"].honor_dmarc_policy is True
assert antiphishing_policies["Policy1"].default is False
assert antiphishing_policies["Policy2"].name == "Policy2"
assert antiphishing_policies["Policy2"].spoof_intelligence is False
assert antiphishing_policies["Policy2"].spoof_intelligence_action == "None"
assert antiphishing_policies["Policy2"].dmarc_reject_action == "None"
@@ -300,10 +364,10 @@ class Test_Defender_Service:
defender_client.powershell.close()
@patch(
"prowler.providers.m365.services.defender.defender_service.Defender._get_antiphising_rules",
new=mock_defender_get_antiphising_rules,
"prowler.providers.m365.services.defender.defender_service.Defender._get_antiphishing_rules",
new=mock_defender_get_antiphishing_rules,
)
def test_get_antiphising_rules(self):
def test_get_antiphishing_rules(self):
with (
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
@@ -314,9 +378,17 @@ class Test_Defender_Service:
identity=M365IdentityInfo(tenant_domain=DOMAIN)
)
)
antiphishing_rules = defender_client.antiphising_rules
antiphishing_rules = defender_client.antiphishing_rules
assert antiphishing_rules["Policy1"].state == "Enabled"
assert antiphishing_rules["Policy1"].priority == 1
assert antiphishing_rules["Policy1"].users == ["test@example.com"]
assert antiphishing_rules["Policy1"].groups == ["example_group"]
assert antiphishing_rules["Policy1"].domains == ["example.com"]
assert antiphishing_rules["Policy2"].state == "Disabled"
assert antiphishing_rules["Policy2"].priority == 2
assert antiphishing_rules["Policy2"].users == ["test@example.com"]
assert antiphishing_rules["Policy2"].groups == ["example_group"]
assert antiphishing_rules["Policy2"].domains == ["example.com"]
defender_client.powershell.close()
@patch(
@@ -378,6 +450,7 @@ class Test_Defender_Service:
)
)
outbound_spam_policies = defender_client.outbound_spam_policies
assert outbound_spam_policies["Policy1"].name == "Policy1"
assert outbound_spam_policies["Policy1"].notify_sender_blocked is True
assert outbound_spam_policies["Policy1"].notify_limit_exceeded is True
assert outbound_spam_policies[
@@ -388,6 +461,7 @@ class Test_Defender_Service:
].notify_sender_blocked_addresses == ["security@example.com"]
assert outbound_spam_policies["Policy1"].auto_forwarding_mode is False
assert outbound_spam_policies["Policy1"].default is False
assert outbound_spam_policies["Policy2"].name == "Policy2"
assert outbound_spam_policies["Policy2"].notify_sender_blocked is False
assert outbound_spam_policies["Policy2"].notify_limit_exceeded is False
assert (
@@ -398,6 +472,7 @@ class Test_Defender_Service:
)
assert outbound_spam_policies["Policy2"].auto_forwarding_mode is True
assert outbound_spam_policies["Policy2"].default is True
defender_client.powershell.close()
@patch(
"prowler.providers.m365.services.defender.defender_service.Defender._get_outbound_spam_filter_rule",
@@ -416,7 +491,16 @@ class Test_Defender_Service:
)
outbound_spam_rules = defender_client.outbound_spam_rules
assert outbound_spam_rules["Policy1"].state == "Enabled"
assert outbound_spam_rules["Policy1"].priority == 1
assert outbound_spam_rules["Policy1"].users == ["test@example.com"]
assert outbound_spam_rules["Policy1"].groups == ["example_group"]
assert outbound_spam_rules["Policy1"].domains == ["example.com"]
assert outbound_spam_rules["Policy2"].state == "Disabled"
assert outbound_spam_rules["Policy2"].priority == 2
assert outbound_spam_rules["Policy2"].users == ["test@example.com"]
assert outbound_spam_rules["Policy2"].groups == ["example_group"]
assert outbound_spam_rules["Policy2"].domains == ["example.com"]
defender_client.powershell.close()
@patch(
"prowler.providers.m365.services.defender.defender_service.Defender._get_inbound_spam_filter_policy",
@@ -438,6 +522,34 @@ class Test_Defender_Service:
assert inbound_spam_policies[1].allowed_sender_domains == ["example.com"]
defender_client.powershell.close()
@patch(
"prowler.providers.m365.services.defender.defender_service.Defender._get_inbound_spam_filter_rule",
new=mock_defender_get_inbound_spam_rule,
)
def test__get_inbound_spam_filter_rule(self):
with (
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
):
defender_client = Defender(
set_mocked_m365_provider(
identity=M365IdentityInfo(tenant_domain=DOMAIN)
)
)
inbound_spam_rules = defender_client.inbound_spam_rules
assert inbound_spam_rules["Policy1"].state == "Enabled"
assert inbound_spam_rules["Policy1"].priority == 1
assert inbound_spam_rules["Policy1"].users == ["test@example.com"]
assert inbound_spam_rules["Policy1"].groups == ["example_group"]
assert inbound_spam_rules["Policy1"].domains == ["example.com"]
assert inbound_spam_rules["Policy2"].state == "Disabled"
assert inbound_spam_rules["Policy2"].priority == 2
assert inbound_spam_rules["Policy2"].users == ["test@example.com"]
assert inbound_spam_rules["Policy2"].groups == ["example_group"]
assert inbound_spam_rules["Policy2"].domains == ["example.com"]
defender_client.powershell.close()
@patch(
"prowler.providers.m365.services.defender.defender_service.Defender._get_report_submission_policy",
new=mock_defender_get_report_submission_policy,
+12
View File
@@ -2,6 +2,18 @@
All notable changes to the **Prowler UI** are documented in this file.
## [v1.7.0] (Prowler v5.7.0) – Not released
### 🚀 Added
- Add a new chart to show the split between passed and failed findings. [(#7680)](https://github.com/prowler-cloud/prowler/pull/7680)
- Added `Accordion` component. [(#7700)](https://github.com/prowler-cloud/prowler/pull/7700)
### 🐞 Fixes
- Fix form validation in launch scan workflow. [(#7693)](https://github.com/prowler-cloud/prowler/pull/7693)
- Moved ProviderType to a shared types file and replaced all occurrences across the codebase. [(#7710)](https://github.com/prowler-cloud/prowler/pull/7710)
---
## [v1.6.0] (Prowler v5.6.0)
+2 -6
View File
@@ -14,12 +14,8 @@ import {
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { createDict } from "@/lib";
import {
FindingProps,
ProviderProps,
ScanProps,
SearchParamsProps,
} from "@/types/components";
import { ProviderProps } from "@/types";
import { FindingProps, ScanProps, SearchParamsProps } from "@/types/components";
export default async function Findings({
searchParams,
@@ -2,11 +2,11 @@ import { Divider } from "@nextui-org/react";
import React from "react";
import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
import { ProviderType } from "@/types";
interface ComplianceScanInfoProps {
scan: {
providerInfo: {
provider: "aws" | "azure" | "gcp" | "kubernetes";
provider: ProviderType;
alias?: string;
uid?: string;
};
+1 -1
View File
@@ -11,7 +11,7 @@ export const filterScans = [
{
key: "provider_type__in",
labelCheckboxGroup: "Cloud Provider",
values: ["aws", "azure", "gcp", "kubernetes"],
values: ["aws", "azure", "m365", "gcp", "kubernetes"],
},
{
key: "state__in",
@@ -12,7 +12,7 @@ import {
SeverityBadge,
StatusFindingBadge,
} from "@/components/ui/table";
import { FindingProps } from "@/types";
import { FindingProps, ProviderType } from "@/types";
import { Muted } from "../muted";
import { DeltaIndicator } from "./delta-indicator";
@@ -215,7 +215,7 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
return (
<>
<EntityInfoShort
cloudProvider={provider as "aws" | "azure" | "gcp" | "kubernetes"}
cloudProvider={provider as ProviderType}
entityAlias={alias as string}
entityId={uid as string}
/>
@@ -6,12 +6,9 @@ import Link from "next/link";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import {
getProviderLogo,
type ProviderType,
} from "@/components/ui/entities/get-provider-logo";
import { getProviderLogo } from "@/components/ui/entities/get-provider-logo";
import { SeverityBadge } from "@/components/ui/table/severity-badge";
import { FindingProps } from "@/types";
import { FindingProps, ProviderType } from "@/types";
import { Muted } from "../muted";
import { DeltaIndicator } from "./delta-indicator";
@@ -9,7 +9,7 @@ import { InfoIcon } from "@/components/icons";
import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
import { FindingProps } from "@/types";
import { FindingProps, ProviderType } from "@/types";
import { Muted } from "../../../findings/muted";
@@ -162,7 +162,7 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
return (
<>
<EntityInfoShort
cloudProvider={provider as "aws" | "azure" | "gcp" | "kubernetes"}
cloudProvider={provider as ProviderType}
entityAlias={alias as string}
entityId={uid as string}
/>
+3 -2
View File
@@ -1,9 +1,10 @@
import { Tooltip } from "@nextui-org/react";
import React from "react";
import { ConnectionFalse, ConnectionPending, ConnectionTrue } from "../icons";
import { getProviderLogo, ProviderType } from "../ui/entities";
import { ProviderType } from "@/types";
import { ConnectionFalse, ConnectionPending, ConnectionTrue } from "../icons";
import { getProviderLogo } from "../ui/entities";
interface ProviderInfoProps {
connected: boolean | null;
provider: ProviderType;
@@ -9,8 +9,8 @@ import * as z from "zod";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import { ProviderType } from "@/components/ui/entities";
import { Form } from "@/components/ui/form";
import { ProviderType } from "@/types";
import { addProvider } from "../../../../actions/providers/providers";
import { addProviderFormSchema, ApiError } from "../../../../types";
@@ -18,9 +18,9 @@ import { getTask } from "@/actions/task/tasks";
import { CheckIcon, RocketIcon } from "@/components/icons";
import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { ProviderType } from "@/components/ui/entities";
import { Form } from "@/components/ui/form";
import { checkTaskStatus } from "@/lib/helper";
import { ProviderType } from "@/types";
import { ApiError, testConnectionFormSchema } from "@/types";
import { ProviderInfo } from "../..";
@@ -10,8 +10,8 @@ import * as z from "zod";
import { updateCredentialsProvider } from "@/actions/providers/providers";
import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { ProviderType } from "@/components/ui/entities";
import { Form } from "@/components/ui/form";
import { ProviderType } from "@/types";
import {
addCredentialsFormSchema,
ApiError,
@@ -10,8 +10,8 @@ import * as z from "zod";
import { addCredentialsProvider } from "@/actions/providers/providers";
import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { ProviderType } from "@/components/ui/entities";
import { Form } from "@/components/ui/form";
import { ProviderType } from "@/types";
import {
addCredentialsFormSchema,
ApiError,
@@ -1,8 +1,8 @@
import Link from "next/link";
import { getProviderName } from "@/components/ui/entities/get-provider-logo";
import { ProviderType } from "@/components/ui/entities/get-provider-logo";
import { getProviderLogo } from "@/components/ui/entities/get-provider-logo";
import { ProviderType } from "@/types";
export const ProviderTitleDocs = ({
providerType,
@@ -26,8 +26,16 @@ export const LaunchScanWorkflow = ({
}: {
providers: ProviderInfo[];
}) => {
const formSchema = onDemandScanFormSchema();
const form = useForm<z.infer<typeof formSchema>>({
const formSchema = z.object({
...onDemandScanFormSchema().shape,
scanName: z
.string()
.min(3, "Must have at least 3 characters")
.or(z.literal(""))
.optional(),
});
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
providerId: "",
@@ -77,25 +85,25 @@ export const LaunchScanWorkflow = ({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col space-y-4"
className="flex flex-wrap justify-start gap-4"
>
<div className="grid grid-cols-12 gap-6">
<div className="col-span-3">
<SelectScanProvider
providers={providers}
control={form.control}
name="providerId"
/>
</div>
<AnimatePresence>
{form.watch("providerId") && (
<>
<div className="w-72">
<SelectScanProvider
providers={providers}
control={form.control}
name="providerId"
/>
</div>
<AnimatePresence>
{form.watch("providerId") && (
<>
<div className="flex flex-wrap gap-6 md:gap-4">
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.3 }}
className="col-span-2 self-end"
className="min-w-48 self-end"
>
<CustomInput
control={form.control}
@@ -115,36 +123,35 @@ export const LaunchScanWorkflow = ({
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.3 }}
className="col-span-4 flex items-end gap-4"
className="flex items-end gap-4"
>
<div className="flex flex-row items-end gap-4">
<CustomButton
type="submit"
ariaLabel="Start scan now"
variant="solid"
color="action"
size="sm"
isLoading={isLoading}
startContent={!isLoading && <RocketIcon size={16} />}
>
{isLoading ? <>Loading</> : <span>Start now</span>}
</CustomButton>
<CustomButton
onPress={() => form.reset()}
className="w-fit border-gray-200 bg-transparent"
ariaLabel="Clear form"
variant="bordered"
size="sm"
radius="sm"
>
Cancel
</CustomButton>
</div>
<CustomButton
type="submit"
ariaLabel="Start scan now"
variant="solid"
color="action"
size="sm"
isLoading={isLoading}
startContent={!isLoading && <RocketIcon size={16} />}
>
{isLoading ? <>Loading</> : <span>Start now</span>}
</CustomButton>
<CustomButton
onPress={() => form.reset()}
className="w-fit border-gray-200 bg-transparent"
ariaLabel="Clear form"
variant="bordered"
size="sm"
radius="sm"
>
Cancel
</CustomButton>
</motion.div>
</>
)}
</AnimatePresence>
{/*
</div>
</>
)}
</AnimatePresence>
{/*
<div className="flex flex-col justify-start">
<AnimatePresence>
{form.watch("providerId") && (
@@ -169,7 +176,6 @@ export const LaunchScanWorkflow = ({
)}
</AnimatePresence>
</div> */}
</div>
</form>
</Form>
);
@@ -10,7 +10,7 @@ import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table";
import { downloadScanZip } from "@/lib/helper";
import { ScanProps } from "@/types";
import { ProviderType, ScanProps } from "@/types";
import { LinkToFindingsFromScan } from "../../link-to-findings-from-scan";
import { TriggerIcon } from "../../trigger-icon";
@@ -60,7 +60,7 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
return (
<EntityInfoShort
cloudProvider={provider as "aws" | "azure" | "gcp" | "kubernetes"}
cloudProvider={provider as ProviderType}
entityAlias={alias}
entityId={uid}
/>
+109
View File
@@ -0,0 +1,109 @@
"use client";
import {
Accordion as NextUIAccordion,
AccordionItem,
Selection,
} from "@nextui-org/react";
import { ChevronDown } from "lucide-react";
import React, { ReactNode, useCallback, useState } from "react";
import { cn } from "@/lib/utils";
export interface AccordionItemProps {
key: string;
title: ReactNode;
subtitle?: ReactNode;
content: ReactNode;
items?: AccordionItemProps[];
isDisabled?: boolean;
}
export interface AccordionProps {
items: AccordionItemProps[];
variant?: "light" | "shadow" | "bordered" | "splitted";
className?: string;
defaultExpandedKeys?: string[];
selectionMode?: "single" | "multiple";
isCompact?: boolean;
showDivider?: boolean;
}
const AccordionContent = ({
content,
items,
}: {
content: ReactNode;
items?: AccordionItemProps[];
}) => {
return (
<div className="text-sm text-gray-700 dark:text-gray-300">
{content}
{items && items.length > 0 && (
<div className="ml-2 mt-4 border-l-2 border-gray-200 pl-4 dark:border-gray-700">
<Accordion
items={items}
variant="light"
isCompact
selectionMode="multiple"
/>
</div>
)}
</div>
);
};
export const Accordion = ({
items,
variant = "light",
className,
defaultExpandedKeys = [],
selectionMode = "single",
isCompact = false,
showDivider = true,
}: AccordionProps) => {
const [expandedKeys, setExpandedKeys] = useState<Selection>(
new Set(defaultExpandedKeys),
);
const handleSelectionChange = useCallback((keys: Selection) => {
setExpandedKeys(keys);
}, []);
return (
<NextUIAccordion
className={cn("w-full", className)}
variant={variant}
selectionMode={selectionMode}
selectedKeys={expandedKeys}
onSelectionChange={handleSelectionChange}
isCompact={isCompact}
showDivider={showDivider}
>
{items.map((item, index) => (
<AccordionItem
key={item.key}
aria-label={
typeof item.title === "string" ? item.title : `Item ${item.key}`
}
title={item.title}
subtitle={item.subtitle}
isDisabled={item.isDisabled}
indicator={<ChevronDown className="text-gray-500" />}
classNames={{
base: index === 0 || index === 1 ? "my-2" : "my-1",
title: "text-sm font-medium",
subtitle: "text-xs text-gray-500",
trigger:
"p-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50",
content: "p-2",
}}
>
<AccordionContent content={item.content} items={item.items} />
</AccordionItem>
))}
</NextUIAccordion>
);
};
Accordion.displayName = "Accordion";
@@ -0,0 +1,241 @@
"use client";
import { Tooltip } from "@nextui-org/react";
import * as React from "react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface HorizontalSplitBarProps {
/**
* First value (left)
*/
valueA: number;
/**
* Second value (right)
*/
valueB: number;
/**
* Additional CSS classes for the main container
*/
className?: string;
/**
* Color for value A (Tailwind classes)
* @default "bg-system-success"
*/
colorA?: string;
/**
* Color for value B (Tailwind classes)
* @default "bg-system-error"
*/
colorB?: string;
/**
* Value format suffix (like "%", "$", etc.)
* Will be appended to the values when displayed
* @example "%"
*/
valueSuffix?: string;
/**
* Bar height
* @default "h-4"
*/
barHeight?: string;
/**
* Color for the empty state (when both values are 0)
* @default "bg-gray-300"
*/
emptyColor?: string;
/**
* Text to display when there is no data
* @default "No data available"
*/
emptyText?: string;
/**
* Minimum width for small values (in pixels)
* @default 25
*/
minBarWidth?: number;
/**
* Custom tooltip content for value A (optional)
* If not provided, the formatted value will be used
*/
tooltipContentA?: string;
/**
* Custom tooltip content for value B (optional)
* If not provided, the formatted value will be used
*/
tooltipContentB?: string;
/**
* Text color for labels
* @default "text-gray-700"
*/
labelColor?: string;
}
/**
* Horizontal split bar chart component that displays two values
* with bars growing from a central separator.
*
* @example
* ```tsx
* <HorizontalSplitBar
* valueA={54}
* valueB={38}
* valueSuffix="%"
* />
* ```
*/
export const HorizontalSplitBar = ({
valueA,
valueB,
className,
colorA = "bg-system-success",
colorB = "bg-system-error",
valueSuffix = "",
barHeight = "h-4",
emptyColor = "bg-gray-300",
emptyText = "No data available",
minBarWidth = 25,
tooltipContentA,
tooltipContentB,
labelColor = "text-gray-700",
}: HorizontalSplitBarProps) => {
// Reference to the container to measure its width
const containerRef = React.useRef<HTMLDivElement>(null);
const [maxContainerWidth, setMaxContainerWidth] = useState(0);
// Effect to measure the container width
useEffect(() => {
if (containerRef.current) {
const updateWidth = () => {
const containerWidth = containerRef.current?.clientWidth || 0;
setMaxContainerWidth(containerWidth);
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}
}, []);
// Ensure values are positive
const valA = Math.max(0, valueA);
const valB = Math.max(0, valueB);
const hasNoData = valA === 0 && valB === 0;
const formattedValueA = `${valA}${valueSuffix}`;
const formattedValueB = `${valB}${valueSuffix}`;
if (hasNoData) {
return (
<div ref={containerRef} className={cn("flex w-full flex-col", className)}>
<div className="flex w-full justify-center">
<div
className={cn(
`w-full ${barHeight} flex items-center justify-center rounded-full`,
emptyColor,
)}
>
<span className="text-xs font-medium text-gray-600">
{emptyText}
</span>
</div>
</div>
</div>
);
}
const availableWidth = Math.max(0, maxContainerWidth);
const halfWidth = availableWidth / 2;
const separatorWidth = 1;
let rawWidthA = valA;
let rawWidthB = valB;
// Determine if we need to scale to fit in available space
const maxSideWidth = halfWidth - separatorWidth / 2;
const needsScaling = rawWidthA > maxSideWidth || rawWidthB > maxSideWidth;
if (needsScaling) {
// Calculate scale factor based on the largest value
const maxRawWidth = Math.max(rawWidthA, rawWidthB);
const scaleFactor = maxSideWidth / maxRawWidth;
// Apply the scale factor to both sides
rawWidthA = rawWidthA * scaleFactor;
rawWidthB = rawWidthB * scaleFactor;
}
// Apply minimum width if needed
const barWidthA = Math.max(rawWidthA, valA > 0 ? minBarWidth : 0);
const barWidthB = Math.max(rawWidthB, valB > 0 ? minBarWidth : 0);
return (
<div ref={containerRef} className={cn("flex w-full flex-col", className)}>
<div className="flex items-center justify-center">
<div
className="flex items-center justify-end gap-2"
style={{ width: `${halfWidth}px` }}
>
{/* Left label */}
<div
className={cn("text-xs font-medium", labelColor)}
aria-label={`${formattedValueA} ${tooltipContentA ? tooltipContentA : ""}`}
>
{valA > 0 ? formattedValueA : "0"}
</div>
{/* Left bar */}
{valA > 0 && (
<Tooltip
content={`${formattedValueA} ${tooltipContentA ? tooltipContentA : ""}`}
className="text-xs"
>
<div
className={cn(`${barHeight} rounded-l-full`, colorA)}
style={{
width: `${barWidthA}px`,
}}
/>
</Tooltip>
)}
</div>
{/* Central separator */}
<div
className="flex-shrink-0 bg-background"
style={{ width: `${separatorWidth}px` }}
/>
<div
className="flex items-center justify-start gap-2"
style={{ width: `${halfWidth}px` }}
>
{/* Right bar */}
{valB > 0 && (
<Tooltip
content={`${formattedValueB} ${tooltipContentB ? tooltipContentB : ""}`}
className="text-xs"
>
<div
className={cn(`${barHeight} rounded-r-full`, colorB)}
style={{
width: `${barWidthB}px`,
}}
/>
</Tooltip>
)}
{/* Right label */}
<div
className={cn("text-xs font-medium", labelColor)}
aria-label={`${formattedValueB} ${tooltipContentB ? tooltipContentB : ""}`}
>
{valB > 0 ? formattedValueB : "0"}
</div>
</div>
</div>
</div>
);
};
export default HorizontalSplitBar;
+1 -1
View File
@@ -120,7 +120,7 @@ export const CustomInput = <T extends FieldValues>({
/>
</FormControl>
{showFormMessage && (
<FormMessage className="text-system-error dark:text-system-error" />
<FormMessage className="max-w-full text-xs text-system-error dark:text-system-error" />
)}
</>
)}
@@ -1,10 +1,12 @@
import React from "react";
import { ProviderType } from "@/types";
import { getProviderLogo } from "./get-provider-logo";
import { SnippetId } from "./snippet-id";
interface EntityInfoProps {
cloudProvider: "aws" | "azure" | "gcp" | "kubernetes";
cloudProvider: ProviderType;
entityAlias?: string;
entityId?: string;
hideCopyButton?: boolean;
@@ -7,8 +7,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
} from "@/components/icons/providers-badge";
export type ProviderType = "aws" | "azure" | "gcp" | "kubernetes" | "m365";
import { ProviderType } from "@/types";
export const getProviderLogo = (provider: ProviderType) => {
switch (provider) {
+1
View File
@@ -1,3 +1,4 @@
export * from "./accordion/Accordion";
export * from "./action-card/ActionCard";
export * from "./alert/Alert";
export * from "./alert-dialog/AlertDialog";
+24 -19
View File
@@ -43,11 +43,11 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.2",
"recharts": "^2.13.0-alpha.4",
"recharts": "^2.15.2",
"server-only": "^0.0.1",
"shadcn-ui": "^0.2.3",
"sharp": "^0.33.5",
"tailwind-merge": "^2.5.3",
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.5",
"zod": "^3.23.8",
@@ -9420,6 +9420,7 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
@@ -10402,9 +10403,10 @@
"dev": true
},
"node_modules/fast-equals": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz",
"integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==",
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
@@ -13363,17 +13365,18 @@
}
},
"node_modules/react-smooth": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz",
"integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
@@ -13419,6 +13422,7 @@
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
@@ -13463,15 +13467,16 @@
}
},
"node_modules/recharts": {
"version": "2.13.0-alpha.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.13.0-alpha.4.tgz",
"integrity": "sha512-K9naL6F7pEcDYJE6yFQASSCQecSLPP0JagnvQ9hPtA/aHgsxsnIOjouLP5yrFZehxzfCkV5TEORr7/uNtSr7Qw==",
"version": "2.15.3",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.3.tgz",
"integrity": "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.0",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
@@ -13480,8 +13485,8 @@
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
@@ -14470,9 +14475,9 @@
}
},
"node_modules/tailwind-merge": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.3.tgz",
"integrity": "sha512-d9ZolCAIzom1nf/5p4LdD5zvjmgSxY0BGgdSvmXIoMYAiPdAW/dSpP7joCDYFY7r/HkEa2qmPtkgsu0xjQeQtw==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz",
"integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==",
"license": "MIT",
"funding": {
"type": "github",
+2 -2
View File
@@ -35,11 +35,11 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.2",
"recharts": "^2.13.0-alpha.4",
"recharts": "^2.15.2",
"server-only": "^0.0.1",
"shadcn-ui": "^0.2.3",
"sharp": "^0.33.5",
"tailwind-merge": "^2.5.3",
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.5",
"zod": "^3.23.8",
+4 -68
View File
@@ -1,6 +1,8 @@
import { LucideIcon } from "lucide-react";
import { SVGProps } from "react";
import { ProviderType } from "./providers";
export type IconSvgProps = SVGProps<SVGSVGElement> & {
size?: number;
};
@@ -45,7 +47,7 @@ export interface CollapseMenuButtonProps {
export interface SelectScanComplianceDataProps {
scans: (ScanProps & {
providerInfo: {
provider: "aws" | "azure" | "gcp" | "kubernetes";
provider: ProviderType;
uid: string;
alias: string;
};
@@ -174,27 +176,6 @@ export interface FindingsSeverityOverview {
};
}
export interface ProviderOverviewProps {
data: {
type: "provider-overviews";
id: "aws" | "gcp" | "azure" | "kubernetes";
attributes: {
findings: {
pass: number;
fail: number;
manual: number;
total: number;
};
resources: {
total: number;
};
};
}[];
meta: {
version: string;
};
}
export interface TaskDetails {
attributes: {
state: string;
@@ -509,51 +490,6 @@ export interface UserProps {
}[];
}
export interface ProviderProps {
id: string;
type: "providers";
attributes: {
provider: "aws" | "azure" | "m365" | "gcp" | "kubernetes";
uid: string;
alias: string;
status: "completed" | "pending" | "cancelled";
resources: number;
connection: {
connected: boolean;
last_checked_at: string;
};
scanner_args: {
only_logs: boolean;
excluded_checks: string[];
aws_retries_max_attempts: number;
};
inserted_at: string;
updated_at: string;
created_by: {
object: string;
id: string;
};
};
relationships: {
secret: {
data: {
type: string;
id: string;
} | null;
};
provider_groups: {
meta: {
count: number;
};
data: Array<{
type: string;
id: string;
}>;
};
};
groupNames?: string[];
}
export interface ScanProps {
type: "scans";
id: string;
@@ -596,7 +532,7 @@ export interface ScanProps {
};
};
providerInfo?: {
provider: "aws" | "azure" | "gcp" | "kubernetes";
provider: ProviderType;
uid: string;
alias: string;
};
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more