Compare commits

...
Author SHA1 Message Date
Pepe Fagoaga 9b0487eb9a chore(postgres): use pgbouncer 2025-06-09 13:08:57 +02:00
Pablo Lara 3a99909b75 chore: align Next.js version to 14.2.29 across Prowler and Cloud (#7962) 2025-06-06 13:54:42 +02:00
Pablo Lara 2ecd9ad2c5 docs: update changelog (#7960) 2025-06-06 13:17:38 +02:00
Alejandro BailoandPablo Lara 50dc396aa3 feat: scan id filter drowpdown (#7949)
Co-authored-by: Pablo Lara <larabjj@gmail.com>
2025-06-06 12:38:14 +02:00
Andoni Alonso acf333493a chore(api): reorder docker layers to speed up build times (#7957) 2025-06-06 10:42:14 +02:00
Pedro Martín bd6272f5a7 feat(docs): add information about tenants and read-only roles (#7956) 2025-06-06 10:14:33 +02:00
Pepe Fagoaga 8c95e1efaf chore: update API changelog for v5.7.3 (#7948) 2025-06-05 15:54:36 +02:00
Hugo Pereira BritoandVíctor Fernández Poyatos 845a0aa0d5 fix(changelog): add entries for password encryption in v5.7.3 (#7939)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-06-05 14:23:12 +02:00
Hugo Pereira Brito 75a11be9e6 fix(docs): add final permission assignments example (#7943) 2025-06-05 18:07:43 +05:45
Hugo Pereira BritoandPepe Fagoaga a778d005b6 fix(docs): add mfa warning for users (#7924)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2025-06-05 17:55:27 +05:45
Pedro Martín 1281f4ec5e chore(changelog): update following the correct format (#7908) 2025-06-05 17:52:36 +05:45
Víctor Fernández Poyatos 6332427e5e fix(compliance): add manual status to requirements (#7938) 2025-06-05 10:54:51 +02:00
Alejandro Bailo d89df83904 fix: Improve the perfomance removing regions heatmap (#7934) 2025-06-05 08:13:47 +02:00
Víctor Fernández Poyatos be420afebc feat(database): handle already closed connections (#7935) 2025-06-04 16:09:36 +02:00
Adrián Jesús Peña Rodríguez fb914a2c90 revert: remove get_with_retry (#7932) 2025-06-04 15:01:47 +02:00
Pablo Lara 4ac3cfc33d docs: update changelog (#7931) 2025-06-04 13:54:25 +02:00
Alejandro Bailo c74360ab63 fix: clear filters sync (#7928) 2025-06-04 13:32:52 +02:00
Alejandro Bailo 4dc4d82d42 feat: aws-well-architected compliance detailed view (#7925) 2025-06-04 12:26:27 +02:00
Víctor Fernández Poyatos 6e7a32cb51 revert(views): calling order to initial view method (#7921) 2025-06-03 16:38:00 +02:00
Alejandro BailoandVíctor Fernández Poyatos 49e501c4be feat: CIS compliance detail view (#7913)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-06-03 15:47:46 +02:00
Víctor Fernández Poyatos 9ee78fe65f fix(views): calling order to initial view method (#7918) 2025-06-03 13:34:44 +02:00
Víctor Fernández Poyatos 7a0549d39c fix(rls): Apply persistent RLS transactions (#7916) 2025-06-03 13:10:41 +02:00
3e8c86d880 feat: ISO compliance detail view (#7897)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
Co-authored-by: Pablo Lara <larabjj@gmail.com>
2025-06-03 09:20:52 +02:00
Pablo Lara e34c18757d fix: Fix named export for addCredentialsServiceAccountFormSchema (#7909) 2025-06-03 08:33:24 +02:00
Alejandro BailoandVíctor Fernández Poyatos 5c1a47d108 feat: compliance detail view + ENS (#7853)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-06-02 18:20:22 +02:00
Víctor Fernández Poyatos 59c51d5a4a feat(compliance): Rework compliance overviews (#7877) 2025-06-02 17:06:24 +02:00
Pedro MartínandRubén De la Torre Vico 66aa67f636 feat(changelog): update version with fixes (#7904)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
2025-06-02 12:32:45 +02:00
Pablo Lara bdda377482 docs: update the changelog (#7901) 2025-06-02 11:49:04 +02:00
Hugo Pereira Brito aa11ed70bd chore(docs): replace old permission images (#7900) 2025-06-02 11:47:11 +02:00
Adrián Jesús Peña RodríguezandPepe Fagoaga 0580dca6cf fix: set user_id for tenant operations (#7890)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2025-06-02 11:06:49 +02:00
Pablo Lara 678ef0ab5a feat(providers): setup workflow to support new GCP credential method (#7872) 2025-06-02 10:23:39 +02:00
César Arroba 4888c27713 chore: fix commit sha when a pr is merged (#7889) 2025-05-30 17:40:57 +05:45
Hugo Pereira Brito b256c10622 chore: replace Directory.Read.All permission to Domain.Read.All for Azure (#7888) 2025-05-30 10:24:49 +02:00
Adrián Jesús Peña RodríguezandVíctor Fernández Poyatos 878e4e0bbc fix: add new get method to avoid race conditions when creating async tasks (#7876)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-05-30 10:07:32 +02:00
Hugo Pereira Brito 6c3653c483 fix(docs): remove warning of encrypted password for cloud (#7886) 2025-05-30 12:01:32 +04:00
Daniel BarranqueroandPepe Fagoaga 71ac703e6f fix(api): connection correctly reflected (#7831)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2025-05-29 20:23:15 +05:45
Sergio Garcia a89e3598f2 fix(gcp): test connection by verifying token (#7882) 2025-05-29 13:20:53 +02:00
Alison Vilela 5d043cc929 fix(awslambda): aws service awslambda not working (#7869) 2025-05-29 12:50:23 +05:45
Pepe Fagoaga 921f94ebbf fix(k8s): UID validation for valid context names (#7871) 2025-05-29 12:32:57 +05:45
sumit-tft 48c9ed8a79 fix(ui): increase limit to retrieve more than 10 scan list (#7865) 2025-05-29 07:52:36 +02:00
Hugo Pereira Brito 12987ec9f9 fix(admincenter): service and group visibility (#7870) 2025-05-28 16:48:49 +02:00
Hugo Pereira Brito 40b90ed063 fix(tests): typo in m365 domain test (#7866) 2025-05-28 16:43:58 +02:00
Alejandro Bailo 60314e781f feat: enhance CustomDropdownFilter (#7868) 2025-05-28 16:30:28 +02:00
Harsh KumarandHarsh Kumar bc56d48595 feat(dashboard): add client-side search functionality to findings table (#7804)
Co-authored-by: Harsh Kumar <harsh.k@cybersecurist.com>
2025-05-28 11:44:01 +02:00
Pedro Martín 2d71cef3d5 feat(azure): add NIS 2 compliance framework (#7857) 2025-05-28 11:35:40 +02:00
Daniel BarranqueroandMrCloudSec 41f6637497 fix(defender): update defender_ensure_notify_alerts_severity_is_high logic (#7862)
Co-authored-by: MrCloudSec <hello@mistercloudsec.com>
2025-05-28 10:32:44 +02:00
Pedro MartínandSergio Garcia c2e54bbbcc fix(threatscore): remove compliance name in tests to remove dummy files (#7859)
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-05-28 10:03:20 +02:00
sumit-tft df8aacd09d fix(ui): Added missing icons (kisa, prowlerThreat) on compliance page (#7860) 2025-05-28 09:51:28 +02:00
Matt Keeler 2dd6be59b9 fix(m365): add compliantDevice grant control support (#7844) 2025-05-28 09:05:00 +02:00
9e8e3eb0e6 fix(m365): update documentation (#7823)
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-05-28 08:52:03 +02:00
Sergio GarciaandPedro Martín 3728430f8c chore: update README (#7842)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2025-05-27 14:25:37 +02:00
sumit-tftandPablo Lara ea97de7f43 fix(ui): updated to use the correct message when download report clicked (#7758)
Co-authored-by: Pablo Lara <larabjj@gmail.com>
2025-05-27 10:51:08 +02:00
Rubén De la Torre Vico f254a4bc0d feat(app): split SDK App service calls (#7778) 2025-05-27 09:52:50 +02:00
Pedro Martín 66acfd8691 feat(aws): add NIS2 compliance framework (#7839) 2025-05-27 09:35:57 +02:00
Matt Keeler 02ca82004f fix(typo): minor language updates (#7843) 2025-05-27 09:26:51 +02:00
Rubén De la Torre Vico 60b5a79b27 fix(vpc): change the ServiceName from EC2 to VPC (#7840) 2025-05-26 17:52:59 +02:00
be1e3e942b feat(api): support GCP Service Account key (#7824)
Co-authored-by: Sergio Garcia <38561120+garcitm@users.noreply.github.com>
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-05-26 15:42:39 +02:00
Sergio Garcia 3658e85cfc chore(github): add Branch class (#7838) 2025-05-26 14:34:44 +02:00
Adrián Jesús Peña Rodríguez 15e4d1acce refactor(reports): change API response message when tasks are running (#7837) 2025-05-26 12:20:05 +02:00
225 changed files with 44285 additions and 2039 deletions
+5 -1
View File
@@ -16,7 +16,7 @@ AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8="
PROWLER_API_VERSION="stable"
# PostgreSQL settings
# If running Django and celery on host, use 'localhost', else use 'postgres-db'
POSTGRES_HOST=postgres-db
POSTGRES_HOST=postgres-db-proxy
POSTGRES_PORT=5432
POSTGRES_ADMIN_USER=prowler_admin
POSTGRES_ADMIN_PASSWORD=postgres
@@ -24,6 +24,10 @@ POSTGRES_USER=prowler
POSTGRES_PASSWORD=postgres
POSTGRES_DB=prowler_db
# Celery-Prowler task settings
TASK_RETRY_DELAY_SECONDS=0.1
TASK_RETRY_ATTEMPTS=5
# Valkey settings
# If running Valkey and celery on host, use localhost, else use 'valkey'
VALKEY_HOST=valkey
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
- name: Set short git commit SHA
id: vars
run: |
shortSha=$(git rev-parse --short ${{ github.sha }})
shortSha=$(git rev-parse --short ${{ github.event.pull_request.merge_commit_sha }})
echo "SHORT_SHA=${shortSha}" >> $GITHUB_ENV
- name: Trigger pull request
@@ -28,7 +28,7 @@ jobs:
repository: ${{ secrets.CLOUD_DISPATCH }}
event-type: prowler-pull-request-merged
client-payload: '{
"PROWLER_COMMIT_SHA": "${{ github.sha }}",
"PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}",
"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) }},
+4 -4
View File
@@ -86,12 +86,12 @@ prowler dashboard
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) |
|---|---|---|---|---|
| AWS | 564 | 82 | 34 | 10 |
| AWS | 567 | 82 | 36 | 10 |
| GCP | 79 | 13 | 9 | 3 |
| Azure | 140 | 18 | 8 | 3 |
| Azure | 142 | 18 | 10 | 3 |
| Kubernetes | 83 | 7 | 5 | 7 |
| GitHub | 3 | 2 | 1 | 0 |
| M365 | 44 | 2 | 2 | 0 |
| GitHub | 16 | 2 | 1 | 0 |
| M365 | 69 | 7 | 2 | 2 |
| NHN (Unofficial) | 6 | 2 | 1 | 0 |
> [!Note]
+24 -1
View File
@@ -4,22 +4,45 @@ All notable changes to the **Prowler API** are documented in this file.
## [v1.9.0] (Prowler UNRELEASED)
### Added
- Support GCP Service Account key. [(#7824)](https://github.com/prowler-cloud/prowler/pull/7824)
- Added new `GET /compliance-overviews` endpoints to retrieve compliance metadata and specific requirements statuses [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877).
### Changed
- Reworked `GET /compliance-overviews` to return proper requirement metrics [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877).
---
## [v1.8.3] (Prowler v5.7.3)
### Added
- Database backend to handle already closed connections [(#7935)](https://github.com/prowler-cloud/prowler/pull/7935).
### Changed
- Renamed field encrypted_password to password for M365 provider [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784)
### Fixed
- Fixed transaction persistence with RLS operations [(#7916)](https://github.com/prowler-cloud/prowler/pull/7916).
- Reverted the change `get_with_retry` to use the original `get` method for retrieving tasks [(#7932)](https://github.com/prowler-cloud/prowler/pull/7932).
- Fixed the connection status verification before launching a scan [(#7831)](https://github.com/prowler-cloud/prowler/pull/7831)
---
## [v1.8.2] (Prowler v5.7.2)
### Fixed
- Fixed task lookup to use task_kwargs instead of task_args for scan report resolution. [(#7830)](https://github.com/prowler-cloud/prowler/pull/7830)
- Fixed Kubernetes UID validation to allow valid context names [(#7871)](https://github.com/prowler-cloud/prowler/pull/7871)
- Fixed a race condition when creating background tasks [(#7876)](https://github.com/prowler-cloud/prowler/pull/7876).
- Fixed an error when modifying or retrieving tenants due to missing user UUID in transaction context [(#7890)](https://github.com/prowler-cloud/prowler/pull/7890).
---
## [v1.8.1] (Prowler v5.7.1)
### Fixed
- Added database index to improve performance on finding lookup. [(#7800)](https://github.com/prowler-cloud/prowler/pull/7800)
- Added database index to improve performance on finding lookup [(#7800)](https://github.com/prowler-cloud/prowler/pull/7800).
---
+3 -4
View File
@@ -37,18 +37,17 @@ COPY pyproject.toml ./
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir poetry
COPY src/backend/ ./backend/
ENV PATH="/home/prowler/.local/bin:$PATH"
# Add `--no-root` to avoid installing the current project as a package
RUN poetry install --no-root && \
rm -rf ~/.cache/pip
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN poetry run python "$(poetry env info --path)/src/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py"
COPY src/backend/ ./backend/
COPY docker-entrypoint.sh ./docker-entrypoint.sh
WORKDIR /home/prowler/backend
# Development image
+46 -30
View File
@@ -1,5 +1,4 @@
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from rest_framework import permissions
from rest_framework.exceptions import NotAuthenticated
from rest_framework.filters import SearchFilter
@@ -47,11 +46,9 @@ class BaseViewSet(ModelViewSet):
class BaseRLSViewSet(BaseViewSet):
def dispatch(self, request, *args, **kwargs):
with transaction.atomic():
return super().dispatch(request, *args, **kwargs)
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
# Ideally, this logic would be in the `.setup()` method but DRF view sets don't call it
# https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.setup
if request.auth is None:
@@ -61,9 +58,19 @@ class BaseRLSViewSet(BaseViewSet):
if tenant_id is None:
raise NotAuthenticated("Tenant ID is not present in token")
with rls_transaction(tenant_id):
self.request.tenant_id = tenant_id
return super().initial(request, *args, **kwargs)
self.request.tenant_id = tenant_id
self._rls_cm = rls_transaction(tenant_id)
self._rls_cm.__enter__()
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
if hasattr(self, "_rls_cm"):
self._rls_cm.__exit__(None, None, None)
del self._rls_cm
return response
def get_serializer_context(self):
context = super().get_serializer_context()
@@ -73,8 +80,7 @@ class BaseRLSViewSet(BaseViewSet):
class BaseTenantViewset(BaseViewSet):
def dispatch(self, request, *args, **kwargs):
with transaction.atomic():
tenant = super().dispatch(request, *args, **kwargs)
tenant = super().dispatch(request, *args, **kwargs)
try:
# If the request is a POST, create the admin role
@@ -109,16 +115,8 @@ class BaseTenantViewset(BaseViewSet):
pass # Tenant might not exist, handle gracefully
def initial(self, request, *args, **kwargs):
if (
request.resolver_match.url_name != "tenant-detail"
and request.method != "DELETE"
):
user_id = str(request.user.id)
super().initial(request, *args, **kwargs)
with rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR):
return super().initial(request, *args, **kwargs)
# TODO: DRY this when we have time
if request.auth is None:
raise NotAuthenticated
@@ -126,20 +124,28 @@ class BaseTenantViewset(BaseViewSet):
if tenant_id is None:
raise NotAuthenticated("Tenant ID is not present in token")
with rls_transaction(tenant_id):
self.request.tenant_id = tenant_id
return super().initial(request, *args, **kwargs)
user_id = str(request.user.id)
self._rls_cm = rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR)
self._rls_cm.__enter__()
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
if hasattr(self, "_rls_cm"):
self._rls_cm.__exit__(None, None, None)
del self._rls_cm
return response
class BaseUserViewset(BaseViewSet):
def dispatch(self, request, *args, **kwargs):
with transaction.atomic():
return super().dispatch(request, *args, **kwargs)
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
# TODO refactor after improving RLS on users
if request.stream is not None and request.stream.method == "POST":
return super().initial(request, *args, **kwargs)
return
if request.auth is None:
raise NotAuthenticated
@@ -147,6 +153,16 @@ class BaseUserViewset(BaseViewSet):
if tenant_id is None:
raise NotAuthenticated("Tenant ID is not present in token")
with rls_transaction(tenant_id):
self.request.tenant_id = tenant_id
return super().initial(request, *args, **kwargs)
self.request.tenant_id = tenant_id
self._rls_cm = rls_transaction(tenant_id)
self._rls_cm.__enter__()
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
if hasattr(self, "_rls_cm"):
self._rls_cm.__exit__(None, None, None)
del self._rls_cm
return response
+7 -7
View File
@@ -190,6 +190,8 @@ def generate_compliance_overview_template(prowler_compliance: dict):
total_checks = len(requirement.Checks)
checks_dict = {check: None for check in requirement.Checks}
req_status_val = "MANUAL" if total_checks == 0 else "PASS"
# Build requirement dictionary
requirement_dict = {
"name": requirement.Name or requirement.Id,
@@ -204,20 +206,18 @@ def generate_compliance_overview_template(prowler_compliance: dict):
"manual": 0,
"total": total_checks,
},
"status": "PASS",
"status": req_status_val,
}
# Update requirements status
if total_checks == 0:
# Update requirements status counts for the framework
if req_status_val == "MANUAL":
requirements_status["manual"] += 1
elif req_status_val == "PASS":
requirements_status["passed"] += 1
# Add requirement to compliance requirements
compliance_requirements[requirement.Id] = requirement_dict
# Calculate pending requirements
pending_requirements = total_requirements - requirements_status["manual"]
requirements_status["passed"] = pending_requirements
# Build compliance dictionary
compliance_dict = {
"framework": compliance_data.Framework,
+124 -11
View File
@@ -1,3 +1,4 @@
import re
import secrets
import uuid
from contextlib import contextmanager
@@ -152,6 +153,28 @@ def delete_related_daily_task(provider_id: str):
PeriodicTask.objects.filter(name=task_name).delete()
def create_objects_in_batches(
tenant_id: str, model, objects: list, batch_size: int = 500
):
"""
Bulk-create model instances in repeated, per-tenant RLS transactions.
All chunks execute in their own transaction, so no single transaction
grows too large.
Args:
tenant_id (str): UUID string of the tenant under which to set RLS.
model: Django model class whose `.objects.bulk_create()` will be called.
objects (list): List of model instances (unsaved) to bulk-create.
batch_size (int): Maximum number of objects per bulk_create call.
"""
total = len(objects)
for i in range(0, total, batch_size):
chunk = objects[i : i + batch_size]
with rls_transaction(value=tenant_id, parameter=POSTGRES_TENANT_VAR):
model.objects.bulk_create(chunk, batch_size)
# Postgres Enums
@@ -227,6 +250,72 @@ def register_enum(apps, schema_editor, enum_class): # noqa: F841
register_adapter(enum_class, enum_adapter)
def _should_create_index_on_partition(
partition_name: str, all_partitions: bool = False
) -> bool:
"""
Determine if we should create an index on this partition.
Args:
partition_name: The name of the partition (e.g., "findings_2025_aug", "findings_default")
all_partitions: If True, create on all partitions. If False, only current/future partitions.
Returns:
bool: True if index should be created on this partition, False otherwise.
"""
if all_partitions:
return True
# Extract date from partition name if it follows the pattern
# Partition names look like: findings_2025_aug, findings_2025_jul, etc.
date_pattern = r"(\d{4})_([a-z]{3})$"
match = re.search(date_pattern, partition_name)
if not match:
# If we can't parse the date, include it to be safe (e.g., default partition)
return True
try:
year_str, month_abbr = match.groups()
year = int(year_str)
# Map month abbreviations to numbers
month_map = {
"jan": 1,
"feb": 2,
"mar": 3,
"apr": 4,
"may": 5,
"jun": 6,
"jul": 7,
"aug": 8,
"sep": 9,
"oct": 10,
"nov": 11,
"dec": 12,
}
month = month_map.get(month_abbr.lower())
if month is None:
# Unknown month abbreviation, include it to be safe
return True
partition_date = datetime(year, month, 1, tzinfo=timezone.utc)
# Get current month start
now = datetime.now(timezone.utc)
current_month_start = now.replace(
day=1, hour=0, minute=0, second=0, microsecond=0
)
# Include current month and future partitions
return partition_date >= current_month_start
except (ValueError, TypeError):
# If date parsing fails, include it to be safe
return True
def create_index_on_partitions(
apps, # noqa: F841
schema_editor,
@@ -235,16 +324,39 @@ def create_index_on_partitions(
columns: str,
method: str = "BTREE",
where: str = "",
all_partitions: bool = True,
):
"""
Create an index on every existing partition of `parent_table`.
Create an index on existing partitions 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'".
method: The index method—BTREE, GIN, etc. Defaults to BTREE.
where: Optional WHERE clause (without the leading "WHERE"), e.g. "status = 'FAIL'".
all_partitions: Whether to create indexes on all partitions or just current/future ones.
Defaults to False (current/future only) to avoid maintenance overhead
on old partitions where the index may not be needed.
Examples:
# Create index only on current and future partitions (recommended for new indexes)
create_index_on_partitions(
apps, schema_editor,
parent_table="findings",
index_name="new_performance_idx",
columns="tenant_id, status, severity",
all_partitions=False # Default behavior
)
# Create index on all partitions (use when migrating existing critical indexes)
create_index_on_partitions(
apps, schema_editor,
parent_table="findings",
index_name="critical_existing_idx",
columns="tenant_id, scan_id",
all_partitions=True
)
"""
with connection.cursor() as cursor:
cursor.execute(
@@ -259,13 +371,14 @@ def create_index_on_partitions(
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)
if _should_create_index_on_partition(partition, all_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(
@@ -279,7 +392,7 @@ def drop_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.
index_name: The same short name used when creating them.
"""
with connection.cursor() as cursor:
cursor.execute(
+34 -4
View File
@@ -3,7 +3,7 @@ from rest_framework import status
from rest_framework.exceptions import APIException
from rest_framework_json_api.exceptions import exception_handler
from rest_framework_json_api.serializers import ValidationError
from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
class ModelValidationError(ValidationError):
@@ -32,6 +32,31 @@ class InvitationTokenExpiredException(APIException):
default_code = "token_expired"
# Task Management Exceptions (non-HTTP)
class TaskManagementError(Exception):
"""Base exception for task management errors."""
def __init__(self, task=None):
self.task = task
super().__init__()
class TaskFailedException(TaskManagementError):
"""Raised when a task has failed."""
class TaskNotFoundException(TaskManagementError):
"""Raised when a task is not found."""
class TaskInProgressException(TaskManagementError):
"""Raised when a task is running but there's no related Task object to return."""
def __init__(self, task_result=None):
self.task_result = task_result
super().__init__()
def custom_exception_handler(exc, context):
if isinstance(exc, django_validation_error):
if hasattr(exc, "error_dict"):
@@ -39,7 +64,12 @@ def custom_exception_handler(exc, context):
else:
exc = ValidationError(detail=exc.messages[0], code=exc.code)
elif isinstance(exc, (TokenError, InvalidToken)):
exc.detail["messages"] = [
message_item["message"] for message_item in exc.detail["messages"]
]
if (
hasattr(exc, "detail")
and isinstance(exc.detail, dict)
and "messages" in exc.detail
):
exc.detail["messages"] = [
message_item["message"] for message_item in exc.detail["messages"]
]
return exception_handler(exc, context)
+4 -5
View File
@@ -22,7 +22,7 @@ from api.db_utils import (
StatusEnumField,
)
from api.models import (
ComplianceOverview,
ComplianceRequirementOverview,
Finding,
Integration,
Invitation,
@@ -637,12 +637,11 @@ class RoleFilter(FilterSet):
class ComplianceOverviewFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
provider_type = ChoiceFilter(choices=Provider.ProviderChoices.choices)
provider_type__in = ChoiceInFilter(choices=Provider.ProviderChoices.choices)
scan_id = UUIDFilter(field_name="scan__id")
scan_id = UUIDFilter(field_name="scan_id")
region = CharFilter(field_name="region")
class Meta:
model = ComplianceOverview
model = ComplianceRequirementOverview
fields = {
"inserted_at": ["date", "gte", "lte"],
"compliance_id": ["exact", "icontains"],
@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("api", "0025_findings_uid_index_parent"),
]
operations = [
migrations.RunSQL(
"ALTER TYPE provider_secret_type ADD VALUE IF NOT EXISTS 'service_account';",
reverse_sql=migrations.RunSQL.noop,
),
]
@@ -0,0 +1,124 @@
# Generated by Django 5.1.8 on 2025-05-21 11:37
import uuid
import django.db.models.deletion
from django.db import migrations, models
import api.db_utils
import api.rls
from api.rls import RowLevelSecurityConstraint
class Migration(migrations.Migration):
dependencies = [
("api", "0026_provider_secret_gcp_service_account"),
]
operations = [
migrations.CreateModel(
name="ComplianceRequirementOverview",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("compliance_id", models.TextField(blank=False)),
("framework", models.TextField(blank=False)),
("version", models.TextField(blank=True)),
("description", models.TextField(blank=True)),
("region", models.TextField(blank=False)),
("requirement_id", models.TextField(blank=False)),
(
"requirement_status",
api.db_utils.StatusEnumField(
choices=[
("FAIL", "Fail"),
("PASS", "Pass"),
("MANUAL", "Manual"),
]
),
),
("passed_checks", models.IntegerField(default=0)),
("failed_checks", models.IntegerField(default=0)),
("total_checks", models.IntegerField(default=0)),
(
"scan",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="compliance_requirements_overviews",
related_query_name="compliance_requirements_overview",
to="api.scan",
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "compliance_requirements_overviews",
"abstract": False,
"indexes": [
models.Index(
fields=["tenant_id", "scan_id"], name="cro_tenant_scan_idx"
),
models.Index(
fields=["tenant_id", "scan_id", "compliance_id"],
name="cro_scan_comp_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "compliance_id", "region"],
name="cro_scan_comp_reg_idx",
),
models.Index(
fields=[
"tenant_id",
"scan_id",
"compliance_id",
"requirement_id",
],
name="cro_scan_comp_req_idx",
),
models.Index(
fields=[
"tenant_id",
"scan_id",
"compliance_id",
"requirement_id",
"region",
],
name="cro_scan_comp_req_reg_idx",
),
],
"constraints": [
models.UniqueConstraint(
fields=(
"tenant_id",
"scan_id",
"compliance_id",
"requirement_id",
"region",
),
name="unique_tenant_compliance_requirement_overview",
)
],
},
),
migrations.AddConstraint(
model_name="ComplianceRequirementOverview",
constraint=RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_compliancerequirementoverview",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
@@ -0,0 +1,29 @@
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", "0027_compliance_requirement_overviews"),
]
operations = [
migrations.RunPython(
partial(
create_index_on_partitions,
parent_table="findings",
index_name="find_tenant_scan_check_idx",
columns="tenant_id, scan_id, check_id",
),
reverse_code=partial(
drop_index_on_partitions,
parent_table="findings",
index_name="find_tenant_scan_check_idx",
),
)
]
@@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0028_findings_check_index_partitions"),
]
operations = [
migrations.AddIndex(
model_name="finding",
index=models.Index(
fields=["tenant_id", "scan_id", "check_id"],
name="find_tenant_scan_check_idx",
),
),
]
+78 -1
View File
@@ -242,7 +242,7 @@ class Provider(RowLevelSecurityProtectedModel):
@staticmethod
def validate_kubernetes_uid(value):
if not re.match(
r"^[a-z0-9][A-Za-z0-9_.:\/-]{1,250}$",
r"^[a-zA-Z0-9][a-zA-Z0-9._@:\/-]{1,250}$",
value,
):
raise ModelValidationError(
@@ -762,6 +762,10 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
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"),
models.Index(
fields=["tenant_id", "scan_id", "check_id"],
name="find_tenant_scan_check_idx",
),
]
class JSONAPIMeta:
@@ -850,6 +854,7 @@ class ProviderSecret(RowLevelSecurityProtectedModel):
class TypeChoices(models.TextChoices):
STATIC = "static", _("Key-value pairs")
ROLE = "role", _("Role assumption")
SERVICE_ACCOUNT = "service_account", _("GCP Service Account Key")
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
@@ -1142,6 +1147,78 @@ class ComplianceOverview(RowLevelSecurityProtectedModel):
resource_name = "compliance-overviews"
class ComplianceRequirementOverview(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
compliance_id = models.TextField(blank=False)
framework = models.TextField(blank=False)
version = models.TextField(blank=True)
description = models.TextField(blank=True)
region = models.TextField(blank=False)
requirement_id = models.TextField(blank=False)
requirement_status = StatusEnumField(choices=StatusChoices)
passed_checks = models.IntegerField(default=0)
failed_checks = models.IntegerField(default=0)
total_checks = models.IntegerField(default=0)
scan = models.ForeignKey(
Scan,
on_delete=models.CASCADE,
related_name="compliance_requirements_overviews",
related_query_name="compliance_requirements_overview",
)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "compliance_requirements_overviews"
constraints = [
models.UniqueConstraint(
fields=(
"tenant_id",
"scan_id",
"compliance_id",
"requirement_id",
"region",
),
name="unique_tenant_compliance_requirement_overview",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "DELETE"],
),
]
indexes = [
models.Index(fields=["tenant_id", "scan_id"], name="cro_tenant_scan_idx"),
models.Index(
fields=["tenant_id", "scan_id", "compliance_id"],
name="cro_scan_comp_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "compliance_id", "region"],
name="cro_scan_comp_reg_idx",
),
models.Index(
fields=["tenant_id", "scan_id", "compliance_id", "requirement_id"],
name="cro_scan_comp_req_idx",
),
models.Index(
fields=[
"tenant_id",
"scan_id",
"compliance_id",
"requirement_id",
"region",
],
name="cro_scan_comp_req_reg_idx",
),
]
class JSONAPIMeta:
resource_name = "compliance-requirements-overviews"
class ScanSummary(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
+1 -1
View File
@@ -1,4 +1,4 @@
from rest_framework_json_api.pagination import JsonApiPageNumberPagination
from drf_spectacular_jsonapi.schemas.pagination import JsonApiPageNumberPagination
class ComplianceOverviewPagination(JsonApiPageNumberPagination):
+312 -279
View File
@@ -10,9 +10,7 @@ paths:
/api/v1/compliance-overviews:
get:
operationId: compliance_overviews_list
description: Retrieve an overview of all the compliance in a given scan. If
no region filters are provided, the region with the most fails will be returned
by default.
description: Retrieve an overview of all the compliance in a given scan.
summary: List compliance overviews for a scan
parameters:
- in: query
@@ -22,15 +20,13 @@ paths:
items:
type: string
enum:
- inserted_at
- compliance_id
- id
- framework
- version
- requirements_status
- region
- provider_type
- scan
- url
- requirements_passed
- requirements_failed
- requirements_manual
- total_requirements
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
@@ -74,44 +70,6 @@ paths:
schema:
type: string
format: date-time
- in: query
name: filter[provider_type]
schema:
type: string
enum:
- aws
- azure
- gcp
- kubernetes
- m365
description: |-
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
* `m365` - M365
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
enum:
- aws
- azure
- gcp
- kubernetes
- m365
description: |-
Multiple values may be separated by commas.
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
* `m365` - M365
explode: false
style: form
- in: query
name: filter[region]
schema:
@@ -171,14 +129,8 @@ paths:
items:
type: string
enum:
- inserted_at
- -inserted_at
- compliance_id
- -compliance_id
- framework
- -framework
- region
- -region
explode: false
tags:
- Compliance Overview
@@ -190,41 +142,43 @@ paths:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/PaginatedComplianceOverviewList'
description: ''
/api/v1/compliance-overviews/{id}:
description: Compliance overviews obtained successfully
'202':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/PaginatedTaskList'
description: The task is in progress
'500':
description: Compliance overviews generation task failed
/api/v1/compliance-overviews/attributes:
get:
operationId: compliance_overviews_retrieve
description: Fetch detailed information about a specific compliance overview
by its ID, including detailed requirement information and check's status.
summary: Retrieve data from a specific compliance overview
operationId: compliance_overviews_attributes_retrieve
description: Retrieve detailed attribute information for all requirements in
a specific compliance framework along with the associated check IDs for each
requirement.
summary: Get compliance requirement attributes
parameters:
- in: query
name: fields[compliance-overviews]
name: fields[compliance-requirements-attributes]
schema:
type: array
items:
type: string
enum:
- inserted_at
- compliance_id
- id
- framework
- version
- requirements_status
- region
- provider_type
- scan
- url
- description
- requirements
- attributes
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: path
name: id
- in: query
name: filter[compliance_id]
schema:
type: string
format: uuid
description: A UUID string identifying this compliance overview.
description: Compliance framework ID to get attributes for.
required: true
tags:
- Compliance Overview
@@ -235,8 +189,8 @@ paths:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/ComplianceOverviewFullResponse'
description: ''
$ref: '#/components/schemas/PaginatedComplianceOverviewAttributesList'
description: Compliance attributes obtained successfully
/api/v1/compliance-overviews/metadata:
get:
operationId: compliance_overviews_metadata_retrieve
@@ -271,8 +225,142 @@ paths:
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/ComplianceOverviewMetadataResponse'
description: ''
$ref: '#/components/schemas/OpenApiResponseResponse'
description: Compliance overviews metadata obtained successfully
'202':
description: The task is in progress
'500':
description: Compliance overviews generation task failed
/api/v1/compliance-overviews/requirements:
get:
operationId: compliance_overviews_requirements_retrieve
description: Retrieve a detailed overview of compliance requirements in a given
scan, grouped by compliance framework. This endpoint provides requirement-level
details and aggregates status across regions.
summary: List compliance requirements overview for a scan
parameters:
- in: query
name: fields[compliance-requirements-details]
schema:
type: array
items:
type: string
enum:
- id
- framework
- version
- description
- status
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- in: query
name: filter[compliance_id]
schema:
type: string
description: Compliance ID.
required: true
- in: query
name: filter[compliance_id__icontains]
schema:
type: string
- in: query
name: filter[framework]
schema:
type: string
- in: query
name: filter[framework__icontains]
schema:
type: string
- in: query
name: filter[framework__iexact]
schema:
type: string
- in: query
name: filter[inserted_at]
schema:
type: string
format: date
- in: query
name: filter[inserted_at__date]
schema:
type: string
format: date
- in: query
name: filter[inserted_at__gte]
schema:
type: string
format: date-time
- in: query
name: filter[inserted_at__lte]
schema:
type: string
format: date-time
- in: query
name: filter[region]
schema:
type: string
- in: query
name: filter[region__icontains]
schema:
type: string
- in: query
name: filter[region__in]
schema:
type: array
items:
type: string
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[scan_id]
schema:
type: string
format: uuid
description: Related scan ID.
required: true
- name: filter[search]
required: false
in: query
description: A search term.
schema:
type: string
- in: query
name: filter[version]
schema:
type: string
- in: query
name: filter[version__icontains]
schema:
type: string
- name: sort
required: false
in: query
description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
schema:
type: array
items:
type: string
enum:
- compliance_id
- -compliance_id
explode: false
tags:
- Compliance Overview
security:
- jwtAuth: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/PaginatedComplianceOverviewDetailList'
description: Compliance requirement details obtained successfully
'202':
description: The task is in progress
'500':
description: Compliance overviews generation task failed
/api/v1/findings:
get:
operationId: findings_list
@@ -5376,7 +5464,8 @@ paths:
'403':
description: There is a problem with credentials
'404':
description: The scan has no reports
description: The scan has no reports, or the report generation task has
not started yet
/api/v1/schedules/daily:
post:
operationId: schedules_daily_create
@@ -6838,80 +6927,37 @@ components:
properties:
type:
allOf:
- $ref: '#/components/schemas/Type7f7Enum'
- $ref: '#/components/schemas/ComplianceOverviewTypeEnum'
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
id:
type: string
format: uuid
id: {}
attributes:
type: object
properties:
inserted_at:
id:
type: string
format: date-time
readOnly: true
compliance_id:
type: string
maxLength: 100
framework:
type: string
maxLength: 100
version:
type: string
maxLength: 50
requirements_status:
type: object
properties:
passed:
type: integer
failed:
type: integer
manual:
type: integer
total:
type: integer
readOnly: true
region:
type: string
maxLength: 50
provider_type:
type: string
nullable: true
readOnly: true
requirements_passed:
type: integer
requirements_failed:
type: integer
requirements_manual:
type: integer
total_requirements:
type: integer
required:
- compliance_id
- id
- framework
relationships:
type: object
properties:
scan:
type: object
properties:
data:
type: object
properties:
id:
type: string
format: uuid
type:
type: string
enum:
- scans
title: Resource Type Name
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common
attributes and relationships.
required:
- id
- type
required:
- data
description: The identifier of the related object.
title: Resource Identifier
nullable: true
ComplianceOverviewFull:
- version
- requirements_passed
- requirements_failed
- requirements_manual
- total_requirements
ComplianceOverviewAttributes:
type: object
required:
- type
@@ -6920,134 +6966,78 @@ components:
properties:
type:
allOf:
- $ref: '#/components/schemas/Type7f7Enum'
- $ref: '#/components/schemas/ComplianceOverviewAttributesTypeEnum'
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
id:
type: string
format: uuid
id: {}
attributes:
type: object
properties:
inserted_at:
id:
type: string
format: date-time
readOnly: true
compliance_id:
type: string
maxLength: 100
framework:
type: string
maxLength: 100
version:
type: string
maxLength: 50
requirements_status:
type: object
properties:
passed:
type: integer
failed:
type: integer
manual:
type: integer
total:
type: integer
readOnly: true
region:
type: string
maxLength: 50
provider_type:
type: string
nullable: true
readOnly: true
description:
type: string
requirements:
type: object
properties:
requirement_id:
type: object
properties:
name:
type: string
checks:
type: object
properties:
check_name:
type: object
properties:
status:
type: string
enum:
- PASS
- FAIL
- null
description: Each key in the 'checks' object is a check name,
with values as 'PASS', 'FAIL', or null.
status:
type: string
enum:
- PASS
- FAIL
- MANUAL
attributes:
type: array
items:
type: object
description:
type: string
checks_status:
type: object
properties:
total:
type: integer
pass:
type: integer
fail:
type: integer
manual:
type: integer
readOnly: true
attributes: {}
required:
- compliance_id
- id
- framework
relationships:
- version
- description
- attributes
ComplianceOverviewAttributesTypeEnum:
type: string
enum:
- compliance-requirements-attributes
ComplianceOverviewDetail:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
allOf:
- $ref: '#/components/schemas/ComplianceOverviewDetailTypeEnum'
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
id: {}
attributes:
type: object
properties:
scan:
type: object
properties:
data:
type: object
properties:
id:
type: string
format: uuid
type:
type: string
enum:
- scans
title: Resource Type Name
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common
attributes and relationships.
required:
- id
- type
required:
- data
description: The identifier of the related object.
title: Resource Identifier
nullable: true
ComplianceOverviewFullResponse:
type: object
properties:
data:
$ref: '#/components/schemas/ComplianceOverviewFull'
required:
- data
id:
type: string
framework:
type: string
version:
type: string
description:
type: string
status:
enum:
- FAIL
- PASS
- MANUAL
type: string
description: |-
* `FAIL` - Fail
* `PASS` - Pass
* `MANUAL` - Manual
required:
- id
- framework
- version
- description
- status
ComplianceOverviewDetailTypeEnum:
type: string
enum:
- compliance-requirements-details
ComplianceOverviewMetadata:
type: object
required:
@@ -7071,17 +7061,14 @@ components:
type: string
required:
- regions
ComplianceOverviewMetadataResponse:
type: object
properties:
data:
$ref: '#/components/schemas/ComplianceOverviewMetadata'
required:
- data
ComplianceOverviewMetadataTypeEnum:
type: string
enum:
- compliance-overviews-metadata
ComplianceOverviewTypeEnum:
type: string
enum:
- compliance-overviews
Finding:
type: object
required:
@@ -8385,7 +8372,7 @@ components:
type: object
properties:
data:
$ref: '#/components/schemas/Membership'
$ref: '#/components/schemas/ComplianceOverviewMetadata'
required:
- data
OverviewFinding:
@@ -8600,29 +8587,33 @@ components:
type: string
enum:
- findings-severity-overview
PaginatedComplianceOverviewAttributesList:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/ComplianceOverviewAttributes'
required:
- data
PaginatedComplianceOverviewDetailList:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/ComplianceOverviewDetail'
required:
- data
PaginatedComplianceOverviewList:
type: object
required:
- count
- results
properties:
count:
type: integer
example: 123
next:
type: string
nullable: true
format: uri
example: http://api.example.org/accounts/?page[number]=4
previous:
type: string
nullable: true
format: uri
example: http://api.example.org/accounts/?page[number]=2
results:
data:
type: array
items:
$ref: '#/components/schemas/ComplianceOverview'
required:
- data
PaginatedFindingList:
type: object
properties:
@@ -9136,10 +9127,12 @@ components:
enum:
- static
- role
- service_account
type: string
description: |-
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
readOnly: true
secret:
oneOf:
@@ -9268,6 +9261,14 @@ components:
- client_id
- client_secret
- refresh_token
- type: object
title: GCP Service Account Key
properties:
service_account_key:
type: object
description: The service account key for GCP.
required:
- service_account_key
- type: object
title: Kubernetes Static Credentials
properties:
@@ -10316,10 +10317,12 @@ components:
enum:
- static
- role
- service_account
type: string
description: |-
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
required:
- secret_type
relationships:
@@ -10383,10 +10386,12 @@ components:
enum:
- static
- role
- service_account
type: string
description: |-
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
secret:
oneOf:
- type: object
@@ -10513,6 +10518,14 @@ components:
- client_id
- client_secret
- refresh_token
- type: object
title: GCP Service Account Key
properties:
service_account_key:
type: object
description: The service account key for GCP.
required:
- service_account_key
- type: object
title: Kubernetes Static Credentials
properties:
@@ -10591,10 +10604,12 @@ components:
enum:
- static
- role
- service_account
type: string
description: |-
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
secret:
oneOf:
- type: object
@@ -10722,6 +10737,14 @@ components:
- client_id
- client_secret
- refresh_token
- type: object
title: GCP Service Account Key
properties:
service_account_key:
type: object
description: The service account key for GCP.
required:
- service_account_key
- type: object
title: Kubernetes Static Credentials
properties:
@@ -10816,10 +10839,12 @@ components:
enum:
- static
- role
- service_account
type: string
description: |-
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
readOnly: true
secret:
oneOf:
@@ -10947,6 +10972,14 @@ components:
- client_id
- client_secret
- refresh_token
- type: object
title: GCP Service Account Key
properties:
service_account_key:
type: object
description: The service account key for GCP.
required:
- service_account_key
- type: object
title: Kubernetes Static Credentials
properties:
@@ -11861,6 +11894,7 @@ components:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
@@ -11869,6 +11903,9 @@ components:
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
id:
type: string
format: uuid
attributes:
type: object
properties:
@@ -12283,10 +12320,6 @@ components:
type: string
enum:
- roles
Type7f7Enum:
type: string
enum:
- compliance-overviews
Type8cdEnum:
type: string
enum:
+6 -6
View File
@@ -1,12 +1,12 @@
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
from api.compliance import (
generate_compliance_overview_template,
generate_scan_compliance,
get_prowler_provider_checks,
get_prowler_provider_compliance,
load_prowler_compliance,
load_prowler_checks,
generate_scan_compliance,
generate_compliance_overview_template,
load_prowler_compliance,
)
from api.models import Provider
@@ -69,7 +69,7 @@ class TestCompliance:
load_prowler_compliance()
from api.compliance import PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, PROWLER_CHECKS
from api.compliance import PROWLER_CHECKS, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE
assert PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE == {
"template_key": "template_value"
@@ -268,7 +268,7 @@ class TestCompliance:
"manual": 0,
"total": 0,
},
"status": "PASS",
"status": "MANUAL",
},
},
"requirements_status": {
@@ -3,9 +3,13 @@ from enum import Enum
from unittest.mock import patch
import pytest
from django.conf import settings
from freezegun import freeze_time
from api.db_utils import (
_should_create_index_on_partition,
batch_delete,
create_objects_in_batches,
enum_to_choices,
generate_random_token,
one_week_from_now,
@@ -138,3 +142,88 @@ class TestBatchDelete:
)
assert Provider.objects.all().count() == 0
assert summary == {"api.Provider": create_test_providers}
class TestShouldCreateIndexOnPartition:
@freeze_time("2025-05-15 00:00:00Z")
@pytest.mark.parametrize(
"partition_name, all_partitions, expected",
[
("any_name", True, True),
("findings_default", True, True),
("findings_2022_jan", True, True),
("foo_bar", False, True),
("findings_2025_MAY", False, True),
("findings_2025_may", False, True),
("findings_2025_jun", False, True),
("findings_2025_apr", False, False),
("findings_2025_xyz", False, True),
],
)
def test_partition_inclusion_logic(self, partition_name, all_partitions, expected):
assert (
_should_create_index_on_partition(partition_name, all_partitions)
is expected
)
@freeze_time("2025-05-15 00:00:00Z")
def test_invalid_date_components(self):
# even if regex matches but int conversion fails, we fallback True
# (e.g. year too big, month number parse error)
bad_name = "findings_99999_jan"
assert _should_create_index_on_partition(bad_name, False) is True
bad_name2 = "findings_2025_abc"
# abc not in month_map → fallback True
assert _should_create_index_on_partition(bad_name2, False) is True
@pytest.mark.django_db
class TestCreateObjectsInBatches:
@pytest.fixture
def tenant(self, tenants_fixture):
return tenants_fixture[0]
def make_provider_instances(self, tenant, count):
"""
Return a list of `count` unsaved Provider instances for the given tenant.
"""
base_uid = 1000
return [
Provider(
tenant=tenant,
uid=str(base_uid + i),
provider=Provider.ProviderChoices.AWS,
)
for i in range(count)
]
def test_exact_multiple_of_batch(self, tenant):
total = 6
batch_size = 3
objs = self.make_provider_instances(tenant, total)
create_objects_in_batches(str(tenant.id), Provider, objs, batch_size=batch_size)
qs = Provider.objects.filter(tenant=tenant)
assert qs.count() == total
def test_non_multiple_of_batch(self, tenant):
total = 7
batch_size = 3
objs = self.make_provider_instances(tenant, total)
create_objects_in_batches(str(tenant.id), Provider, objs, batch_size=batch_size)
qs = Provider.objects.filter(tenant=tenant)
assert qs.count() == total
def test_batch_size_default(self, tenant):
default_size = settings.DJANGO_DELETION_BATCH_SIZE
total = default_size + 2
objs = self.make_provider_instances(tenant, total)
create_objects_in_batches(str(tenant.id), Provider, objs)
qs = Provider.objects.filter(tenant=tenant)
assert qs.count() == total
+379
View File
@@ -0,0 +1,379 @@
import json
from uuid import uuid4
import pytest
from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.response import Response
from api.exceptions import (
TaskFailedException,
TaskInProgressException,
TaskNotFoundException,
)
from api.models import Task, User
from api.rls import Tenant
from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin
@pytest.mark.django_db
class TestPaginateByPkMixin:
@pytest.fixture
def tenant(self):
return Tenant.objects.create(name="Test Tenant")
@pytest.fixture
def users(self, tenant):
# Create 5 users with proper email field
users = []
for i in range(5):
user = User.objects.create(email=f"user{i}@example.com", name=f"User {i}")
users.append(user)
return users
class DummyView(PaginateByPkMixin):
def __init__(self, page):
self._page = page
def paginate_queryset(self, qs):
return self._page
def get_serializer(self, queryset, many):
class S:
def __init__(self, data):
# serialize to list of ids
self.data = [obj.id for obj in data] if many else queryset.id
return S(queryset)
def get_paginated_response(self, data):
return Response({"results": data}, status=status.HTTP_200_OK)
def test_no_pagination(self, users):
base_qs = User.objects.all().order_by("id")
view = self.DummyView(page=None)
resp = view.paginate_by_pk(
request=None, base_queryset=base_qs, manager=User.objects
)
# since no pagination, should return all ids in order
expected = [u.id for u in base_qs]
assert isinstance(resp, Response)
assert resp.data == expected
def test_with_pagination(self, users):
base_qs = User.objects.all().order_by("id")
# simulate paging to first 2 ids
page = [base_qs[1].id, base_qs[3].id]
view = self.DummyView(page=page)
resp = view.paginate_by_pk(
request=None, base_queryset=base_qs, manager=User.objects
)
# should fetch only those two users, in the same order as page
assert resp.status_code == status.HTTP_200_OK
assert resp.data == {"results": page}
@pytest.mark.django_db
class TestTaskManagementMixin:
class DummyView(TaskManagementMixin):
pass
@pytest.fixture
def tenant(self):
return Tenant.objects.create(name="Test Tenant")
@pytest.fixture(autouse=True)
def cleanup(self):
Task.objects.all().delete()
TaskResult.objects.all().delete()
def test_no_task_and_no_taskresult_raises_not_found(self):
view = self.DummyView()
with pytest.raises(TaskNotFoundException):
view.check_task_status("task_xyz", {"foo": "bar"})
def test_no_task_and_no_taskresult_returns_none_when_not_raising(self):
view = self.DummyView()
result = view.check_task_status(
"task_xyz", {"foo": "bar"}, raise_on_not_found=False
)
assert result is None
def test_taskresult_pending_raises_in_progress(self):
task_kwargs = {"foo": "bar"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="task_xyz",
task_kwargs=json.dumps(task_kwargs),
status="PENDING",
)
view = self.DummyView()
with pytest.raises(TaskInProgressException) as excinfo:
view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False)
assert hasattr(excinfo.value, "task_result")
assert excinfo.value.task_result == tr
def test_taskresult_started_raises_in_progress(self):
task_kwargs = {"foo": "bar"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="task_xyz",
task_kwargs=json.dumps(task_kwargs),
status="STARTED",
)
view = self.DummyView()
with pytest.raises(TaskInProgressException) as excinfo:
view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False)
assert hasattr(excinfo.value, "task_result")
assert excinfo.value.task_result == tr
def test_taskresult_progress_raises_in_progress(self):
task_kwargs = {"foo": "bar"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="task_xyz",
task_kwargs=json.dumps(task_kwargs),
status="PROGRESS",
)
view = self.DummyView()
with pytest.raises(TaskInProgressException) as excinfo:
view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False)
assert hasattr(excinfo.value, "task_result")
assert excinfo.value.task_result == tr
def test_taskresult_failure_raises_failed(self):
task_kwargs = {"a": 1}
TaskResult.objects.create(
task_id=str(uuid4()),
task_name="task_fail",
task_kwargs=json.dumps(task_kwargs),
status="FAILURE",
)
view = self.DummyView()
with pytest.raises(TaskFailedException):
view.check_task_status("task_fail", task_kwargs, raise_on_not_found=False)
def test_taskresult_failure_returns_none_when_not_raising(self):
task_kwargs = {"a": 1}
TaskResult.objects.create(
task_id=str(uuid4()),
task_name="task_fail",
task_kwargs=json.dumps(task_kwargs),
status="FAILURE",
)
view = self.DummyView()
result = view.check_task_status(
"task_fail", task_kwargs, raise_on_failed=False, raise_on_not_found=False
)
assert result is None
def test_taskresult_success_returns_none(self):
task_kwargs = {"x": 2}
TaskResult.objects.create(
task_id=str(uuid4()),
task_name="task_ok",
task_kwargs=json.dumps(task_kwargs),
status="SUCCESS",
)
view = self.DummyView()
# should not raise, and returns None
assert (
view.check_task_status("task_ok", task_kwargs, raise_on_not_found=False)
is None
)
def test_taskresult_revoked_returns_none(self):
task_kwargs = {"x": 2}
TaskResult.objects.create(
task_id=str(uuid4()),
task_name="task_revoked",
task_kwargs=json.dumps(task_kwargs),
status="REVOKED",
)
view = self.DummyView()
# should not raise, and returns None
assert (
view.check_task_status(
"task_revoked", task_kwargs, raise_on_not_found=False
)
is None
)
def test_task_with_failed_status_raises_failed(self, tenant):
task_kwargs = {"provider_id": "test"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs),
status="FAILURE",
)
task = Task.objects.create(tenant=tenant, task_runner_task=tr)
view = self.DummyView()
with pytest.raises(TaskFailedException) as excinfo:
view.check_task_status("scan_task", task_kwargs)
# Check that the exception contains the expected task
assert hasattr(excinfo.value, "task")
assert excinfo.value.task == task
def test_task_with_cancelled_status_raises_failed(self, tenant):
task_kwargs = {"provider_id": "test"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs),
status="REVOKED",
)
task = Task.objects.create(tenant=tenant, task_runner_task=tr)
view = self.DummyView()
with pytest.raises(TaskFailedException) as excinfo:
view.check_task_status("scan_task", task_kwargs)
# Check that the exception contains the expected task
assert hasattr(excinfo.value, "task")
assert excinfo.value.task == task
def test_task_with_failed_status_returns_task_when_not_raising(self, tenant):
task_kwargs = {"provider_id": "test"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs),
status="FAILURE",
)
task = Task.objects.create(tenant=tenant, task_runner_task=tr)
view = self.DummyView()
result = view.check_task_status("scan_task", task_kwargs, raise_on_failed=False)
assert result == task
def test_task_with_completed_status_returns_none(self, tenant):
task_kwargs = {"provider_id": "test"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs),
status="SUCCESS",
)
Task.objects.create(tenant=tenant, task_runner_task=tr)
view = self.DummyView()
result = view.check_task_status("scan_task", task_kwargs)
assert result is None
def test_task_with_executing_status_returns_task(self, tenant):
task_kwargs = {"provider_id": "test"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs),
status="STARTED",
)
task = Task.objects.create(tenant=tenant, task_runner_task=tr)
view = self.DummyView()
result = view.check_task_status("scan_task", task_kwargs)
assert result is not None
assert result.pk == task.pk
def test_task_with_pending_status_returns_task(self, tenant):
task_kwargs = {"provider_id": "test"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs),
status="PENDING",
)
task = Task.objects.create(tenant=tenant, task_runner_task=tr)
view = self.DummyView()
result = view.check_task_status("scan_task", task_kwargs)
assert result is not None
assert result.pk == task.pk
def test_get_task_response_if_running_returns_none_for_completed_task(self, tenant):
task_kwargs = {"provider_id": "test"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs),
status="SUCCESS",
)
Task.objects.create(tenant=tenant, task_runner_task=tr)
view = self.DummyView()
result = view.get_task_response_if_running("scan_task", task_kwargs)
assert result is None
def test_get_task_response_if_running_returns_none_for_no_task(self):
view = self.DummyView()
result = view.get_task_response_if_running(
"nonexistent", {"foo": "bar"}, raise_on_not_found=False
)
assert result is None
def test_get_task_response_if_running_returns_202_for_executing_task(self, tenant):
task_kwargs = {"provider_id": "test"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs),
status="STARTED",
)
task = Task.objects.create(tenant=tenant, task_runner_task=tr)
view = self.DummyView()
result = view.get_task_response_if_running("scan_task", task_kwargs)
assert isinstance(result, Response)
assert result.status_code == status.HTTP_202_ACCEPTED
assert "Content-Location" in result.headers
# The response should contain the serialized task data
assert result.data is not None
assert "id" in result.data
assert str(result.data["id"]) == str(task.id)
def test_get_task_response_if_running_returns_none_for_available_task(self, tenant):
task_kwargs = {"provider_id": "test"}
tr = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs),
status="PENDING",
)
Task.objects.create(tenant=tenant, task_runner_task=tr)
view = self.DummyView()
result = view.get_task_response_if_running("scan_task", task_kwargs)
# PENDING maps to AVAILABLE, which is not EXECUTING, so should return None
assert result is None
def test_kwargs_filtering_works_correctly(self, tenant):
# Create tasks with different kwargs
task_kwargs_1 = {"provider_id": "test1", "scan_type": "full"}
task_kwargs_2 = {"provider_id": "test2", "scan_type": "quick"}
tr1 = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs_1),
status="STARTED",
)
tr2 = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan_task",
task_kwargs=json.dumps(task_kwargs_2),
status="STARTED",
)
task1 = Task.objects.create(tenant=tenant, task_runner_task=tr1)
task2 = Task.objects.create(tenant=tenant, task_runner_task=tr2)
view = self.DummyView()
# Should find task1 when searching for its kwargs
result1 = view.check_task_status("scan_task", {"provider_id": "test1"})
assert result1 is not None
assert result1.pk == task1.pk
# Should find task2 when searching for its kwargs
result2 = view.check_task_status("scan_task", {"provider_id": "test2"})
assert result2 is not None
assert result2.pk == task2.pk
# Should not find anything when searching for non-existent kwargs
result3 = view.check_task_status(
"scan_task", {"provider_id": "test3"}, raise_on_not_found=False
)
assert result3 is None
+283 -184
View File
@@ -15,10 +15,10 @@ from django.conf import settings
from django.urls import reverse
from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.response import Response
from api.compliance import get_compliance_frameworks
from api.models import (
ComplianceOverview,
Integration,
Invitation,
Membership,
@@ -35,6 +35,7 @@ from api.models import (
UserRoleRelationship,
)
from api.rls import Tenant
from api.v1.views import ComplianceOverviewViewSet
TODAY = str(datetime.today().date())
@@ -914,6 +915,16 @@ class TestProviderViewSet:
"uid": "gke_aaaa-dev_europe-test1_dev-aaaa-test-cluster-long-name-123456789",
"alias": "GKE",
},
{
"provider": "kubernetes",
"uid": "gke_project/cluster-name",
"alias": "GKE",
},
{
"provider": "kubernetes",
"uid": "admin@k8s-demo",
"alias": "test",
},
{
"provider": "azure",
"uid": "8851db6b-42e5-4533-aa9e-30a32d67e875",
@@ -921,7 +932,7 @@ class TestProviderViewSet:
},
{
"provider": "m365",
"uid": "TestingPro.onMirosoft.com",
"uid": "TestingPro.onmicrosoft.com",
"alias": "test",
},
{
@@ -1679,6 +1690,26 @@ class TestProviderSecretViewSet:
"refresh_token": "refresh-token",
},
),
# GCP with Service Account Key secret
(
Provider.ProviderChoices.GCP.value,
ProviderSecret.TypeChoices.SERVICE_ACCOUNT,
{
"service_account_key": {
"type": "service_account",
"project_id": "project-id",
"private_key_id": "private-key-id",
"private_key": "private-key",
"client_email": "client-email",
"client_id": "client-id",
"auth_uri": "auth-uri",
"token_uri": "token-uri",
"auth_provider_x509_cert_url": "auth-provider-x509-cert-url",
"client_x509_cert_url": "client-x509-cert-url",
"universe_domain": "universe-domain",
},
},
),
# Kubernetes with STATIC secret
(
Provider.ProviderChoices.KUBERNETES.value,
@@ -2304,7 +2335,10 @@ class TestScanViewSet:
url = reverse("scan-report", kwargs={"pk": scan.id})
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["errors"]["detail"] == "The scan has no reports."
assert (
response.json()["errors"]["detail"]
== "The scan has no reports, or the report generation task has not started yet."
)
def test_report_s3_no_credentials(
self, authenticated_client, scans_fixture, monkeypatch
@@ -2372,7 +2406,7 @@ class TestScanViewSet:
):
"""
When output_location is a local path and glob.glob returns an empty list,
the view should return HTTP 404 with detail "The scan has no reports."
the view should return HTTP 404 with detail "The scan has no reports, or the report generation task has not started yet."
"""
scan = scans_fixture[0]
scan.output_location = "/tmp/nonexistent_report_pattern.zip"
@@ -2384,7 +2418,10 @@ class TestScanViewSet:
response = authenticated_client.get(url)
assert response.status_code == 404
assert response.json()["errors"]["detail"] == "The scan has no reports."
assert (
response.json()["errors"]["detail"]
== "The scan has no reports, or the report generation task has not started yet."
)
def test_report_local_file(self, authenticated_client, scans_fixture, monkeypatch):
scan = scans_fixture[0]
@@ -2459,7 +2496,10 @@ class TestScanViewSet:
url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework})
resp = authenticated_client.get(url)
assert resp.status_code == status.HTTP_404_NOT_FOUND
assert resp.json()["errors"]["detail"] == "The scan has no reports."
assert (
resp.json()["errors"]["detail"]
== "The scan has no reports, or the report generation task has not started yet."
)
def test_compliance_s3_no_credentials(
self, authenticated_client, scans_fixture, monkeypatch
@@ -2681,7 +2721,10 @@ class TestScanViewSet:
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["errors"]["detail"] == "The scan has no reports."
assert (
response.json()["errors"]["detail"]
== "The scan has no reports, or the report generation task has not started yet."
)
@patch("api.v1.views.get_s3_client")
def test_report_s3_client_error_other(
@@ -4719,210 +4762,266 @@ class TestComplianceOverviewViewSet:
assert len(response.json()["data"]) == 0
def test_compliance_overview_list(
self, authenticated_client, compliance_overviews_fixture
self, authenticated_client, compliance_requirements_overviews_fixture
):
# List compliance overviews with existing data
compliance_overview1, compliance_overview2 = compliance_overviews_fixture
scan_id = str(compliance_overview1.scan.id)
requirement_overview1 = compliance_requirements_overviews_fixture[0]
scan_id = str(requirement_overview1.scan.id)
response = authenticated_client.get(
reverse("complianceoverview-list"),
{"filter[scan_id]": scan_id},
)
assert response.status_code == status.HTTP_200_OK
assert (
len(response.json()["data"]) == 1
) # Due to the custom get_queryset method, only one compliance_id
data = response.json()["data"]
assert len(data) == 2 # Two compliance frameworks
def test_compliance_overview_list_missing_scan_id(self, authenticated_client):
# Attempt to list compliance overviews without providing filter[scan_id]
response = authenticated_client.get(reverse("complianceoverview-list"))
# Check that we get aggregated data for each compliance framework
framework_ids = [item["id"] for item in data]
assert "aws_account_security_onboarding_aws" in framework_ids
assert "cis_1.4_aws" in framework_ids
# Check structure of response
for item in data:
assert "id" in item
assert "attributes" in item
attributes = item["attributes"]
assert "framework" in attributes
assert "version" in attributes
assert "requirements_passed" in attributes
assert "requirements_failed" in attributes
assert "requirements_manual" in attributes
assert "total_requirements" in attributes
def test_compliance_overview_metadata(
self, authenticated_client, compliance_requirements_overviews_fixture
):
requirement_overview1 = compliance_requirements_overviews_fixture[0]
scan_id = str(requirement_overview1.scan.id)
response = authenticated_client.get(
reverse("complianceoverview-metadata"),
{"filter[scan_id]": scan_id},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert "attributes" in data
assert "regions" in data["attributes"]
assert isinstance(data["attributes"]["regions"], list)
def test_compliance_overview_requirements(
self, authenticated_client, compliance_requirements_overviews_fixture
):
requirement_overview1 = compliance_requirements_overviews_fixture[0]
scan_id = str(requirement_overview1.scan.id)
compliance_id = requirement_overview1.compliance_id
response = authenticated_client.get(
reverse("complianceoverview-requirements"),
{
"filter[scan_id]": scan_id,
"filter[compliance_id]": compliance_id,
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
# Check structure of requirements response
for item in data:
assert "id" in item
assert "attributes" in item
attributes = item["attributes"]
assert "framework" in attributes
assert "version" in attributes
assert "description" in attributes
assert "status" in attributes
def test_compliance_overview_requirements_manual(
self, authenticated_client, compliance_requirements_overviews_fixture
):
scan_id = str(compliance_requirements_overviews_fixture[0].scan.id)
# Compliance with a manual requirement
compliance_id = "aws_account_security_onboarding_aws"
response = authenticated_client.get(
reverse("complianceoverview-requirements"),
{
"filter[scan_id]": scan_id,
"filter[compliance_id]": compliance_id,
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert data[-1]["attributes"]["status"] == "MANUAL"
def test_compliance_overview_requirements_missing_scan_id(
self, authenticated_client
):
response = authenticated_client.get(
reverse("complianceoverview-requirements"),
{"filter[compliance_id]": "aws_account_security_onboarding_aws"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["errors"][0]["source"]["pointer"] == "filter[scan_id]"
assert response.json()["errors"][0]["code"] == "required"
def test_compliance_overview_requirements_missing_compliance_id(
self, authenticated_client, compliance_requirements_overviews_fixture
):
requirement_overview1 = compliance_requirements_overviews_fixture[0]
scan_id = str(requirement_overview1.scan.id)
response = authenticated_client.get(
reverse("complianceoverview-requirements"),
{"filter[scan_id]": scan_id},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_compliance_overview_attributes(self, authenticated_client):
response = authenticated_client.get(
reverse("complianceoverview-attributes"),
{"filter[compliance_id]": "aws_account_security_onboarding_aws"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
# Check structure of attributes response
for item in data:
assert "id" in item
assert "attributes" in item
attributes = item["attributes"]
assert "framework" in attributes
assert "version" in attributes
assert "description" in attributes
assert "attributes" in attributes
assert "metadata" in attributes["attributes"]
assert "check_ids" in attributes["attributes"]
def test_compliance_overview_attributes_missing_compliance_id(
self, authenticated_client
):
response = authenticated_client.get(
reverse("complianceoverview-attributes"),
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_compliance_overview_task_management_integration(
self, authenticated_client, compliance_requirements_overviews_fixture
):
"""Test that task management mixin is properly integrated"""
from unittest.mock import patch
requirement_overview1 = compliance_requirements_overviews_fixture[0]
scan_id = str(requirement_overview1.scan.id)
# Mock a running task
with patch.object(
ComplianceOverviewViewSet, "get_task_response_if_running"
) as mock_task_response:
mock_response = Response(
{"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED
)
mock_task_response.return_value = mock_response
response = authenticated_client.get(
reverse("complianceoverview-list"),
{"filter[scan_id]": scan_id},
)
assert response.status_code == status.HTTP_202_ACCEPTED
mock_task_response.assert_called_once()
def test_compliance_overview_task_failed_exception(
self, authenticated_client, compliance_requirements_overviews_fixture
):
"""Test handling of TaskFailedException"""
from unittest.mock import patch
from api.exceptions import TaskFailedException
requirement_overview1 = compliance_requirements_overviews_fixture[0]
scan_id = str(requirement_overview1.scan.id)
# Mock a failed task
with patch.object(
ComplianceOverviewViewSet, "get_task_response_if_running"
) as mock_task_response:
mock_task_response.side_effect = TaskFailedException("Task failed")
response = authenticated_client.get(
reverse("complianceoverview-list"),
{"filter[scan_id]": scan_id},
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert "Task failed to generate compliance overview data" in str(
response.data
)
@pytest.mark.parametrize(
"filter_name, filter_value, expected_count",
"filter_name, filter_value_attr, expected_count_min",
[
("compliance_id", "aws_account_security_onboarding_aws", 1),
("compliance_id.icontains", "security_onboarding", 1),
("framework", "AWS-Account-Security-Onboarding", 1),
("framework.icontains", "security-onboarding", 1),
("version", "1.0", 1),
("version", "2.0", 0),
("version.icontains", "0", 1),
("region", "eu-west-1", 1),
("region.icontains", "west-1", 1),
("region.in", "eu-west-1,eu-west-2", 1),
("inserted_at.date", "2024-01-01", 0),
("inserted_at.date", TODAY, 1),
("inserted_at.gte", "2024-01-01", 1),
("scan_id", "scan.id", 1),
("compliance_id", "compliance_id", 1),
("framework", "framework", 1),
("version", "version", 1),
("region", "region", 1),
],
)
def test_compliance_overview_filters(
self,
authenticated_client,
compliance_overviews_fixture,
compliance_requirements_overviews_fixture,
filter_name,
filter_value,
expected_count,
filter_value_attr,
expected_count_min,
):
# Test filtering compliance overviews
compliance_overview1 = compliance_overviews_fixture[0]
scan_id = str(compliance_overview1.scan.id)
requirement_overview = compliance_requirements_overviews_fixture[0]
scan_id = str(requirement_overview.scan.id)
filter_value = requirement_overview
for attr in filter_value_attr.split("."):
filter_value = getattr(filter_value, attr)
filter_value = str(filter_value)
query_params = {
"filter[scan_id]": scan_id,
f"filter[{filter_name}]": filter_value,
}
if filter_name == "scan_id":
query_params = {"filter[scan_id]": filter_value}
response = authenticated_client.get(
reverse("complianceoverview-list"),
{
"filter[scan_id]": scan_id,
f"filter[{filter_name}]": filter_value,
},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == expected_count
@pytest.mark.parametrize(
"filter_name",
["invalid_filter", "unknown_field"],
)
def test_compliance_overview_filters_invalid(
self, authenticated_client, compliance_overviews_fixture, filter_name
):
# Test handling of invalid filters
compliance_overview1 = compliance_overviews_fixture[0]
scan_id = str(compliance_overview1.scan.id)
response = authenticated_client.get(
reverse("complianceoverview-list"),
{
"filter[scan_id]": scan_id,
f"filter[{filter_name}]": "some_value",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.parametrize(
"sort_field",
["inserted_at", "-inserted_at", "compliance_id", "-compliance_id"],
)
def test_compliance_overview_sort(
self, authenticated_client, compliance_overviews_fixture, sort_field
):
# Test sorting compliance overviews
compliance_overview1 = compliance_overviews_fixture[0]
scan_id = str(compliance_overview1.scan.id)
response = authenticated_client.get(
reverse("complianceoverview-list"),
{
"filter[scan_id]": scan_id,
"sort": sort_field,
},
)
assert response.status_code == status.HTTP_200_OK
def test_compliance_overview_sort_invalid(
self, authenticated_client, compliance_overviews_fixture
):
# Test handling of invalid sort parameters
compliance_overview1 = compliance_overviews_fixture[0]
scan_id = str(compliance_overview1.scan.id)
response = authenticated_client.get(
reverse("complianceoverview-list"),
{
"filter[scan_id]": scan_id,
"sort": "invalid_field",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["errors"][0]["code"] == "invalid"
assert "invalid sort parameter" in response.json()["errors"][0]["detail"]
def test_compliance_overview_retrieve(
self, authenticated_client, compliance_overviews_fixture
):
# Retrieve a specific compliance overview
compliance_overview1 = compliance_overviews_fixture[0]
response = authenticated_client.get(
reverse(
"complianceoverview-detail",
kwargs={"pk": compliance_overview1.id},
),
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert data["id"] == str(compliance_overview1.id)
attributes = data["attributes"]
assert attributes["compliance_id"] == compliance_overview1.compliance_id
assert attributes["framework"] == compliance_overview1.framework
assert attributes["version"] == compliance_overview1.version
assert attributes["region"] == compliance_overview1.region
assert attributes["description"] == compliance_overview1.description
assert "requirements" in attributes
def test_compliance_overview_invalid_retrieve(self, authenticated_client):
# Attempt to retrieve a compliance overview with an invalid ID
response = authenticated_client.get(
reverse(
"complianceoverview-detail",
kwargs={"pk": "invalid-id"},
),
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_compliance_overview_list_queryset(
self, authenticated_client, compliance_overviews_fixture
):
compliance_overview1, compliance_overview2 = compliance_overviews_fixture
scan_id = str(compliance_overview1.scan.id)
response = authenticated_client.get(
reverse("complianceoverview-list"),
{"filter[scan_id]": scan_id},
)
# No filters, most fails should be returned
assert len(response.json()["data"]) == 1
assert response.json()["data"][0]["id"] == str(compliance_overview2.id)
compliance_overview1.requirements_failed = 5
compliance_overview1.save()
response = authenticated_client.get(
reverse("complianceoverview-list"),
{"filter[scan_id]": scan_id},
)
# No filters, now compliance_overview1 has more fails
assert len(response.json()["data"]) == 1
assert response.json()["data"][0]["id"] == str(compliance_overview1.id)
def test_compliance_overview_metadata(
self, authenticated_client, compliance_overviews_fixture
):
response = authenticated_client.get(
reverse("complianceoverview-metadata"),
{"filter[scan_id]": str(compliance_overviews_fixture[0].scan_id)},
)
data = response.json()
expected_regions = set(
ComplianceOverview.objects.all()
.values_list("region", flat=True)
.distinct("region")
query_params,
)
assert response.status_code == status.HTTP_200_OK
assert data["data"]["type"] == "compliance-overviews-metadata"
assert data["data"]["id"] is None
assert set(data["data"]["attributes"]["regions"]) == expected_regions
response_data = response.json()
def test_compliance_overview_metadata_missing_scan_id(self, authenticated_client):
# Attempt to list compliance overviews without providing filter[scan_id]
response = authenticated_client.get(reverse("complianceoverview-metadata"))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["errors"][0]["source"]["pointer"] == "filter[scan_id]"
assert response.json()["errors"][0]["code"] == "required"
assert len(response_data["data"]) >= expected_count_min
if response_data["data"]:
first_item = response_data["data"][0]
assert "id" in first_item
assert "type" in first_item
assert first_item["type"] == "compliance-overviews"
assert "attributes" in first_item
attributes = first_item["attributes"]
assert "framework" in attributes
assert "version" in attributes
assert "requirements_passed" in attributes
assert "requirements_failed" in attributes
assert "requirements_manual" in attributes
assert "total_requirements" in attributes
if filter_name == "compliance_id":
assert first_item["id"] == filter_value
elif filter_name == "framework":
assert attributes["framework"] == filter_value
elif filter_name == "version":
assert attributes["version"] == filter_value
@pytest.mark.django_db
+189
View File
@@ -1,5 +1,16 @@
from django.urls import reverse
from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.response import Response
from api.exceptions import (
TaskFailedException,
TaskInProgressException,
TaskNotFoundException,
)
from api.models import StateChoices, Task
from api.v1.serializers import TaskSerializer
class PaginateByPkMixin:
"""
@@ -31,3 +42,181 @@ class PaginateByPkMixin:
serialized = self.get_serializer(queryset, many=True).data
return self.get_paginated_response(serialized)
class TaskManagementMixin:
"""
Mixin to manage task status checking.
This mixin provides functionality to check if a task with specific parameters
is running, completed, failed, or doesn't exist. It returns the task when running
and raises specific exceptions for failed/not found scenarios that can be handled
at the view level.
"""
def check_task_status(
self,
task_name: str,
task_kwargs: dict,
raise_on_failed: bool = True,
raise_on_not_found: bool = True,
) -> Task | None:
"""
Check the status of a task with given name and kwargs.
This method first checks for a related Task object, and if not found,
checks TaskResult directly. If a TaskResult is found and running but
there's no related Task, it raises TaskInProgressException.
Args:
task_name (str): The name of the task to check
task_kwargs (dict): The kwargs to match against the task
raise_on_failed (bool): Whether to raise exception if task failed
raise_on_not_found (bool): Whether to raise exception if task not found
Returns:
Task | None: The task instance if found (regardless of state), None if not found and raise_on_not_found=False
Raises:
TaskFailedException: If task failed and raise_on_failed=True
TaskNotFoundException: If task not found and raise_on_not_found=True
TaskInProgressException: If task is running but no related Task object exists
"""
# First, try to find a Task object with related TaskResult
try:
# Build the filter for task kwargs
task_filter = {
"task_runner_task__task_name": task_name,
}
# Add kwargs filters - we need to check if the task kwargs contain our parameters
for key, value in task_kwargs.items():
task_filter["task_runner_task__task_kwargs__contains"] = str(value)
task = (
Task.objects.filter(**task_filter)
.select_related("task_runner_task")
.order_by("-inserted_at")
.first()
)
if task:
# Get task state using the same logic as TaskSerializer
task_state_mapping = {
"PENDING": StateChoices.AVAILABLE,
"STARTED": StateChoices.EXECUTING,
"PROGRESS": StateChoices.EXECUTING,
"SUCCESS": StateChoices.COMPLETED,
"FAILURE": StateChoices.FAILED,
"REVOKED": StateChoices.CANCELLED,
}
celery_status = (
task.task_runner_task.status if task.task_runner_task else None
)
task_state = task_state_mapping.get(
celery_status or "", StateChoices.AVAILABLE
)
# Check task state and raise exceptions accordingly
if task_state in (StateChoices.FAILED, StateChoices.CANCELLED):
if raise_on_failed:
raise TaskFailedException(task=task)
return task
elif task_state == StateChoices.COMPLETED:
return None
return task
except Task.DoesNotExist:
pass
# If no Task found, check TaskResult directly
try:
# Build the filter for TaskResult
task_result_filter = {
"task_name": task_name,
}
# Add kwargs filters - check if the task kwargs contain our parameters
for key, value in task_kwargs.items():
task_result_filter["task_kwargs__contains"] = str(value)
task_result = (
TaskResult.objects.filter(**task_result_filter)
.order_by("-date_created")
.first()
)
if task_result:
# Check if the TaskResult indicates a running task
if task_result.status in ["PENDING", "STARTED", "PROGRESS"]:
# Task is running but no related Task object exists
raise TaskInProgressException(task_result=task_result)
elif task_result.status == "FAILURE":
if raise_on_failed:
raise TaskFailedException(task=None)
# For other statuses (SUCCESS, REVOKED), we don't have a Task to return,
# so we treat it as not found
except TaskResult.DoesNotExist:
pass
# No task found at all
if raise_on_not_found:
raise TaskNotFoundException()
return None
def get_task_response_if_running(
self,
task_name: str,
task_kwargs: dict,
raise_on_failed: bool = True,
raise_on_not_found: bool = True,
) -> Response | None:
"""
Get a 202 response with task details if the task is currently running.
This method is useful for endpoints that should return task status when
a background task is in progress, similar to the compliance overview endpoints.
Args:
task_name (str): The name of the task to check
task_kwargs (dict): The kwargs to match against the task
Returns:
Response | None: 202 response with task details if running, None otherwise
"""
task = self.check_task_status(
task_name=task_name,
task_kwargs=task_kwargs,
raise_on_failed=raise_on_failed,
raise_on_not_found=raise_on_not_found,
)
if not task:
return None
# Get task state
task_state_mapping = {
"PENDING": StateChoices.AVAILABLE,
"STARTED": StateChoices.EXECUTING,
"PROGRESS": StateChoices.EXECUTING,
"SUCCESS": StateChoices.COMPLETED,
"FAILURE": StateChoices.FAILED,
"REVOKED": StateChoices.CANCELLED,
}
celery_status = task.task_runner_task.status if task.task_runner_task else None
task_state = task_state_mapping.get(celery_status or "", StateChoices.AVAILABLE)
if task_state == StateChoices.EXECUTING:
self.response_serializer_class = TaskSerializer
serializer = TaskSerializer(task)
return Response(
data=serializer.data,
status=status.HTTP_202_ACCEPTED,
headers={
"Content-Location": reverse("task-detail", kwargs={"pk": task.id})
},
)
@@ -154,6 +154,17 @@ from rest_framework_json_api import serializers
},
"required": ["client_id", "client_secret", "refresh_token"],
},
{
"type": "object",
"title": "GCP Service Account Key",
"properties": {
"service_account_key": {
"type": "object",
"description": "The service account key for GCP.",
}
},
"required": ["service_account_key"],
},
{
"type": "object",
"title": "Kubernetes Static Credentials",
+52 -112
View File
@@ -14,7 +14,6 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.tokens import RefreshToken
from api.models import (
ComplianceOverview,
Finding,
Integration,
IntegrationProviderRelationship,
@@ -31,6 +30,7 @@ from api.models import (
RoleProviderGroupRelationship,
Scan,
StateChoices,
StatusChoices,
Task,
User,
UserRoleRelationship,
@@ -1159,6 +1159,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
)
elif secret_type == ProviderSecret.TypeChoices.ROLE:
serializer = AWSRoleAssumptionProviderSecret(data=secret)
elif secret_type == ProviderSecret.TypeChoices.SERVICE_ACCOUNT:
serializer = GCPServiceAccountProviderSecret(data=secret)
else:
raise serializers.ValidationError(
{"secret_type": f"Secret type not supported: {secret_type}"}
@@ -1212,6 +1214,13 @@ class GCPProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class GCPServiceAccountProviderSecret(serializers.Serializer):
service_account_key = serializers.JSONField()
class Meta:
resource_name = "provider-secrets"
class KubernetesProviderSecret(serializers.Serializer):
kubeconfig_content = serializers.CharField()
@@ -1670,130 +1679,61 @@ class RoleProviderGroupRelationshipSerializer(RLSSerializer, BaseWriteSerializer
# Compliance overview
class ComplianceOverviewSerializer(RLSSerializer):
class ComplianceOverviewSerializer(serializers.Serializer):
"""
Serializer for the ComplianceOverview model.
Serializer for compliance requirement status aggregated by compliance framework.
This serializer is used to format aggregated compliance framework data,
providing counts of passed, failed, and manual requirements along with
an overall global status for each framework.
"""
requirements_status = serializers.SerializerMethodField(
read_only=True, method_name="get_requirements_status"
)
provider_type = serializers.SerializerMethodField(read_only=True)
# Add ID field which will be used for resource identification
id = serializers.CharField()
framework = serializers.CharField()
version = serializers.CharField()
requirements_passed = serializers.IntegerField()
requirements_failed = serializers.IntegerField()
requirements_manual = serializers.IntegerField()
total_requirements = serializers.IntegerField()
class Meta:
model = ComplianceOverview
fields = [
"id",
"inserted_at",
"compliance_id",
"framework",
"version",
"requirements_status",
"region",
"provider_type",
"scan",
"url",
]
@extend_schema_field(
{
"type": "object",
"properties": {
"passed": {"type": "integer"},
"failed": {"type": "integer"},
"manual": {"type": "integer"},
"total": {"type": "integer"},
},
}
)
def get_requirements_status(self, obj):
return {
"passed": obj.requirements_passed,
"failed": obj.requirements_failed,
"manual": obj.requirements_manual,
"total": obj.total_requirements,
}
@extend_schema_field(serializers.CharField(allow_null=True))
def get_provider_type(self, obj):
"""
Retrieves the provider_type from scan.provider.provider_type.
"""
try:
return obj.scan.provider.provider
except AttributeError:
return None
class JSONAPIMeta:
resource_name = "compliance-overviews"
class ComplianceOverviewFullSerializer(ComplianceOverviewSerializer):
requirements = serializers.SerializerMethodField(read_only=True)
class ComplianceOverviewDetailSerializer(serializers.Serializer):
"""
Serializer for detailed compliance requirement information.
class Meta(ComplianceOverviewSerializer.Meta):
fields = ComplianceOverviewSerializer.Meta.fields + [
"description",
"requirements",
]
This serializer formats the aggregated requirement data, showing detailed status
and counts for each requirement across all regions.
"""
@extend_schema_field(
{
"type": "object",
"properties": {
"requirement_id": {
"type": "object",
"properties": {
"name": {"type": "string"},
"checks": {
"type": "object",
"properties": {
"check_name": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["PASS", "FAIL", None],
},
},
}
},
"description": "Each key in the 'checks' object is a check name, with values as "
"'PASS', 'FAIL', or null.",
},
"status": {
"type": "string",
"enum": ["PASS", "FAIL", "MANUAL"],
},
"attributes": {
"type": "array",
"items": {
"type": "object",
},
},
"description": {"type": "string"},
"checks_status": {
"type": "object",
"properties": {
"total": {"type": "integer"},
"pass": {"type": "integer"},
"fail": {"type": "integer"},
"manual": {"type": "integer"},
},
},
},
}
},
}
)
def get_requirements(self, obj):
"""
Returns the detailed structure of requirements.
"""
return obj.requirements
id = serializers.CharField()
framework = serializers.CharField()
version = serializers.CharField()
description = serializers.CharField()
status = serializers.ChoiceField(choices=StatusChoices.choices)
class JSONAPIMeta:
resource_name = "compliance-requirements-details"
class ComplianceOverviewAttributesSerializer(serializers.Serializer):
id = serializers.CharField()
framework = serializers.CharField()
version = serializers.CharField()
description = serializers.CharField()
attributes = serializers.JSONField()
class JSONAPIMeta:
resource_name = "compliance-requirements-attributes"
class ComplianceOverviewMetadataSerializer(serializers.Serializer):
regions = serializers.ListField(child=serializers.CharField(), allow_empty=True)
class Meta:
class JSONAPIMeta:
resource_name = "compliance-overviews-metadata"
+375 -55
View File
@@ -17,7 +17,7 @@ from django.conf import settings as django_settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import SearchQuery
from django.db import transaction
from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Subquery, Sum
from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Sum
from django.db.models.functions import Coalesce
from django.http import HttpResponse
from django.urls import reverse
@@ -26,10 +26,10 @@ from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django_celery_beat.models import PeriodicTask
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
OpenApiTypes,
extend_schema,
extend_schema_view,
)
@@ -58,8 +58,12 @@ from tasks.tasks import (
)
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
from api.compliance import get_compliance_frameworks
from api.compliance import (
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
get_compliance_frameworks,
)
from api.db_router import MainRouter
from api.exceptions import TaskFailedException
from api.filters import (
ComplianceOverviewFilter,
FindingFilter,
@@ -81,6 +85,7 @@ from api.filters import (
)
from api.models import (
ComplianceOverview,
ComplianceRequirementOverview,
Finding,
Integration,
Invitation,
@@ -111,9 +116,10 @@ from api.utils import (
validate_invitation,
)
from api.uuid_utils import datetime_to_uuid7, uuid7_start
from api.v1.mixins import PaginateByPkMixin
from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin
from api.v1.serializers import (
ComplianceOverviewFullSerializer,
ComplianceOverviewAttributesSerializer,
ComplianceOverviewDetailSerializer,
ComplianceOverviewMetadataSerializer,
ComplianceOverviewSerializer,
FindingDynamicFilterSerializer,
@@ -1160,7 +1166,9 @@ class ProviderViewSet(BaseRLSViewSet):
200: OpenApiResponse(description="Report obtained successfully"),
202: OpenApiResponse(description="The task is in progress"),
403: OpenApiResponse(description="There is a problem with credentials"),
404: OpenApiResponse(description="The scan has no reports"),
404: OpenApiResponse(
description="The scan has no reports, or the report generation task has not started yet"
),
},
),
compliance=extend_schema(
@@ -1363,7 +1371,9 @@ class ScanViewSet(BaseRLSViewSet):
code = e.response.get("Error", {}).get("Code")
if code == "NoSuchKey":
return Response(
{"detail": "The scan has no reports."},
{
"detail": "The scan has no reports, or the report generation task has not started yet."
},
status=status.HTTP_404_NOT_FOUND,
)
return Response(
@@ -1376,7 +1386,9 @@ class ScanViewSet(BaseRLSViewSet):
files = glob.glob(path_pattern)
if not files:
return Response(
{"detail": "The scan has no reports."},
{
"detail": "The scan has no reports, or the report generation task has not started yet."
},
status=status.HTTP_404_NOT_FOUND,
)
filepath = files[0]
@@ -1402,7 +1414,10 @@ class ScanViewSet(BaseRLSViewSet):
if not scan.output_location:
return Response(
{"detail": "The scan has no reports."}, status=status.HTTP_404_NOT_FOUND
{
"detail": "The scan has no reports, or the report generation task has not started yet."
},
status=status.HTTP_404_NOT_FOUND,
)
if scan.output_location.startswith("s3://"):
@@ -1440,7 +1455,10 @@ class ScanViewSet(BaseRLSViewSet):
if not scan.output_location:
return Response(
{"detail": "The scan has no reports."}, status=status.HTTP_404_NOT_FOUND
{
"detail": "The scan has no reports, or the report generation task has not started yet."
},
status=status.HTTP_404_NOT_FOUND,
)
if scan.output_location.startswith("s3://"):
@@ -1477,10 +1495,10 @@ class ScanViewSet(BaseRLSViewSet):
},
)
prowler_task = Task.objects.get(id=task.id)
scan.task_id = task.id
scan.save(update_fields=["task_id"])
prowler_task = Task.objects.get(id=task.id)
self.response_serializer_class = TaskSerializer
output_serializer = self.get_serializer(prowler_task)
@@ -2379,8 +2397,7 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
list=extend_schema(
tags=["Compliance Overview"],
summary="List compliance overviews for a scan",
description="Retrieve an overview of all the compliance in a given scan. If no region filters are provided, the"
" region with the most fails will be returned by default.",
description="Retrieve an overview of all the compliance in a given scan.",
parameters=[
OpenApiParameter(
name="filter[scan_id]",
@@ -2390,12 +2407,18 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
description="Related scan ID.",
),
],
),
retrieve=extend_schema(
tags=["Compliance Overview"],
summary="Retrieve data from a specific compliance overview",
description="Fetch detailed information about a specific compliance overview by its ID, including detailed "
"requirement information and check's status.",
responses={
200: OpenApiResponse(
description="Compliance overviews obtained successfully",
response=ComplianceOverviewSerializer(many=True),
),
202: OpenApiResponse(
description="The task is in progress", response=TaskSerializer
),
500: OpenApiResponse(
description="Compliance overviews generation task failed"
),
},
),
metadata=extend_schema(
tags=["Compliance Overview"],
@@ -2411,19 +2434,84 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
description="Related scan ID.",
),
],
responses={
200: OpenApiResponse(
description="Compliance overviews metadata obtained successfully",
response=ComplianceOverviewMetadataSerializer,
),
202: OpenApiResponse(description="The task is in progress"),
500: OpenApiResponse(
description="Compliance overviews generation task failed"
),
},
),
requirements=extend_schema(
tags=["Compliance Overview"],
summary="List compliance requirements overview for a scan",
description="Retrieve a detailed overview of compliance requirements in a given scan, grouped by compliance "
"framework. This endpoint provides requirement-level details and aggregates status across regions.",
parameters=[
OpenApiParameter(
name="filter[scan_id]",
required=True,
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
description="Related scan ID.",
),
OpenApiParameter(
name="filter[compliance_id]",
required=True,
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Compliance ID.",
),
],
responses={
200: OpenApiResponse(
description="Compliance requirement details obtained successfully",
response=ComplianceOverviewDetailSerializer(many=True),
),
202: OpenApiResponse(description="The task is in progress"),
500: OpenApiResponse(
description="Compliance overviews generation task failed"
),
},
filters=True,
),
attributes=extend_schema(
tags=["Compliance Overview"],
summary="Get compliance requirement attributes",
description="Retrieve detailed attribute information for all requirements in a specific compliance framework "
"along with the associated check IDs for each requirement.",
parameters=[
OpenApiParameter(
name="filter[compliance_id]",
required=True,
type=str,
location=OpenApiParameter.QUERY,
description="Compliance framework ID to get attributes for.",
),
],
responses={
200: OpenApiResponse(
description="Compliance attributes obtained successfully",
response=ComplianceOverviewAttributesSerializer(many=True),
),
},
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
class ComplianceOverviewViewSet(BaseRLSViewSet):
@method_decorator(CACHE_DECORATOR, name="requirements")
@method_decorator(CACHE_DECORATOR, name="attributes")
class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
pagination_class = ComplianceOverviewPagination
queryset = ComplianceOverview.objects.all()
queryset = ComplianceRequirementOverview.objects.all()
serializer_class = ComplianceOverviewSerializer
filterset_class = ComplianceOverviewFilter
http_method_names = ["get"]
search_fields = ["compliance_id"]
ordering = ["compliance_id"]
ordering_fields = ["inserted_at", "compliance_id", "framework", "region"]
ordering_fields = ["compliance_id"]
# RBAC required permissions (implicit -> MANAGE_PROVIDERS enable unlimited visibility or check the visibility of
# the provider through the provider group)
required_permissions = []
@@ -2434,51 +2522,44 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
role, Permissions.UNLIMITED_VISIBILITY.value, False
)
if self.action == "retrieve":
if unlimited_visibility:
# User has unlimited visibility, return all compliance
return ComplianceOverview.objects.filter(
tenant_id=self.request.tenant_id
)
providers = get_providers(role)
return ComplianceOverview.objects.filter(
tenant_id=self.request.tenant_id, scan__provider__in=providers
)
if unlimited_visibility:
base_queryset = self.filter_queryset(
ComplianceOverview.objects.filter(tenant_id=self.request.tenant_id)
ComplianceRequirementOverview.objects.filter(
tenant_id=self.request.tenant_id
)
)
else:
providers = Provider.objects.filter(
provider_groups__in=role.provider_groups.all()
).distinct()
base_queryset = self.filter_queryset(
ComplianceOverview.objects.filter(
ComplianceRequirementOverview.objects.filter(
tenant_id=self.request.tenant_id, scan__provider__in=providers
)
)
max_failed_ids = (
base_queryset.filter(compliance_id=OuterRef("compliance_id"))
.order_by("-requirements_failed")
.values("id")[:1]
)
return base_queryset.filter(id__in=Subquery(max_failed_ids)).order_by(
"compliance_id"
)
return base_queryset
def get_serializer_class(self):
if self.action == "retrieve":
return ComplianceOverviewFullSerializer
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
elif self.action == "list":
return ComplianceOverviewSerializer
elif self.action == "metadata":
return ComplianceOverviewMetadataSerializer
elif self.action == "attributes":
return ComplianceOverviewAttributesSerializer
elif self.action == "requirements":
return ComplianceOverviewDetailSerializer
return super().get_serializer_class()
@extend_schema(exclude=True)
def retrieve(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
def list(self, request, *args, **kwargs):
if not request.query_params.get("filter[scan_id]"):
scan_id = request.query_params.get("filter[scan_id]")
if not scan_id:
raise ValidationError(
[
{
@@ -2489,7 +2570,82 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
}
]
)
return super().list(request, *args, **kwargs)
try:
if task := self.get_task_response_if_running(
task_name="scan-compliance-overviews",
task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id},
raise_on_not_found=False,
):
return task
except TaskFailedException:
return Response(
{"detail": "Task failed to generate compliance overview data."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
queryset = self.filter_queryset(self.filter_queryset(self.get_queryset()))
requirement_status_subquery = queryset.values(
"compliance_id", "requirement_id"
).annotate(
fail_count=Count("id", filter=Q(requirement_status="FAIL")),
pass_count=Count("id", filter=Q(requirement_status="PASS")),
total_count=Count("id"),
)
compliance_data = {}
framework_info = {}
for item in queryset.values("compliance_id", "framework", "version").distinct():
framework_info[item["compliance_id"]] = {
"framework": item["framework"],
"version": item["version"],
}
for item in requirement_status_subquery:
compliance_id = item["compliance_id"]
if item["fail_count"] > 0:
req_status = "FAIL"
elif item["pass_count"] == item["total_count"]:
req_status = "PASS"
else:
req_status = "MANUAL"
if compliance_id not in compliance_data:
compliance_data[compliance_id] = {
"total_requirements": 0,
"requirements_passed": 0,
"requirements_failed": 0,
"requirements_manual": 0,
}
compliance_data[compliance_id]["total_requirements"] += 1
if req_status == "PASS":
compliance_data[compliance_id]["requirements_passed"] += 1
elif req_status == "FAIL":
compliance_data[compliance_id]["requirements_failed"] += 1
else:
compliance_data[compliance_id]["requirements_manual"] += 1
response_data = []
for compliance_id, data in compliance_data.items():
framework = framework_info.get(compliance_id, {})
response_data.append(
{
"id": compliance_id,
"compliance_id": compliance_id,
"framework": framework.get("framework", ""),
"version": framework.get("version", ""),
"requirements_passed": data["requirements_passed"],
"requirements_failed": data["requirements_failed"],
"requirements_manual": data["requirements_manual"],
"total_requirements": data["total_requirements"],
}
)
serializer = self.get_serializer(response_data, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], url_name="metadata")
def metadata(self, request):
@@ -2505,11 +2661,21 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
}
]
)
tenant_id = self.request.tenant_id
try:
if task := self.get_task_response_if_running(
task_name="scan-compliance-overviews",
task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id},
raise_on_not_found=False,
):
return task
except TaskFailedException:
return Response(
{"detail": "Task failed to generate compliance overview data."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
regions = list(
ComplianceOverview.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
self.get_queryset()
.filter(scan_id=scan_id)
.values_list("region", flat=True)
.order_by("region")
.distinct()
@@ -2520,6 +2686,160 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
serializer.is_valid(raise_exception=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_name="requirements")
def requirements(self, request):
scan_id = request.query_params.get("filter[scan_id]")
compliance_id = request.query_params.get("filter[compliance_id]")
if not scan_id:
raise ValidationError(
[
{
"detail": "This query parameter is required.",
"status": 400,
"source": {"pointer": "filter[scan_id]"},
"code": "required",
}
]
)
if not compliance_id:
raise ValidationError(
[
{
"detail": "This query parameter is required.",
"status": 400,
"source": {"pointer": "filter[compliance_id]"},
"code": "required",
}
]
)
try:
if task := self.get_task_response_if_running(
task_name="scan-compliance-overviews",
task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id},
raise_on_not_found=False,
):
return task
except TaskFailedException:
return Response(
{"detail": "Task failed to generate compliance overview data."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
filtered_queryset = self.filter_queryset(self.get_queryset())
all_requirements = (
filtered_queryset.values(
"requirement_id", "framework", "version", "description"
)
.distinct()
.annotate(
total_instances=Count("id"),
manual_count=Count("id", filter=Q(requirement_status="MANUAL")),
)
)
passed_instances = (
filtered_queryset.filter(requirement_status="PASS")
.values("requirement_id")
.annotate(pass_count=Count("id"))
)
passed_counts = {
item["requirement_id"]: item["pass_count"] for item in passed_instances
}
requirements_summary = []
for requirement in all_requirements:
requirement_id = requirement["requirement_id"]
total_instances = requirement["total_instances"]
passed_count = passed_counts.get(requirement_id, 0)
is_manual = requirement["manual_count"] == total_instances
if is_manual:
requirement_status = "MANUAL"
elif passed_count == total_instances:
requirement_status = "PASS"
else:
requirement_status = "FAIL"
requirements_summary.append(
{
"id": requirement_id,
"framework": requirement["framework"],
"version": requirement["version"],
"description": requirement["description"],
"status": requirement_status,
}
)
serializer = self.get_serializer(requirements_summary, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_name="attributes")
def attributes(self, request):
compliance_id = request.query_params.get("filter[compliance_id]")
if not compliance_id:
raise ValidationError(
[
{
"detail": "This query parameter is required.",
"status": 400,
"source": {"pointer": "filter[compliance_id]"},
"code": "required",
}
]
)
provider_type = None
try:
sample_requirement = (
self.get_queryset().filter(compliance_id=compliance_id).first()
)
if sample_requirement:
provider_type = sample_requirement.scan.provider.provider
except Exception:
pass
# If we couldn't determine from database, try each provider type
if not provider_type:
for pt in Provider.ProviderChoices.values:
if compliance_id in PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(pt, {}):
provider_type = pt
break
if not provider_type:
raise NotFound(detail=f"Compliance framework '{compliance_id}' not found.")
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(
provider_type, {}
)
compliance_framework = compliance_template.get(compliance_id)
if not compliance_framework:
raise NotFound(detail=f"Compliance framework '{compliance_id}' not found.")
attribute_data = []
for requirement_id, requirement in compliance_framework.get(
"requirements", {}
).items():
check_ids = list(requirement.get("checks", {}).keys())
metadata = requirement.get("attributes", [])
attribute_data.append(
{
"id": requirement_id,
"framework": compliance_framework.get("framework", ""),
"version": compliance_framework.get("version", ""),
"description": requirement.get("description", ""),
"attributes": {"metadata": metadata, "check_ids": check_ids},
}
)
serializer = self.get_serializer(attribute_data, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@extend_schema(tags=["Overview"])
@extend_schema_view(
@@ -2566,7 +2886,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
class OverviewViewSet(BaseRLSViewSet):
queryset = ComplianceOverview.objects.all()
http_method_names = ["get"]
ordering = ["-id"]
ordering = ["-inserted_at"]
# RBAC required permissions (implicit -> MANAGE_PROVIDERS enable unlimited visibility or check the visibility of
# the provider through the provider group)
required_permissions = []
+2
View File
@@ -26,6 +26,7 @@ INSTALLED_APPS = [
"rest_framework",
"corsheaders",
"drf_spectacular",
"drf_spectacular_jsonapi",
"django_guid",
"rest_framework_json_api",
"django_celery_results",
@@ -127,6 +128,7 @@ DJANGO_GUID = {
}
DATABASE_ROUTERS = ["api.db_router.MainRouter"]
POSTGRES_EXTRA_DB_BACKEND_BASE = "database_backend"
# Password validation
+110
View File
@@ -15,6 +15,7 @@ from tasks.jobs.backfill import backfill_resource_scan_summaries
from api.db_utils import rls_transaction
from api.models import (
ComplianceOverview,
ComplianceRequirementOverview,
Finding,
Integration,
IntegrationProviderRelationship,
@@ -29,6 +30,7 @@ from api.models import (
Scan,
ScanSummary,
StateChoices,
StatusChoices,
Task,
User,
UserRoleRelationship,
@@ -777,6 +779,114 @@ def compliance_overviews_fixture(scans_fixture, tenants_fixture):
return compliance_overview1, compliance_overview2
@pytest.fixture
def compliance_requirements_overviews_fixture(scans_fixture, tenants_fixture):
"""Fixture for ComplianceRequirementOverview objects used by the new ComplianceOverviewViewSet."""
tenant = tenants_fixture[0]
scan1, scan2, scan3 = scans_fixture
# Create ComplianceRequirementOverview objects for scan1
requirement_overview1 = ComplianceRequirementOverview.objects.create(
tenant=tenant,
scan=scan1,
compliance_id="aws_account_security_onboarding_aws",
framework="AWS-Account-Security-Onboarding",
version="1.0",
description="Description for AWS Account Security Onboarding",
region="eu-west-1",
requirement_id="requirement1",
requirement_status=StatusChoices.PASS,
passed_checks=2,
failed_checks=0,
total_checks=2,
)
requirement_overview2 = ComplianceRequirementOverview.objects.create(
tenant=tenant,
scan=scan1,
compliance_id="aws_account_security_onboarding_aws",
framework="AWS-Account-Security-Onboarding",
version="1.0",
description="Description for AWS Account Security Onboarding",
region="eu-west-1",
requirement_id="requirement2",
requirement_status=StatusChoices.PASS,
passed_checks=2,
failed_checks=0,
total_checks=2,
)
requirement_overview3 = ComplianceRequirementOverview.objects.create(
tenant=tenant,
scan=scan1,
compliance_id="aws_account_security_onboarding_aws",
framework="AWS-Account-Security-Onboarding",
version="1.0",
description="Description for AWS Account Security Onboarding",
region="eu-west-2",
requirement_id="requirement1",
requirement_status=StatusChoices.PASS,
passed_checks=2,
failed_checks=0,
total_checks=2,
)
requirement_overview4 = ComplianceRequirementOverview.objects.create(
tenant=tenant,
scan=scan1,
compliance_id="aws_account_security_onboarding_aws",
framework="AWS-Account-Security-Onboarding",
version="1.0",
description="Description for AWS Account Security Onboarding",
region="eu-west-2",
requirement_id="requirement2",
requirement_status=StatusChoices.FAIL,
passed_checks=1,
failed_checks=1,
total_checks=2,
)
requirement_overview5 = ComplianceRequirementOverview.objects.create(
tenant=tenant,
scan=scan1,
compliance_id="aws_account_security_onboarding_aws",
framework="AWS-Account-Security-Onboarding",
version="1.0",
description="Description for AWS Account Security Onboarding (MANUAL)",
region="eu-west-2",
requirement_id="requirement3",
requirement_status=StatusChoices.MANUAL,
passed_checks=0,
failed_checks=0,
total_checks=0,
)
# Create a different compliance framework for testing
requirement_overview6 = ComplianceRequirementOverview.objects.create(
tenant=tenant,
scan=scan1,
compliance_id="cis_1.4_aws",
framework="CIS-1.4-AWS",
version="1.4",
description="CIS AWS Foundations Benchmark v1.4.0",
region="eu-west-1",
requirement_id="cis_requirement1",
requirement_status=StatusChoices.FAIL,
passed_checks=0,
failed_checks=3,
total_checks=3,
)
return (
requirement_overview1,
requirement_overview2,
requirement_overview3,
requirement_overview4,
requirement_overview5,
requirement_overview6,
)
def get_api_tokens(
api_client, user_email: str, user_password: str, tenant_id: str = None
) -> tuple[str, str]:
+15
View File
@@ -0,0 +1,15 @@
import django.db
from django.db.backends.postgresql.base import (
DatabaseWrapper as BuiltinPostgresDatabaseWrapper,
)
from psycopg2 import InterfaceError
class DatabaseWrapper(BuiltinPostgresDatabaseWrapper):
def create_cursor(self, name=None):
try:
return super().create_cursor(name=name)
except InterfaceError:
django.db.close_old_connections()
django.db.connection.connect()
return super().create_cursor(name=name)
+120 -71
View File
@@ -13,9 +13,9 @@ from api.compliance import (
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
generate_scan_compliance,
)
from api.db_utils import rls_transaction
from api.db_utils import create_objects_in_batches, rls_transaction
from api.models import (
ComplianceOverview,
ComplianceRequirementOverview,
Finding,
Provider,
Resource,
@@ -119,11 +119,11 @@ def perform_prowler_scan(
ValueError: If the provider cannot be connected.
"""
check_status_by_region = {}
exception = None
unique_resources = set()
scan_resource_cache: set[tuple[str, str, str, str]] = set()
start_time = time.time()
exc = None
with rls_transaction(tenant_id):
provider_instance = Provider.objects.get(pk=provider_id)
@@ -139,7 +139,7 @@ def perform_prowler_scan(
provider_instance.connected = True
except Exception as e:
provider_instance.connected = False
raise ValueError(
exc = ValueError(
f"Provider {provider_instance.provider} is not connected: {e}"
)
finally:
@@ -148,6 +148,11 @@ def perform_prowler_scan(
)
provider_instance.save()
# If the provider is not connected, raise an exception outside the transaction.
# If raised within the transaction, the transaction will be rolled back and the provider will not be marked as not connected.
if exc:
raise exc
prowler_scan = ProwlerScan(provider=prowler_provider, checks=checks_to_execute)
resource_cache = {}
@@ -287,16 +292,6 @@ def perform_prowler_scan(
)
finding_instance.add_resources([resource_instance])
# Update compliance data if applicable
if finding.status.value == "MUTED":
continue
region_dict = check_status_by_region.setdefault(finding.region, {})
current_status = region_dict.get(finding.check_id)
if current_status == "FAIL":
continue
region_dict[finding.check_id] = finding.status.value
# Update scan resource summaries
scan_resource_cache.add(
(
@@ -329,63 +324,6 @@ def perform_prowler_scan(
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(
@@ -564,3 +502,114 @@ def aggregate_findings(tenant_id: str, scan_id: str):
for agg in aggregation
}
ScanSummary.objects.bulk_create(scan_aggregations, batch_size=3000)
def create_compliance_requirements(tenant_id: str, scan_id: str):
"""
Create detailed compliance requirement overview records for a scan.
This function processes the compliance data collected during a scan and creates
individual records for each compliance requirement in each region. These detailed
records provide a granular view of compliance status.
Args:
tenant_id (str): The ID of the tenant for which to create records.
scan_id (str): The ID of the scan for which to create records.
Returns:
dict: A dictionary containing the number of requirements created and the regions processed.
Raises:
ValidationError: If tenant_id is not a valid UUID.
"""
try:
with rls_transaction(tenant_id):
scan_instance = Scan.objects.get(pk=scan_id)
provider_instance = scan_instance.provider
prowler_provider = initialize_prowler_provider(provider_instance)
# Get check status data by region from findings
check_status_by_region = {}
with rls_transaction(tenant_id):
findings = Finding.objects.filter(scan_id=scan_id, muted=False)
for finding in findings:
# Get region from resources
for resource in finding.resources.all():
region = resource.region
region_dict = check_status_by_region.setdefault(region, {})
current_status = region_dict.get(finding.check_id)
if current_status == "FAIL":
continue
region_dict[finding.check_id] = finding.status
try:
# Try to get regions from provider
regions = prowler_provider.get_regions()
except (AttributeError, Exception):
# If not available, use regions from findings
regions = set(check_status_by_region.keys())
# Get compliance template for the provider
compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
provider_instance.provider
]
# Create compliance data by region
compliance_overview_by_region = {
region: deepcopy(compliance_template) for region in regions
}
# Apply check statuses to compliance data
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 requirement objects
compliance_requirement_objects = []
for region, compliance_data in compliance_overview_by_region.items():
for compliance_id, compliance in compliance_data.items():
# Create an overview record for each requirement within each compliance framework
for requirement_id, requirement in compliance["requirements"].items():
compliance_requirement_objects.append(
ComplianceRequirementOverview(
tenant_id=tenant_id,
scan=scan_instance,
region=region,
compliance_id=compliance_id,
framework=compliance["framework"],
version=compliance["version"],
requirement_id=requirement_id,
description=requirement["description"],
passed_checks=requirement["checks_status"]["pass"],
failed_checks=requirement["checks_status"]["fail"],
total_checks=requirement["checks_status"]["total"],
requirement_status=requirement["status"],
)
)
# Bulk create requirement records
create_objects_in_batches(
tenant_id, ComplianceRequirementOverview, compliance_requirement_objects
)
return {
"requirements_created": len(compliance_requirement_objects),
"regions_processed": list(regions),
"compliance_frameworks": (
list(compliance_overview_by_region.get(list(regions)[0], {}).keys())
if regions
else []
),
}
except Exception as e:
logger.error(f"Error creating compliance requirements for scan {scan_id}: {e}")
raise e
+25 -1
View File
@@ -17,7 +17,11 @@ from tasks.jobs.export import (
_generate_output_directory,
_upload_to_s3,
)
from tasks.jobs.scan import aggregate_findings, perform_prowler_scan
from tasks.jobs.scan import (
aggregate_findings,
create_compliance_requirements,
perform_prowler_scan,
)
from tasks.utils import batched, get_next_execution_datetime
from api.compliance import get_compliance_frameworks
@@ -101,6 +105,7 @@ def perform_scan_task(
chain(
perform_scan_summary_task.si(tenant_id, scan_id),
create_compliance_requirements_task.si(tenant_id=tenant_id, scan_id=scan_id),
generate_outputs.si(
scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id
),
@@ -211,6 +216,9 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
chain(
perform_scan_summary_task.si(tenant_id, scan_instance.id),
create_compliance_requirements_task.si(
tenant_id=tenant_id, scan_id=str(scan_instance.id)
),
generate_outputs.si(
scan_id=str(scan_instance.id), provider_id=provider_id, tenant_id=tenant_id
),
@@ -371,3 +379,19 @@ def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str):
scan_id (str): The scan identifier.
"""
return backfill_resource_scan_summaries(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(base=RLSTask, name="scan-compliance-overviews")
def create_compliance_requirements_task(tenant_id: str, scan_id: str):
"""
Creates detailed compliance requirement records for a scan.
This task processes the compliance data collected during a scan and creates
individual records for each compliance requirement in each region. These detailed
records provide a granular view of compliance status.
Args:
tenant_id (str): The tenant ID for which to create records.
scan_id (str): The ID of the scan for which to create records.
"""
return create_compliance_requirements(tenant_id=tenant_id, scan_id=scan_id)
+821 -8
View File
@@ -1,16 +1,19 @@
import json
import uuid
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from tasks.jobs.scan import (
_create_finding_delta,
_store_resources,
create_compliance_requirements,
perform_prowler_scan,
)
from tasks.utils import CustomEncoder
from api.models import (
ComplianceRequirementOverview,
Finding,
Provider,
Resource,
@@ -206,6 +209,10 @@ class TestPerformScan:
scan.refresh_from_db()
assert scan.state == StateChoices.FAILED
provider.refresh_from_db()
assert provider.connected is False
assert isinstance(provider.connection_last_checked_at, datetime)
@pytest.mark.parametrize(
"last_status, new_status, expected_delta",
[
@@ -230,7 +237,7 @@ class TestPerformScan:
):
tenant_id = uuid.uuid4()
provider_instance = MagicMock()
provider_instance.id = "provider456"
provider_instance.id = "provider123"
finding = MagicMock()
finding.resource_uid = "resource_uid_123"
@@ -245,15 +252,16 @@ class TestPerformScan:
resource_instance.region = finding.region
mock_get_or_create_resource.return_value = (resource_instance, True)
tag_instance = MagicMock()
mock_get_or_create_tag.return_value = (tag_instance, True)
resource, resource_uid_tuple = _store_resources(
finding, tenant_id, provider_instance
finding, str(tenant_id), provider_instance
)
mock_get_or_create_resource.assert_called_once_with(
tenant_id=tenant_id,
tenant_id=str(tenant_id),
provider=provider_instance,
uid=finding.resource_uid,
defaults={
@@ -300,11 +308,11 @@ class TestPerformScan:
mock_get_or_create_tag.return_value = (tag_instance, True)
resource, resource_uid_tuple = _store_resources(
finding, tenant_id, provider_instance
finding, str(tenant_id), provider_instance
)
mock_get_or_create_resource.assert_called_once_with(
tenant_id=tenant_id,
tenant_id=str(tenant_id),
provider=provider_instance,
uid=finding.resource_uid,
defaults={
@@ -358,14 +366,14 @@ class TestPerformScan:
]
resource, resource_uid_tuple = _store_resources(
finding, tenant_id, provider_instance
finding, str(tenant_id), provider_instance
)
mock_get_or_create_tag.assert_any_call(
tenant_id=tenant_id, key="tag1", value="value1"
tenant_id=str(tenant_id), key="tag1", value="value1"
)
mock_get_or_create_tag.assert_any_call(
tenant_id=tenant_id, key="tag2", value="value2"
tenant_id=str(tenant_id), key="tag2", value="value2"
)
resource_instance.upsert_or_delete_tags.assert_called_once()
tags_passed = resource_instance.upsert_or_delete_tags.call_args[1]["tags"]
@@ -377,3 +385,808 @@ class TestPerformScan:
# TODO Add tests for aggregations
@pytest.mark.django_db
class TestCreateComplianceRequirements:
def test_create_compliance_requirements_success(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
findings_fixture,
resources_fixture,
):
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template,
patch("tasks.jobs.scan.generate_scan_compliance"),
patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects,
patch("api.models.Finding.objects.filter") as mock_findings_filter,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
scan.provider = provider
scan.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = [
"us-east-1",
"us-west-2",
]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
mock_compliance_template.__getitem__.return_value = {
"cis_1.4_aws": {
"framework": "CIS AWS Foundations Benchmark",
"version": "1.4.0",
"requirements": {
"1.1": {
"description": "Ensure root access key does not exist",
"checks_status": {
"pass": 0,
"fail": 0,
"manual": 0,
"total": 1,
},
"status": "PASS",
},
"1.2": {
"description": "Ensure MFA is enabled for root account",
"checks_status": {
"pass": 0,
"fail": 1,
"manual": 0,
"total": 1,
},
"status": "FAIL",
},
},
},
"aws_account_security_onboarding_aws": {
"framework": "AWS Account Security Onboarding",
"version": "1.0",
"requirements": {
"requirement1": {
"description": "Basic security requirement",
"checks_status": {
"pass": 1,
"fail": 0,
"manual": 0,
"total": 1,
},
"status": "PASS",
},
},
},
}
mock_findings_filter.return_value = []
result = create_compliance_requirements(tenant_id, scan_id)
assert "requirements_created" in result
assert "regions_processed" in result
assert "compliance_frameworks" in result
assert result["regions_processed"] == ["us-east-1", "us-west-2"]
assert result["requirements_created"] == 6
assert len(result["compliance_frameworks"]) == 2
mock_create_objects.assert_called_once()
call_args = mock_create_objects.call_args[0]
assert call_args[0] == tenant_id
assert call_args[1] == ComplianceRequirementOverview
assert len(call_args[2]) == 6
compliance_objects = call_args[2]
for obj in compliance_objects:
assert isinstance(obj, ComplianceRequirementOverview)
assert obj.tenant.id == tenant.id
assert obj.scan == scan
assert obj.region in ["us-east-1", "us-west-2"]
assert obj.compliance_id in [
"cis_1.4_aws",
"aws_account_security_onboarding_aws",
]
def test_create_compliance_requirements_with_findings(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template,
patch(
"tasks.jobs.scan.generate_scan_compliance"
) as mock_generate_compliance,
patch("tasks.jobs.scan.create_objects_in_batches"),
patch("api.models.Finding.objects.filter") as mock_findings_filter,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
scan.provider = provider
scan.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
mock_finding1 = MagicMock()
mock_finding1.check_id = "check1"
mock_finding1.status = "PASS"
mock_resource1 = MagicMock()
mock_resource1.region = "us-east-1"
mock_finding1.resources.all.return_value = [mock_resource1]
mock_finding2 = MagicMock()
mock_finding2.check_id = "check2"
mock_finding2.status = "FAIL"
mock_resource2 = MagicMock()
mock_resource2.region = "us-west-2"
mock_finding2.resources.all.return_value = [mock_resource2]
mock_findings_filter.return_value = [mock_finding1, mock_finding2]
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = [
"us-east-1",
"us-west-2",
]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
mock_compliance_template.__getitem__.return_value = {
"test_compliance": {
"framework": "Test Framework",
"version": "1.0",
"requirements": {
"req_1": {
"description": "Test Requirement 1",
"checks": {"check_1": None},
"checks_status": {
"pass": 2,
"fail": 1,
"manual": 0,
"total": 3,
},
"status": "FAIL",
},
"req_2": {
"description": "Test Requirement 2",
"checks": {"check_2": None},
"checks_status": {
"pass": 2,
"fail": 0,
"manual": 0,
"total": 2,
},
"status": "PASS",
},
},
}
}
result = create_compliance_requirements(tenant_id, scan_id)
mock_findings_filter.assert_called_once_with(scan_id=scan_id, muted=False)
assert mock_generate_compliance.call_count == 2
assert result["requirements_created"] == 4
assert set(result["regions_processed"]) == {"us-east-1", "us-west-2"}
def test_create_compliance_requirements_no_provider_regions(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template,
patch("tasks.jobs.scan.generate_scan_compliance"),
patch("tasks.jobs.scan.create_objects_in_batches"),
patch("api.models.Finding.objects.filter") as mock_findings_filter,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.KUBERNETES
provider.save()
scan.provider = provider
scan.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
mock_finding = MagicMock()
mock_finding.check_id = "check1"
mock_finding.status = "PASS"
mock_resource = MagicMock()
mock_resource.region = "default"
mock_finding.resources.all.return_value = [mock_resource]
mock_findings_filter.return_value = [mock_finding]
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.side_effect = AttributeError(
"No get_regions method"
)
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
mock_compliance_template.__getitem__.return_value = {
"kubernetes_cis": {
"framework": "CIS Kubernetes Benchmark",
"version": "1.6.0",
"requirements": {
"1.1": {
"description": "Test requirement",
"checks_status": {
"pass": 0,
"fail": 0,
"manual": 0,
"total": 1,
},
"status": "PASS",
},
},
},
}
result = create_compliance_requirements(tenant_id, scan_id)
assert result["regions_processed"] == ["default"]
def test_create_compliance_requirements_empty_findings(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template,
patch(
"tasks.jobs.scan.generate_scan_compliance"
) as mock_generate_compliance,
patch("tasks.jobs.scan.create_objects_in_batches"),
patch("api.models.Finding.objects.filter") as mock_findings_filter,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
scan.provider = provider
scan.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
mock_findings_filter.return_value = []
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
mock_compliance_template.__getitem__.return_value = {
"cis_1.4_aws": {
"framework": "CIS AWS Foundations Benchmark",
"version": "1.4.0",
"requirements": {
"1.1": {
"description": "Test requirement",
"checks_status": {
"pass": 0,
"fail": 0,
"manual": 0,
"total": 1,
},
"status": "PASS",
},
},
},
}
mock_findings_filter.return_value = []
result = create_compliance_requirements(tenant_id, scan_id)
assert result["regions_processed"] == ["us-east-1"]
assert result["requirements_created"] == 1
mock_generate_compliance.assert_not_called()
def test_create_compliance_requirements_error_handling(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
scan.provider = provider
scan.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
mock_initialize_prowler_provider.side_effect = Exception(
"Provider initialization failed"
)
with pytest.raises(Exception, match="Provider initialization failed"):
create_compliance_requirements(tenant_id, scan_id)
def test_create_compliance_requirements_muted_findings_excluded(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template,
patch("tasks.jobs.scan.generate_scan_compliance"),
patch("tasks.jobs.scan.create_objects_in_batches"),
patch("api.models.Finding.objects.filter") as mock_findings_filter,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
scan.provider = provider
scan.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
mock_findings_filter.return_value = []
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
mock_compliance_template.__getitem__.return_value = {}
mock_findings_filter.return_value = []
create_compliance_requirements(tenant_id, scan_id)
mock_findings_filter.assert_called_once_with(scan_id=scan_id, muted=False)
def test_create_compliance_requirements_check_status_priority(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template,
patch(
"tasks.jobs.scan.generate_scan_compliance"
) as mock_generate_compliance,
patch("tasks.jobs.scan.create_objects_in_batches"),
patch("api.models.Finding.objects.filter") as mock_findings_filter,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
scan.provider = provider
scan.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
mock_finding1 = MagicMock()
mock_finding1.check_id = "check1"
mock_finding1.status = "PASS"
mock_resource1 = MagicMock()
mock_resource1.region = "us-east-1"
mock_finding1.resources.all.return_value = [mock_resource1]
mock_finding2 = MagicMock()
mock_finding2.check_id = "check1"
mock_finding2.status = "FAIL"
mock_resource2 = MagicMock()
mock_resource2.region = "us-east-1"
mock_finding2.resources.all.return_value = [mock_resource2]
mock_findings_filter.return_value = [mock_finding1, mock_finding2]
mock_prowler_provider_instance = MagicMock()
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
mock_initialize_prowler_provider.return_value = (
mock_prowler_provider_instance
)
mock_compliance_template.__getitem__.return_value = {
"cis_1.4_aws": {
"framework": "CIS AWS Foundations Benchmark",
"version": "1.4.0",
"requirements": {
"1.1": {
"description": "Test requirement",
"checks_status": {
"pass": 0,
"fail": 0,
"manual": 0,
"total": 1,
},
"status": "PASS",
},
},
},
}
create_compliance_requirements(tenant_id, scan_id)
assert mock_generate_compliance.call_count == 1
def test_compliance_overview_aggregation_requirement_fail_priority(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template,
patch(
"tasks.jobs.scan.generate_scan_compliance"
) as mock_generate_compliance,
patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects,
patch("api.models.Finding.objects.filter") as mock_findings_filter,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
providers_fixture[0]
mock_findings_filter.return_value = []
mock_prowler_provider = MagicMock()
mock_prowler_provider.get_regions.return_value = [
"us-east-1",
"us-west-2",
"eu-west-1",
]
mock_initialize_prowler_provider.return_value = mock_prowler_provider
mock_compliance_template.__getitem__.return_value = {
"test_compliance": {
"framework": "Test Framework",
"version": "1.0",
"requirements": {
"req_1": {
"description": "Test Requirement 1",
"checks": {"check_1": None},
"checks_status": {
"pass": 2,
"fail": 1,
"manual": 0,
"total": 3,
},
"status": "FAIL",
}
},
}
}
mock_generate_compliance.return_value = {
"test_compliance": {
"framework": "Test Framework",
"version": "1.0",
"requirements": {
"req_1": {
"description": "Test Requirement 1",
"checks": {
"check_1": {
"us-east-1": {"status": "PASS"},
"us-west-2": {"status": "FAIL"},
"eu-west-1": {"status": "PASS"},
}
},
"checks_status": {
"pass": 2,
"fail": 1,
"manual": 0,
"total": 3,
},
"status": "FAIL",
}
},
}
}
created_objects = []
mock_create_objects.side_effect = (
lambda tenant_id, model, objs, batch_size=500: created_objects.extend(
objs
)
)
create_compliance_requirements(str(tenant.id), str(scan.id))
assert len(created_objects) == 3
assert all(obj.requirement_status == "FAIL" for obj in created_objects)
def test_compliance_overview_aggregation_requirement_pass_all_regions(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template,
patch(
"tasks.jobs.scan.generate_scan_compliance"
) as mock_generate_compliance,
patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects,
patch("api.models.Finding.objects.filter") as mock_findings_filter,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
providers_fixture[0]
mock_findings_filter.return_value = []
mock_prowler_provider = MagicMock()
mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"]
mock_initialize_prowler_provider.return_value = mock_prowler_provider
mock_compliance_template.__getitem__.return_value = {
"test_compliance": {
"framework": "Test Framework",
"version": "1.0",
"requirements": {
"req_1": {
"description": "Test Requirement 1",
"checks": {"check_1": None},
"checks_status": {
"pass": 2,
"fail": 0,
"manual": 0,
"total": 2,
},
"status": "PASS",
}
},
}
}
mock_generate_compliance.return_value = {
"test_compliance": {
"framework": "Test Framework",
"version": "1.0",
"requirements": {
"req_1": {
"description": "Test Requirement 1",
"checks": {
"check_1": {
"us-east-1": {"status": "PASS"},
"us-west-2": {"status": "PASS"},
}
},
"checks_status": {
"pass": 2,
"fail": 0,
"manual": 0,
"total": 2,
},
"status": "PASS",
}
},
}
}
created_objects = []
mock_create_objects.side_effect = (
lambda tenant_id, model, objs, batch_size=500: created_objects.extend(
objs
)
)
create_compliance_requirements(str(tenant.id), str(scan.id))
assert len(created_objects) == 2
assert all(obj.requirement_status == "PASS" for obj in created_objects)
def test_compliance_overview_aggregation_multiple_requirements_mixed_status(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
) as mock_compliance_template,
patch(
"tasks.jobs.scan.generate_scan_compliance"
) as mock_generate_compliance,
patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects,
patch("api.models.Finding.objects.filter") as mock_findings_filter,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
providers_fixture[0]
mock_findings_filter.return_value = []
mock_prowler_provider = MagicMock()
mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"]
mock_initialize_prowler_provider.return_value = mock_prowler_provider
mock_compliance_template.__getitem__.return_value = {
"test_compliance": {
"framework": "Test Framework",
"version": "1.0",
"requirements": {
"req_1": {
"description": "Test Requirement 1",
"checks": {"check_1": None},
"checks_status": {
"pass": 2,
"fail": 0,
"manual": 0,
"total": 2,
},
"status": "PASS",
},
"req_2": {
"description": "Test Requirement 2",
"checks": {"check_2": None},
"checks_status": {
"pass": 1,
"fail": 1,
"manual": 0,
"total": 2,
},
"status": "FAIL",
},
},
}
}
mock_generate_compliance.return_value = {
"test_compliance": {
"framework": "Test Framework",
"version": "1.0",
"requirements": {
"req_1": {
"description": "Test Requirement 1",
"checks": {
"check_1": {
"us-east-1": {"status": "PASS"},
"us-west-2": {"status": "PASS"},
}
},
"checks_status": {
"pass": 2,
"fail": 0,
"manual": 0,
"total": 2,
},
"status": "PASS",
},
"req_2": {
"description": "Test Requirement 2",
"checks": {
"check_2": {
"us-east-1": {"status": "PASS"},
"us-west-2": {"status": "FAIL"},
}
},
"checks_status": {
"pass": 1,
"fail": 1,
"manual": 0,
"total": 2,
},
"status": "FAIL",
},
},
}
}
created_objects = []
mock_create_objects.side_effect = (
lambda tenant_id, model, objs, batch_size=500: created_objects.extend(
objs
)
)
create_compliance_requirements(str(tenant.id), str(scan.id))
assert len(created_objects) == 4
req_1_objects = [
obj for obj in created_objects if obj.requirement_id == "req_1"
]
req_2_objects = [
obj for obj in created_objects if obj.requirement_id == "req_2"
]
assert len(req_1_objects) == 2
assert len(req_2_objects) == 2
assert all(obj.requirement_status == "PASS" for obj in req_1_objects)
assert all(obj.requirement_status == "FAIL" for obj in req_2_objects)
+43
View File
@@ -0,0 +1,43 @@
import warnings
from dashboard.common_methods import get_section_containers_3_levels
warnings.filterwarnings("ignore")
def get_table(data):
data["REQUIREMENTS_DESCRIPTION"] = (
data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"]
)
data["REQUIREMENTS_DESCRIPTION"] = data["REQUIREMENTS_DESCRIPTION"].apply(
lambda x: x[:150] + "..." if len(str(x)) > 150 else x
)
data["REQUIREMENTS_ATTRIBUTES_SECTION"] = data[
"REQUIREMENTS_ATTRIBUTES_SECTION"
].apply(lambda x: x[:80] + "..." if len(str(x)) > 80 else x)
data["REQUIREMENTS_ATTRIBUTES_SUBSECTION"] = data[
"REQUIREMENTS_ATTRIBUTES_SUBSECTION"
].apply(lambda x: x[:150] + "..." if len(str(x)) > 150 else x)
aux = data[
[
"REQUIREMENTS_DESCRIPTION",
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
]
return get_section_containers_3_levels(
aux,
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"REQUIREMENTS_DESCRIPTION",
)
+43
View File
@@ -0,0 +1,43 @@
import warnings
from dashboard.common_methods import get_section_containers_3_levels
warnings.filterwarnings("ignore")
def get_table(data):
data["REQUIREMENTS_DESCRIPTION"] = (
data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"]
)
data["REQUIREMENTS_DESCRIPTION"] = data["REQUIREMENTS_DESCRIPTION"].apply(
lambda x: x[:150] + "..." if len(str(x)) > 150 else x
)
data["REQUIREMENTS_ATTRIBUTES_SECTION"] = data[
"REQUIREMENTS_ATTRIBUTES_SECTION"
].apply(lambda x: x[:80] + "..." if len(str(x)) > 80 else x)
data["REQUIREMENTS_ATTRIBUTES_SUBSECTION"] = data[
"REQUIREMENTS_ATTRIBUTES_SUBSECTION"
].apply(lambda x: x[:150] + "..." if len(str(x)) > 150 else x)
aux = data[
[
"REQUIREMENTS_DESCRIPTION",
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
]
return get_section_containers_3_levels(
aux,
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"REQUIREMENTS_DESCRIPTION",
)
+22 -6
View File
@@ -90,12 +90,28 @@ def create_layout_overview(
),
html.Div(
[
(
html.Label(
"Table Rows:",
className="text-prowler-stone-900 font-bold text-sm",
style={"margin-right": "10px"},
)
html.Label(
"Search:",
className="text-prowler-stone-900 font-bold text-sm",
style={"margin-right": "10px"},
),
dcc.Input(
id="search-input",
type="text",
placeholder="Search by check title, service, region...",
debounce=True,
style={
"padding": "4px 8px",
"border": "1px solid #ccc",
"borderRadius": "4px",
"marginRight": "20px",
"width": "250px",
},
),
html.Label(
"Table Rows:",
className="text-prowler-stone-900 font-bold text-sm",
style={"margin-right": "10px"},
),
table_row_dropdown,
download_button_csv,
+11
View File
@@ -518,6 +518,7 @@ else:
Input("service-filter", "value"),
Input("table-rows", "value"),
Input("status-filter", "value"),
Input("search-input", "value"),
Input("aws_card", "n_clicks"),
Input("azure_card", "n_clicks"),
Input("gcp_card", "n_clicks"),
@@ -540,6 +541,7 @@ def filter_data(
service_values,
table_row_values,
status_values,
search_value,
aws_clicks,
azure_clicks,
gcp_clicks,
@@ -1144,6 +1146,15 @@ def filter_data(
}
index_count = 0
if search_value:
search_value = search_value.lower()
filtered_data = filtered_data[
filtered_data["CHECK_TITLE"].str.lower().str.contains(search_value)
| filtered_data["SERVICE_NAME"].str.lower().str.contains(search_value)
| filtered_data["REGION"].str.lower().str.contains(search_value)
| filtered_data["STATUS"].str.lower().str.contains(search_value)
]
full_filtered_data = filtered_data.copy()
filtered_data = filtered_data.head(table_row_values)
# Sort the filtered_data
+21 -2
View File
@@ -26,6 +26,25 @@ services:
ports:
- ${UI_PORT:-3000}:${UI_PORT:-3000}
postgres-proxy:
image: edoburu/pgbouncer:latest
hostname: "postgres-db-proxy"
environment:
- DB_HOST=postgres-db
- DB_PORT=5432
- DB_USER=${POSTGRES_ADMIN_USER}
- DB_PASSWORD=${POSTGRES_ADMIN_PASSWORD}
- ADMIN_USERS=prowler_admin
- AUTH_TYPE=scram-sha-256
env_file:
- path: ./.env
required: false
ports:
- "5432:5432"
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16.3-alpine3.20
hostname: "postgres-db"
@@ -38,8 +57,8 @@ services:
env_file:
- path: .env
required: false
ports:
- "${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}"
# ports:
# - "${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}"
healthcheck:
test: ["CMD-SHELL", "sh -c 'pg_isready -U ${POSTGRES_ADMIN_USER} -d ${POSTGRES_DB}'"]
interval: 5s
+16 -57
View File
@@ -70,7 +70,7 @@ The other three cases does not need additional configuration, `--az-cli-auth` an
Prowler for Azure needs two types of permission scopes to be set:
- **Microsoft Entra ID permissions**: used to retrieve metadata from the identity assumed by Prowler and specific Entra checks (not mandatory to have access to execute the tool). The permissions required by the tool are the following:
- `Directory.Read.All`
- `Domain.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All` (used only for the Entra checks related with multifactor authentication)
- **Subscription scope permissions**: required to launch the checks against your resources, mandatory to launch the tool. It is required to add the following RBAC builtin roles per subscription to the entity that is going to be assumed by the tool:
@@ -81,6 +81,9 @@ Prowler for Azure needs two types of permission scopes to be set:
To assign the permissions, follow the instructions in the [Microsoft Entra ID permissions](../tutorials/azure/create-prowler-service-principal.md#assigning-the-proper-permissions) section and the [Azure subscriptions permissions](../tutorials/azure/subscriptions.md#assign-the-appropriate-permissions-to-the-identity-that-is-going-to-be-assumed-by-prowler) section, respectively.
???+ warning
Some permissions in `ProwlerRole` are considered **write** permissions, so if you have a `ReadOnly` lock attached to some resources you may get an error and will not get a finding for that check.
#### Checks that require ProwlerRole
The following checks require the `ProwlerRole` permissions to be executed, if you want to run them, make sure you have assigned the role to the identity that is going to be assumed by Prowler:
@@ -160,70 +163,27 @@ export AZURE_CLIENT_ID="XXXXXXXXX"
export AZURE_CLIENT_SECRET="XXXXXXXXX"
export AZURE_TENANT_ID="XXXXXXXXX"
export M365_USER="your_email@example.com"
export M365_PASSWORD="6500780061006d0070006c006500700061007300730077006f0072006400" # replace this to yours
export M365_PASSWORD="examplepassword"
```
These two new environment variables are **required** to execute the PowerShell modules needed to retrieve information from M365 services. Prowler uses Service Principal authentication to access Microsoft Graph and user credentials to authenticate to Microsoft PowerShell modules.
- `M365_USER` should be your Microsoft account email using the default domain. This means it must look like `example@YourCompany.onmicrosoft.com`.
- `M365_USER` should be your Microsoft account email using the **assigned domain in the tenant**. This means it must look like `example@YourCompany.onmicrosoft.com` or `example@YourCompany.com`, but it must be the exact domain assigned to that user in the tenant.
To ensure that you are using the default domain you can see how to verify it [here](../tutorials/microsoft365/getting-started-m365.md#step-1-obtain-your-domain).
???+ warning
The user must not be MFA capable. Microsoft does not allow MFA capable users to authenticate programmatically. See [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity-platform/scenario-desktop-acquire-token-username-password?tabs=dotnet) for more information.
If you don't have a user created with that domain, Prowler will not work as it will not be able to ensure both app an user belong to the same tenant. To proceed, you can either create a new user with that domain or modify the domain of an existing user.
???+ warning
Using a tenant domain other than the one assigned — even if it belongs to the same tenant — will cause Prowler to fail, as Microsoft authentication will not succeed.
Ensure you are using the right domain for the user you are trying to authenticate with.
![User Domains](../tutorials/microsoft365/img/user-domains.png)
- `M365_PASSWORD` must be an encrypted SecureString. To convert your password into a valid encrypted string, you need to use PowerShell.
???+ warning
Passwords encrypted using ConvertTo-SecureString can only be decrypted on the same OS/user context. If you generate an encrypted password on macOS or Linux (both UNIX), it should fail on Windows and vice versa. As Prowler Cloud runs on UNIX if you generate your password using Windows it won't work so you'll need to generate a new password using any UNIX distro (example above)
If you are working from Windows and you will use your encrypted password in a different system (like for example executing Prowler in macOS or adding your password to Prowler Cloud), you will need to generate a "UNIX compatible" version of your encrypted password. This can be done using WSL which is so easy to install on Windows.
=== "UNIX"
Open a PowerShell cmd with a [supported version](requirements.md#supported-powershell-versions) and then run the following command:
```console
$securePassword = ConvertTo-SecureString "examplepassword" -AsPlainText -Force
$encryptedPassword = $securePassword | ConvertFrom-SecureString
Write-Output $encryptedPassword
6500780061006d0070006c006500700061007300730077006f0072006400
```
If everything is done correctly, you will see the encrypted string that you need to set as the `M365_PASSWORD` environment variable.
=== "Windows"
How to install WSL and PowerShell on it to generate that password (you can use a different distro but this one will work for sure):
```console
wsl --install -d Ubuntu-22.04
```
Then, open the Ubuntu terminal and run the following commands:
```console
sudo apt update && sudo apt install -y wget apt-transport-https software-properties-common
wget -q "https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb"
sudo dpkg -i packages-microsoft-prod.deb
sudo apt update
sudo apt install -y powershell
pwsh
```
With this done you will see now that a prompt running PowerShell with the latest version is open so here you will be able to generate your encrypted password:
```console
$securePassword = ConvertTo-SecureString "examplepassword" -AsPlainText -Force
$encryptedPassword = $securePassword | ConvertFrom-SecureString
Write-Output $encryptedPassword
6500780061006d0070006c006500700061007300730077006f0072006400
```
If everything is done correctly, you will see the encrypted string that you need to set as the `M365_PASSWORD` environment variable.
- `M365_PASSWORD` must be the user password.
???+ note
Before we asked for a encrypted password, but now we ask for the user password directly. Prowler will now handle the password encryption for you.
### Interactive Browser authentication
@@ -242,10 +202,9 @@ Since this is a delegated permission authentication method, necessary permission
Prowler for M365 requires two types of permission scopes to be set (if you want to run the full provider including PowerShell checks). Both must be configured using Microsoft Entra ID:
- **Service Principal Application Permissions**: These are set at the **application** level and are used to retrieve data from the identity being assessed:
- `Directory.Read.All`: Required for all services.
- `Domain.Read.All`: Required for all services.
- `Policy.Read.All`: Required for all services.
- `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in.
- `Sites.Read.All`: Required for SharePoint service.
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
- `AuditLog.Read.All`: Required for Entra service.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 347 KiB

After

Width:  |  Height:  |  Size: 119 KiB

@@ -40,7 +40,7 @@ az ad sp create-for-rbac --name "ProwlerApp"
To allow Prowler to retrieve metadata from the identity assumed and run specific Entra checks, it is needed to assign the following permissions:
- `Directory.Read.All`
- `Domain.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All` (used only for the Entra checks related with multifactor authentication)
@@ -58,7 +58,7 @@ To assign the permissions you can make it from the Azure Portal or using the Azu
5. Then click on "+ Add a permission" and select "Microsoft Graph"
6. Once in the "Microsoft Graph" view, select "Application permissions"
7. Finally, search for "Directory", "Policy" and "UserAuthenticationMethod" select the following permissions:
- `Directory.Read.All`
- `Domain.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All`
8. Click on "Add permissions" to apply the new permissions.
@@ -90,7 +90,7 @@ A Service Principal is required to grant Prowler the necessary privileges.
Assign the following Microsoft Graph permissions:
- Directory.Read.All
- Domain.Read.All
- Policy.Read.All
@@ -107,11 +107,11 @@ Assign the following Microsoft Graph permissions:
3. Search and select:
- `Directory.Read.All`
- `Domain.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All`
![Permission Screenshots](./img/directory-permission.png)
![Permission Screenshots](./img/domain-permission.png)
4. Click `Add permissions`, then grant admin consent
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

@@ -4,9 +4,9 @@ Set up your M365 account to enable security scanning using Prowler Cloud/App.
## Requirements
To configure your M365 account, youll need:
To configure your M365 account, you'll need:
1. Obtain your `Default Domain` from the Entra ID portal.
1. Obtain a domain from the Entra ID portal.
2. Access Prowler Cloud/App and add a new cloud provider `Microsoft 365`.
@@ -18,8 +18,6 @@ To configure your M365 account, youll need:
3.3 Assign the required roles to your user.
3.4 Retrieve your encrypted password.
4. Add the credentials to Prowler Cloud/App.
## Step 1: Obtain your Domain
@@ -32,9 +30,7 @@ Go to the Entra ID portal, then you can search for `Domain` or go to Identity >
![Custom Domain Names](./img/custom-domain-names.png)
Once you are there just look for the `Default Domain` this should be something similar to `YourCompany.onmicrosoft.com`. To ensure that you are picking the correct domain just click on it and verify that the type is `Initial` and you can't delete it.
![Search Default Domain](./img/search-default-domain.png)
Once you are there just select the domain you want to use.
---
@@ -78,11 +74,11 @@ A Service Principal is required to grant Prowler the necessary privileges.
![New Registration](./img/new-registration.png)
4. Go to `Certificates & secrets` > `+ New client secret`
4. Go to `Certificates & secrets` > `Client secrets` > `+ New client secret`
![Certificate & Secrets nav](./img/certificates-and-secrets.png)
5. Fill in the required fields and click `Add`, then copy the generated value (that value will be `AZURE_CLIENT_SECRET`)
5. Fill in the required fields and click `Add`, then copy the generated `value` (that value will be `AZURE_CLIENT_SECRET`)
![New Client Secret](./img/new-client-secret.png)
@@ -99,12 +95,11 @@ With this done you will have all the needed keys, summarized in the following ta
### Grant required API permissions
Assign the following Microsoft Graph permissions:
- `Directory.Read.All`: Required for all services.
- `AuditLog.Read.All`: Required for Entra service.
- `Domain.Read.All`: Required for all services.
- `Policy.Read.All`: Required for all services.
- `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in.
- `Sites.Read.All`: Required for SharePoint service.
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
- `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in.
Follow these steps to assign the permissions:
@@ -117,12 +112,12 @@ Follow these steps to assign the permissions:
![Add API Permission](./img/add-app-api-permission.png)
3. Search and select every permission below and once all are selected click on `Add permissions`:
- `Directory.Read.All`
- `AuditLog.Read.All`: Required for Entra service.
- `Domain.Read.All`
- `Policy.Read.All`
- `Sites.Read.All`
- `SharePointTenantSettings.Read.All`
![Permission Screenshots](./img/directory-permission.png)
4. Click `Add permissions`, then grant admin consent
@@ -143,6 +138,10 @@ Follow these steps to assign the permissions:
![Grant Admin Consent](./img/grant-admin-consent-delegated.png)
The final result of permission assignment should be this:
![Final Permission Assignment](./img/final-permissions-m365.png)
---
### Assign required roles to your user
@@ -174,25 +173,20 @@ Follow these steps to assign the role:
---
### Get your encrypted password
For this step you will need to use PowerShell, here you will have to create your Encrypted Password based on the password of the User that you are going to use. For more information about how to generate this Password go [here](../../getting-started/requirements.md#service-principal-and-user-credentials-authentication-recommended) and follow the steps needed to obtain `M365_PASSWORD`.
---
## Step 4: Add credentials to Prowler Cloud/App
1. Go to your App Registration overview and copy the `Client ID` and `Tenant ID`
![App Overview](./img/app-overview.png)
2. Go to Prowler Cloud/App and paste:
- `Client ID`
- `Tenant ID`
- `AZURE_CLIENT_SECRET` from earlier
- `M365_USER` your user using the default domain, more info [here](../../getting-started/requirements.md#service-principal-and-user-credentials-authentication-recommended)
- `M365_PASSWORD` generated before
- `M365_USER` the user using the correct assigned domain, more info [here](../../getting-started/requirements.md#service-principal-and-user-credentials-authentication-recommended)
- `M365_PASSWORD` the password of the user
![Prowler Cloud M365 Credentials](./img/m365-credentials.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 347 KiB

After

Width:  |  Height:  |  Size: 119 KiB

+6
View File
@@ -4,6 +4,9 @@ The **Prowler App** supports multiple users within a single tenant, enabling sea
[Roles](#roles) help you control user permissions, determining what actions each user can perform and the data they can access within Prowler. By default, each account includes an immutable **admin** role, ensuring that your account always retains administrative access.
???+ note
If the account is created without an invitation, a new tenant will be provisioned for it. However, if the account is created through an invitation, the user will join the inviters tenant.
## Membership
To get to User-Invitation Management we will focus on the Membership section.
@@ -156,6 +159,9 @@ Follow these steps to create a role for your account:
<img src="../img/rbac/role_create_1.png" alt="Role parameters" width="700"/>
???+ note
To assign read-only access, select only the `Unlimited Visibility` permission when creating the role. Then, go to the Users page and assign this role to the appropriate user.
#### Editing a Role
Follow these steps to edit a role on your account:
+19 -5
View File
@@ -2,7 +2,7 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.8.0] (Prowler v5.8.0)
## [v5.8.0] (Prowler UNRELEASED)
### Added
- Add CIS 1.11 compliance framework for Kubernetes. [(#7790)](https://github.com/prowler-cloud/prowler/pull/7790)
@@ -19,11 +19,29 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Add `repository_dependency_scanning_enabled` check for GitHub provider. [(#7771)](https://github.com/prowler-cloud/prowler/pull/7771)
- Add `repository_secret_scanning_enabled` check for GitHub provider. [(#7759)](https://github.com/prowler-cloud/prowler/pull/7759)
- Add `repository_default_branch_requires_codeowners_review` check for GitHub provider. [(#7753)](https://github.com/prowler-cloud/prowler/pull/7753)
- Add NIS 2 compliance framework for AWS. [(7839)](https://github.com/prowler-cloud/prowler/pull/7839)
- Add NIS 2 compliance framework for Azure. [(7857)](https://github.com/prowler-cloud/prowler/pull/7857)
- Add search bar in Dashboard Overview page. [(#7804)](https://github.com/prowler-cloud/prowler/pull/7804)
---
## [v5.7.3] (Prowler v5.7.3)
### Fixed
- Automatically encrypt password in Microsoft365 provider. [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784)
- Remove last encrypted password appearances. [(#7825)](https://github.com/prowler-cloud/prowler/pull/7825)
---
## [v5.7.2] (Prowler v5.7.2)
### Fixed
- Fix `m365_powershell test_credentials` to use sanitized credentials. [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761)
- Fix `admincenter_users_admins_reduced_license_footprint` check logic to pass when admin user has no license. [(#7779)](https://github.com/prowler-cloud/prowler/pull/7779)
- Fix `m365_powershell` to close the PowerShell sessions in msgraph services. [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816)
- Fix `defender_ensure_notify_alerts_severity_is_high`check to accept high or lower severity. [(#7862)](https://github.com/prowler-cloud/prowler/pull/7862)
- Replace `Directory.Read.All` permission with `Domain.Read.All` which is more restrictive. [(#7888)](https://github.com/prowler-cloud/prowler/pull/7888)
- Split calls to list Azure Functions attributes. [(#7778)](https://github.com/prowler-cloud/prowler/pull/7778)
---
@@ -52,14 +70,12 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Update and upgrade CIS for all the providers [(#7738)](https://github.com/prowler-cloud/prowler/pull/7738)
- Cover policies with conditions with SNS endpoint in `sns_topics_not_publicly_accessible`. [(#7750)](https://github.com/prowler-cloud/prowler/pull/7750)
- Change severity logic for `ec2_securitygroup_allow_ingress_from_internet_to_all_ports` check. [(#7764)](https://github.com/prowler-cloud/prowler/pull/7764)
- Automatically encrypt password in Microsoft365 provider. [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784)
---
## [v5.6.0] (Prowler v5.6.0)
### Added
- Add SOC2 compliance framework to Azure. [(#7489)](https://github.com/prowler-cloud/prowler/pull/7489)
- Add check for unused Service Accounts in GCP. [(#7419)](https://github.com/prowler-cloud/prowler/pull/7419)
- Add Powershell to Microsoft365. [(#7331)](https://github.com/prowler-cloud/prowler/pull/7331)
@@ -109,7 +125,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Add Microsoft User and User Credential auth to reports [(#7681)](https://github.com/prowler-cloud/prowler/pull/7681)
### Fixed
- Fix package name location in pyproject.toml while replicating for prowler-cloud. [(#7531)](https://github.com/prowler-cloud/prowler/pull/7531)
- Remove cache in PyPI release action. [(#7532)](https://github.com/prowler-cloud/prowler/pull/7532)
- Add the correct values for logger.info inside iam service. [(#7526)](https://github.com/prowler-cloud/prowler/pull/7526)
@@ -130,7 +145,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [v5.5.1] (Prowler v5.5.1)
### Fixed
- Add default name to contacts in Azure Defender. [(#7483)](https://github.com/prowler-cloud/prowler/pull/7483)
- Handle projects without ID in GCP. [(#7496)](https://github.com/prowler-cloud/prowler/pull/7496)
- Restore packages location in PyProject. [(#7510)](https://github.com/prowler-cloud/prowler/pull/7510)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -329,9 +329,8 @@ class CheckMetadata(BaseModel):
checks = set()
if service:
# This is a special case for the AWS provider since `lambda` is a reserved keyword in Python
if service == "awslambda":
service = "lambda"
if service == "lambda":
service = "awslambda"
checks = {
check_name
for check_name, check_metadata in bulk_checks_metadata.items()
@@ -3,7 +3,7 @@
"CheckID": "vpc_endpoint_for_ec2_enabled",
"CheckTitle": "Amazon EC2 should be configured to use VPC endpoints that are created for the Amazon EC2 service.",
"CheckType": [],
"ServiceName": "ec2",
"ServiceName": "vpc",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"Severity": "medium",
@@ -11,19 +11,20 @@ class app_function_access_keys_configured(Check):
functions,
) in app_client.functions.items():
for function in functions.values():
report = Check_Report_Azure(metadata=self.metadata(), resource=function)
report.subscription = subscription_name
report.status = "FAIL"
report.status_extended = (
f"Function {function.name} does not have function keys configured."
)
if len(function.function_keys) > 0:
report.status = "PASS"
report.status_extended = (
f"Function {function.name} has function keys configured."
if function.function_keys is not None:
report = Check_Report_Azure(
metadata=self.metadata(), resource=function
)
report.subscription = subscription_name
report.status = "FAIL"
report.status_extended = f"Function {function.name} does not have function keys configured."
findings.append(report)
if len(function.function_keys) > 0:
report.status = "PASS"
report.status_extended = (
f"Function {function.name} has function keys configured."
)
findings.append(report)
return findings
@@ -14,26 +14,29 @@ class app_function_application_insights_enabled(Check):
functions,
) in app_client.functions.items():
for function in functions.values():
report = Check_Report_Azure(metadata=self.metadata(), resource=function)
report.subscription = subscription_name
report.status = "FAIL"
report.status_extended = (
f"Function {function.name} is not using Application Insights."
)
if function.enviroment_variables.get(
"APPINSIGHTS_INSTRUMENTATIONKEY", ""
) in [
component.instrumentation_key
for component in appinsights_client.components[
subscription_name
].values()
]:
report.status = "PASS"
if function.enviroment_variables is not None:
report = Check_Report_Azure(
metadata=self.metadata(), resource=function
)
report.subscription = subscription_name
report.status = "FAIL"
report.status_extended = (
f"Function {function.name} is using Application Insights."
f"Function {function.name} is not using Application Insights."
)
findings.append(report)
if function.enviroment_variables.get(
"APPINSIGHTS_INSTRUMENTATIONKEY", ""
) in [
component.instrumentation_key
for component in appinsights_client.components[
subscription_name
].values()
]:
report.status = "PASS"
report.status_extended = (
f"Function {function.name} is using Application Insights."
)
findings.append(report)
return findings
@@ -11,20 +11,25 @@ class app_function_latest_runtime_version(Check):
functions,
) in app_client.functions.items():
for function in functions.values():
report = Check_Report_Azure(metadata=self.metadata(), resource=function)
report.subscription = subscription_name
report.status = "PASS"
report.status_extended = (
f"Function {function.name} is using the latest runtime."
)
if function.enviroment_variables is not None:
report = Check_Report_Azure(
metadata=self.metadata(), resource=function
)
report.subscription = subscription_name
report.status = "PASS"
report.status_extended = (
f"Function {function.name} is using the latest runtime."
)
if (
function.enviroment_variables.get("FUNCTIONS_EXTENSION_VERSION", "")
!= "~4"
):
report.status = "FAIL"
report.status_extended = f"Function {function.name} is not using the latest runtime. The current runtime is '{function.enviroment_variables.get('FUNCTIONS_EXTENSION_VERSION', '')}' and should be '~4'."
if (
function.enviroment_variables.get(
"FUNCTIONS_EXTENSION_VERSION", ""
)
!= "~4"
):
report.status = "FAIL"
report.status_extended = f"Function {function.name} is not using the latest runtime. The current runtime is '{function.enviroment_variables.get('FUNCTIONS_EXTENSION_VERSION', '')}' and should be '~4'."
findings.append(report)
findings.append(report)
return findings
@@ -1,5 +1,5 @@
from dataclasses import dataclass, field
from typing import Dict, List
from typing import Dict, List, Optional
from azure.mgmt.web import WebSiteManagementClient
@@ -124,14 +124,16 @@ class App(AzureService):
# Filter function apps
if getattr(function, "kind", "").startswith("functionapp"):
# List host keys
host_keys = client.web_apps.list_host_keys(
resource_group_name=function.resource_group,
name=function.name,
) # Need to add role 'Logic App Contributor' to the service principal to get the host keys or add to the reader role the permission 'Microsoft.Web/sites/host/listkeys'
host_keys = self._get_function_host_keys(
subscription_name, function.resource_group, function.name
)
if host_keys is not None:
function_keys = getattr(host_keys, "function_keys", {})
else:
function_keys = None
function_config = client.web_apps.get_configuration(
resource_group_name=function.resource_group,
name=function.name,
function_config = self._get_function_config(
subscription_name, function.resource_group, function.name
)
functions[subscription_name].update(
@@ -141,16 +143,9 @@ class App(AzureService):
name=function.name,
location=function.location,
kind=function.kind,
function_keys=getattr(
host_keys, "function_keys", {}
),
function_keys=function_keys,
enviroment_variables=getattr(
client.web_apps.list_application_settings(
resource_group_name=function.resource_group,
name=function.name,
),
"properties",
{},
function_config, "properties", None
),
identity=getattr(function, "identity", None),
public_access=(
@@ -167,7 +162,7 @@ class App(AzureService):
"",
),
ftps_state=getattr(
function_config, "ftps_state", ""
function_config, "ftps_state", None
),
)
}
@@ -209,6 +204,30 @@ class App(AzureService):
)
return monitor_diagnostics_settings
def _get_function_host_keys(self, subscription, resource_group, name):
try:
return self.clients[subscription].web_apps.list_host_keys(
resource_group_name=resource_group,
name=name,
)
except Exception as error:
logger.error(
f"Error getting host keys for {name} in {resource_group}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
def _get_function_config(self, subscription, resource_group, name):
try:
return self.clients[subscription].web_apps.list_application_settings(
resource_group_name=resource_group,
name=name,
)
except Exception as error:
logger.error(
f"Error getting configuration for {name} in {resource_group}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
@dataclass
class ManagedServiceIdentity:
@@ -250,9 +269,9 @@ class FunctionApp:
name: str
location: str
kind: str
function_keys: Dict[str, str]
enviroment_variables: Dict[str, str]
function_keys: Optional[Dict[str, str]]
enviroment_variables: Optional[Dict[str, str]]
identity: ManagedServiceIdentity
public_access: bool
vnet_subnet_id: str
ftps_state: str
ftps_state: Optional[str]
@@ -1,16 +1,16 @@
{
"Provider": "azure",
"CheckID": "defender_ensure_notify_alerts_severity_is_high",
"CheckTitle": "Ensure That 'Notify about alerts with the following severity' is Set to 'High'",
"CheckTitle": "Ensure that email notifications are configured for alerts with a minimum severity of 'High' or lower",
"CheckType": [],
"ServiceName": "defender",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "AzureEmailNotifications",
"Description": "Microsoft Defender for Cloud emails the subscription owners whenever a high-severity alert is triggered for their subscription. You should provide a security contact email address as an additional email address.",
"Risk": "Microsoft Defender for Cloud emails the Subscription Owner to notify them about security alerts. Adding your Security Contact's email address to the 'Additional email addresses' field ensures that your organization's Security Team is included in these alerts. This ensures that the proper people are aware of any potential compromise in order to mitigate the risk in a timely fashion.",
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details",
"Description": "Microsoft Defender for Cloud sends email notifications when alerts of a certain severity level or higher are triggered. By setting the minimum severity to 'High', 'Medium', or even 'Low', you ensure that alerts with equal or greater severity (e.g., High or Critical) are still delivered. Selecting a lower threshold like 'Low' results in more comprehensive alert coverage.",
"Risk": "If this setting is too restrictive (e.g., set to 'Critical' only), important security alerts with 'High' or 'Medium' severity might be missed. Ensuring that 'High' or a lower threshold is configured helps security teams stay informed about significant threats and respond in a timely manner.",
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/email-notifications-alerts#manage-notifications-on-email",
"Remediation": {
"Code": {
"CLI": "",
@@ -19,7 +19,7 @@
"Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/bc_azr_general_4#terraform"
},
"Recommendation": {
"Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Defender for Cloud 3. Click on Environment Settings 4. Click on the appropriate Management Group, Subscription, or Workspace 5. Click on Email notifications 6. Enter a valid security contact email address (or multiple addresses separated by commas) in the Additional email addresses field 7. Click Save",
"Text": "1. From Azure Home select the Portal Menu. 2. Select Microsoft Defender for Cloud. 3. Click on Environment Settings. 4. Click on the appropriate Management Group, Subscription, or Workspace. 5. Click on Email notifications. 6. Under 'Notify about alerts with the following severity (or higher)', select at least 'High' (or optionally 'Medium' or 'Low' for broader coverage). 7. Click Save.",
"Url": "https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list"
}
},
@@ -13,12 +13,15 @@ class defender_ensure_notify_alerts_severity_is_high(Check):
for contact in security_contacts.values():
report = Check_Report_Azure(metadata=self.metadata(), resource=contact)
report.subscription = subscription_name
report.status = "PASS"
report.status_extended = f"Notifiy alerts are enabled for severity high in subscription {subscription_name}."
report.status = "FAIL"
report.status_extended = f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {subscription_name}."
if contact.alert_notifications_minimal_severity != "High":
report.status = "FAIL"
report.status_extended = f"Notifiy alerts are not enabled for severity high in subscription {subscription_name}."
if (
contact.alert_notifications_minimal_severity != "Critical"
and contact.alert_notifications_minimal_severity != ""
):
report.status = "PASS"
report.status_extended = f"Notifications are enabled for alerts with a minimum severity of high or lower ({contact.alert_notifications_minimal_severity}) in subscription {subscription_name}."
findings.append(report)
@@ -12,7 +12,7 @@ class sqlserver_auditing_enabled(Check):
)
report.subscription = subscription
report.status = "PASS"
report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has a auditing policy configured."
report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has an auditing policy configured."
for auditing_policy in sql_server.auditing_policies:
if auditing_policy.state == "Disabled":
report.status = "FAIL"
+3 -3
View File
@@ -515,9 +515,9 @@ class GcpProvider(Provider):
credentials=session,
)
# Test the connection using the Service Usage API since it is enabled by default
client = discovery.build("serviceusage", "v1", credentials=session)
request = client.services().list(parent=f"projects/{project_id}")
# Test the connection using OAuth2 API to verify token validity
client = discovery.build("oauth2", "v2", credentials=session)
request = client.tokeninfo()
request.execute()
return Connection(is_connected=True)
@@ -24,15 +24,11 @@ class repository_branch_delete_on_merge_enabled(Check):
for repo in repository_client.repositories.values():
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = (
f"Repository {repo.name} does not delete branches on merge."
)
report.status_extended = f"Repository {repo.name} does not delete branches on merge in default branch ({repo.default_branch.name})."
if repo.delete_branch_on_merge:
report.status = "PASS"
report.status_extended = (
f"Repository {repo.name} does delete branches on merge."
)
report.status_extended = f"Repository {repo.name} does delete branches on merge in default branch ({repo.default_branch.name})."
findings.append(report)
@@ -22,14 +22,14 @@ class repository_default_branch_deletion_disabled(Check):
"""
findings = []
for repo in repository_client.repositories.values():
if repo.default_branch_deletion is not None:
if repo.default_branch.branch_deletion is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = (
f"Repository {repo.name} does allow default branch deletion."
)
if not repo.default_branch_deletion:
if not repo.default_branch.branch_deletion:
report.status = "PASS"
report.status_extended = (
f"Repository {repo.name} does deny default branch deletion."
@@ -22,18 +22,14 @@ class repository_default_branch_disallows_force_push(Check):
"""
findings = []
for repo in repository_client.repositories.values():
if repo.allow_force_pushes is not None:
if repo.default_branch.allow_force_pushes is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = (
f"Repository {repo.name} does allow force push."
)
report.status_extended = f"Repository {repo.name} does allow force pushes on default branch ({repo.default_branch.name})."
if not repo.allow_force_pushes:
if not repo.default_branch.allow_force_pushes:
report.status = "PASS"
report.status_extended = (
f"Repository {repo.name} does deny force push."
)
report.status_extended = f"Repository {repo.name} does deny force pushes on default branch ({repo.default_branch.name})."
findings.append(report)
@@ -22,12 +22,12 @@ class repository_default_branch_protection_applies_to_admins(Check):
"""
findings = []
for repo in repository_client.repositories.values():
if repo.enforce_admins is not None:
if repo.default_branch.enforce_admins is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not enforce administrators to be subject to the same branch protection rules as other users."
if repo.enforce_admins:
if repo.default_branch.enforce_admins:
report.status = "PASS"
report.status_extended = f"Repository {repo.name} does enforce administrators to be subject to the same branch protection rules as other users."
@@ -22,14 +22,14 @@ class repository_default_branch_protection_enabled(Check):
"""
findings = []
for repo in repository_client.repositories.values():
if repo.default_branch_protection is not None:
if repo.default_branch.protected is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not enforce branch protection on default branch ({repo.default_branch})."
report.status_extended = f"Repository {repo.name} does not enforce branch protection on default branch ({repo.default_branch.name})."
if repo.default_branch_protection:
if repo.default_branch.protected:
report.status = "PASS"
report.status_extended = f"Repository {repo.name} does enforce branch protection on default branch ({repo.default_branch})."
report.status_extended = f"Repository {repo.name} does enforce branch protection on default branch ({repo.default_branch.name})."
findings.append(report)
@@ -22,9 +22,9 @@ class repository_default_branch_requires_codeowners_review(Check):
"""
findings = []
for repo in repository_client.repositories.values():
if repo.require_code_owner_reviews is not None:
if repo.default_branch.require_code_owner_reviews is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
if repo.require_code_owner_reviews:
if repo.default_branch.require_code_owner_reviews:
report.status = "PASS"
report.status_extended = f"Repository {repo.name} requires code owner approval for changes to owned code."
else:
@@ -22,18 +22,14 @@ class repository_default_branch_requires_conversation_resolution(Check):
"""
findings = []
for repo in repository_client.repositories.values():
if repo.conversation_resolution is not None:
if repo.default_branch.conversation_resolution is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = (
f"Repository {repo.name} does not require conversation resolution."
)
report.status_extended = f"Repository {repo.name} does not require conversation resolution on default branch ({repo.default_branch.name})."
if repo.conversation_resolution:
if repo.default_branch.conversation_resolution:
report.status = "PASS"
report.status_extended = (
f"Repository {repo.name} does require conversation resolution."
)
report.status_extended = f"Repository {repo.name} does require conversation resolution on default branch ({repo.default_branch.name})."
findings.append(report)
@@ -22,14 +22,14 @@ class repository_default_branch_requires_linear_history(Check):
"""
findings = []
for repo in repository_client.repositories.values():
if repo.required_linear_history is not None:
if repo.default_branch.required_linear_history is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not require linear history on default branch ({repo.default_branch})."
report.status_extended = f"Repository {repo.name} does not require linear history on default branch ({repo.default_branch.name})."
if repo.required_linear_history:
if repo.default_branch.required_linear_history:
report.status = "PASS"
report.status_extended = f"Repository {repo.name} does require linear history on default branch ({repo.default_branch})."
report.status_extended = f"Repository {repo.name} does require linear history on default branch ({repo.default_branch.name})."
findings.append(report)
@@ -22,12 +22,12 @@ class repository_default_branch_requires_multiple_approvals(Check):
"""
findings = []
for repo in repository_client.repositories.values():
if repo.approval_count is not None:
if repo.default_branch.approval_count is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not enforce at least 2 approvals for code changes."
if repo.approval_count >= 2:
if repo.default_branch.approval_count >= 2:
report.status = "PASS"
report.status_extended = f"Repository {repo.name} does enforce at least 2 approvals for code changes."
@@ -22,14 +22,14 @@ class repository_default_branch_requires_signed_commits(Check):
"""
findings = []
for repo in repository_client.repositories.values():
if repo.require_signed_commits is not None:
if repo.default_branch.require_signed_commits is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not require signed commits on default branch ({repo.default_branch})."
report.status_extended = f"Repository {repo.name} does not require signed commits on default branch ({repo.default_branch.name})."
if repo.require_signed_commits:
if repo.default_branch.require_signed_commits:
report.status = "PASS"
report.status_extended = f"Repository {repo.name} does require signed commits on default branch ({repo.default_branch})."
report.status_extended = f"Repository {repo.name} does require signed commits on default branch ({repo.default_branch.name})."
findings.append(report)
@@ -22,16 +22,14 @@ class repository_default_branch_status_checks_required(Check):
"""
findings = []
for repo in repository_client.repositories.values():
if repo.status_checks is not None:
report = CheckReportGithub(
self.metadata(), resource=repo, owner=repo.name
)
if repo.default_branch.status_checks is not None:
report = CheckReportGithub(self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = (
f"Repository {repo.name} does not enforce status checks."
)
if repo.status_checks:
if repo.default_branch.status_checks:
report.status = "PASS"
report.status_extended = (
f"Repository {repo.name} does enforce status checks."
@@ -20,9 +20,7 @@ class repository_inactive_not_archived(Check):
)
for repo in repository_client.repositories.values():
report = CheckReportGithub(
metadata=self.metadata(), resource=repo, repository=repo.name
)
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
if repo.archived:
report.status = "PASS"
@@ -163,24 +163,27 @@ class Repository(GithubService):
name=repo.name,
owner=repo.owner.login,
full_name=repo.full_name,
default_branch=repo.default_branch,
default_branch=Branch(
name=default_branch,
protected=branch_protection,
default_branch=True,
require_pull_request=require_pr,
approval_count=approval_cnt,
required_linear_history=required_linear_history,
allow_force_pushes=allow_force_pushes,
branch_deletion=branch_deletion,
status_checks=status_checks,
enforce_admins=enforce_admins,
conversation_resolution=conversation_resolution,
require_code_owner_reviews=require_code_owner_reviews,
require_signed_commits=require_signed_commits,
),
private=repo.private,
archived=repo.archived,
pushed_at=repo.pushed_at,
securitymd=securitymd_exists,
require_pull_request=require_pr,
approval_count=approval_cnt,
required_linear_history=required_linear_history,
allow_force_pushes=allow_force_pushes,
default_branch_deletion=branch_deletion,
status_checks=status_checks,
enforce_admins=enforce_admins,
conversation_resolution=conversation_resolution,
default_branch_protection=branch_protection,
codeowners_exists=codeowners_exists,
require_code_owner_reviews=require_code_owner_reviews,
secret_scanning_enabled=secret_scanning_enabled,
require_signed_commits=require_signed_commits,
dependabot_alerts_enabled=dependabot_alerts_enabled,
delete_branch_on_merge=delete_branch_on_merge,
)
@@ -192,6 +195,24 @@ class Repository(GithubService):
return repos
class Branch(BaseModel):
"""Model for Github Branch"""
name: str
protected: bool
default_branch: bool
require_pull_request: Optional[bool]
approval_count: Optional[int]
required_linear_history: Optional[bool]
allow_force_pushes: Optional[bool]
branch_deletion: Optional[bool]
status_checks: Optional[bool]
enforce_admins: Optional[bool]
require_code_owner_reviews: Optional[bool]
require_signed_commits: Optional[bool]
conversation_resolution: Optional[bool]
class Repo(BaseModel):
"""Model for Github Repository"""
@@ -199,23 +220,12 @@ class Repo(BaseModel):
name: str
owner: str
full_name: str
default_branch_protection: Optional[bool]
default_branch: str
default_branch: Branch
private: bool
archived: bool
pushed_at: datetime
securitymd: Optional[bool]
require_pull_request: Optional[bool]
required_linear_history: Optional[bool]
allow_force_pushes: Optional[bool]
default_branch_deletion: Optional[bool]
status_checks: Optional[bool]
enforce_admins: Optional[bool]
approval_count: Optional[int]
codeowners_exists: Optional[bool]
require_code_owner_reviews: Optional[bool]
secret_scanning_enabled: Optional[bool]
require_signed_commits: Optional[bool]
dependabot_alerts_enabled: Optional[bool]
delete_branch_on_merge: Optional[bool]
conversation_resolution: Optional[bool]
@@ -36,7 +36,7 @@ class admincenter_groups_not_public_visibility(Check):
report.status = "FAIL"
report.status_extended = f"Group {group.name} has {group.visibility} visibility and should be Private."
if group.visibility != "Public":
if group.visibility and group.visibility != "Public":
report.status = "PASS"
report.status_extended = (
f"Group {group.name} has {group.visibility} visibility."
@@ -11,8 +11,6 @@ from prowler.providers.m365.m365_provider import M365Provider
class AdminCenter(M365Service):
def __init__(self, provider: M365Provider):
super().__init__(provider)
if self.powershell:
self.powershell.close()
self.organization_config = None
self.sharing_policy = None
@@ -203,7 +201,7 @@ class DirectoryRole(BaseModel):
class Group(BaseModel):
id: str
name: str
visibility: str
visibility: Optional[str]
class Domain(BaseModel):
@@ -9,13 +9,13 @@ from prowler.providers.m365.services.entra.entra_service import (
class entra_identity_protection_sign_in_risk_enabled(Check):
"""Check if at least one Conditional Access policy is a Identity Protection sign-in risk policy.
"""Check if at least one Conditional Access policy is an Identity Protection sign-in risk policy.
This check ensures that at least one Conditional Access policy is a Identity Protection sign-in risk policy.
This check ensures that at least one Conditional Access policy is an Identity Protection sign-in risk policy.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check to ensure that at least one Conditional Access policy is a Identity Protection sign-in risk policy.
"""Execute the check to ensure that at least one Conditional Access policy is an Identity Protection sign-in risk policy.
Returns:
list[CheckReportM365]: A list containing the results of the check.
@@ -9,13 +9,13 @@ from prowler.providers.m365.services.entra.entra_service import (
class entra_identity_protection_user_risk_enabled(Check):
"""Check if at least one Conditional Access policy is a Identity Protection user risk policy.
"""Check if at least one Conditional Access policy is an Identity Protection user risk policy.
This check ensures that at least one Conditional Access policy is a Identity Protection user risk policy.
This check ensures that at least one Conditional Access policy is an Identity Protection user risk policy.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check to ensure that at least one Conditional Access policy is a Identity Protection user risk policy.
"""Execute the check to ensure that at least one Conditional Access policy is an Identity Protection user risk policy.
Returns:
list[CheckReportM365]: A list containing the results of the check.
@@ -29,7 +29,7 @@ class entra_identity_protection_user_risk_enabled(Check):
resource_id="conditionalAccessPolicies",
)
report.status = "FAIL"
report.status_extended = "No Conditional Access Policy is an user risk based Identity Protection Policy."
report.status_extended = "No Conditional Access Policy is a user risk based Identity Protection Policy."
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
@@ -62,13 +62,13 @@ class entra_identity_protection_user_risk_enabled(Check):
)
if RiskLevel.HIGH not in policy.conditions.user_risk_levels:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is an user risk based Identity Protection Policy but does not protect against high risk potential account compromises."
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a user risk based Identity Protection Policy but does not protect against high risk potential account compromises."
elif policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is an user risk based Identity Protection Policy and reports high risk potential account compromises, but does not protect against them."
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a user risk based Identity Protection Policy and reports high risk potential account compromises, but does not protect against them."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is an user risk based Identity Protection Policy and does protect against high risk potential account compromises."
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a user risk based Identity Protection Policy and does protect against high risk potential account compromises."
break
findings.append(report)
@@ -493,6 +493,7 @@ class ConditionalAccessGrantControl(Enum):
BLOCK = "block"
DOMAIN_JOINED_DEVICE = "domainJoinedDevice"
PASSWORD_CHANGE = "passwordChange"
COMPLIANT_DEVICE = "compliantDevice"
class GrantControlOperator(Enum):
@@ -12,7 +12,7 @@ class compute_instance_login_user(Check):
)
report.status = "PASS"
report.status_extended = (
f"VM Instance {instance.name} has a appropriate login user."
f"VM Instance {instance.name} has an appropriate login user."
)
if instance.login_user:
report.status = "FAIL"
+1 -1
View File
@@ -37,7 +37,7 @@ mock_metadata_lambda = CheckMetadata(
CheckID="awslambda_function_url_public",
CheckTitle="Check 1",
CheckType=["type1"],
ServiceName="lambda",
ServiceName="awslambda",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
-4
View File
@@ -857,7 +857,6 @@ KISA_ISMSP_AWS = Compliance(
],
)
PROWLER_THREATSCORE_AWS_NAME = "prowler_threatscore_aws"
PROWLER_THREATSCORE_AWS = Compliance(
Framework="ProwlerThreatScore",
Version="1.0",
@@ -901,7 +900,6 @@ PROWLER_THREATSCORE_AWS = Compliance(
],
)
PROWLER_THREATSCORE_AZURE_NAME = "prowler_threatscore_azure"
PROWLER_THREATSCORE_AZURE = Compliance(
Framework="ProwlerThreatScore",
Version="1.0",
@@ -945,7 +943,6 @@ PROWLER_THREATSCORE_AZURE = Compliance(
],
)
PROWLER_THREATSCORE_GCP_NAME = "prowler_threatscore_gcp"
PROWLER_THREATSCORE_GCP = Compliance(
Framework="ProwlerThreatScore",
Version="1.0",
@@ -989,7 +986,6 @@ PROWLER_THREATSCORE_GCP = Compliance(
],
)
PROWLER_THREATSCORE_M365_NAME = "prowler_threatscore_m365"
PROWLER_THREATSCORE_M365 = Compliance(
Framework="ProwlerThreatScore",
Version="1.0",
@@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import (
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_aws import (
ProwlerThreatScoreAWS,
)
from tests.lib.outputs.compliance.fixtures import (
PROWLER_THREATSCORE_AWS,
PROWLER_THREATSCORE_AWS_NAME,
)
from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_AWS
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1
@@ -24,9 +21,7 @@ class TestProwlerThreatScoreAWS:
generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"})
]
output = ProwlerThreatScoreAWS(
findings, PROWLER_THREATSCORE_AWS, PROWLER_THREATSCORE_AWS_NAME
)
output = ProwlerThreatScoreAWS(findings, PROWLER_THREATSCORE_AWS)
output_data = output.data[0]
assert isinstance(output_data, ProwlerThreatScoreAWSModel)
assert output_data.Provider == "aws"
@@ -134,9 +129,7 @@ class TestProwlerThreatScoreAWS:
findings = [
generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"})
]
output = ProwlerThreatScoreAWS(
findings, PROWLER_THREATSCORE_AWS, PROWLER_THREATSCORE_AWS_NAME
)
output = ProwlerThreatScoreAWS(findings, PROWLER_THREATSCORE_AWS)
output._file_descriptor = mock_file
with patch.object(mock_file, "close", return_value=None):
@@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import (
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_azure import (
ProwlerThreatScoreAzure,
)
from tests.lib.outputs.compliance.fixtures import (
PROWLER_THREATSCORE_AZURE,
PROWLER_THREATSCORE_AZURE_NAME,
)
from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_AZURE
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
@@ -33,9 +30,7 @@ class TestProwlerThreatScoreAzure:
)
]
output = ProwlerThreatScoreAzure(
findings, PROWLER_THREATSCORE_AZURE, PROWLER_THREATSCORE_AZURE_NAME
)
output = ProwlerThreatScoreAzure(findings, PROWLER_THREATSCORE_AZURE)
output_data = output.data[0]
assert isinstance(output_data, ProwlerThreatScoreAzureModel)
assert output_data.Provider == "azure"
@@ -144,9 +139,7 @@ class TestProwlerThreatScoreAzure:
findings = [
generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"})
]
output = ProwlerThreatScoreAzure(
findings, PROWLER_THREATSCORE_AZURE, PROWLER_THREATSCORE_AZURE_NAME
)
output = ProwlerThreatScoreAzure(findings, PROWLER_THREATSCORE_AZURE)
output._file_descriptor = mock_file
with patch.object(mock_file, "close", return_value=None):
@@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import (
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_gcp import (
ProwlerThreatScoreGCP,
)
from tests.lib.outputs.compliance.fixtures import (
PROWLER_THREATSCORE_GCP,
PROWLER_THREATSCORE_GCP_NAME,
)
from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_GCP
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID
@@ -30,9 +27,7 @@ class TestProwlerThreatScoreGCP:
)
]
output = ProwlerThreatScoreGCP(
findings, PROWLER_THREATSCORE_GCP, PROWLER_THREATSCORE_GCP_NAME
)
output = ProwlerThreatScoreGCP(findings, PROWLER_THREATSCORE_GCP)
output_data = output.data[0]
assert isinstance(output_data, ProwlerThreatScoreGCPModel)
assert output_data.Provider == "gcp"
@@ -140,9 +135,7 @@ class TestProwlerThreatScoreGCP:
findings = [
generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"})
]
output = ProwlerThreatScoreGCP(
findings, PROWLER_THREATSCORE_GCP, PROWLER_THREATSCORE_GCP_NAME
)
output = ProwlerThreatScoreGCP(findings, PROWLER_THREATSCORE_GCP)
output._file_descriptor = mock_file
with patch.object(mock_file, "close", return_value=None):
@@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import (
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_m365 import (
ProwlerThreatScoreM365,
)
from tests.lib.outputs.compliance.fixtures import (
PROWLER_THREATSCORE_M365,
PROWLER_THREATSCORE_M365_NAME,
)
from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_M365
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
from tests.providers.m365.m365_fixtures import TENANT_ID
@@ -30,9 +27,7 @@ class TestProwlerThreatScoreM365:
)
]
output = ProwlerThreatScoreM365(
findings, PROWLER_THREATSCORE_M365, PROWLER_THREATSCORE_M365_NAME
)
output = ProwlerThreatScoreM365(findings, PROWLER_THREATSCORE_M365)
output_data = output.data[0]
assert isinstance(output_data, ProwlerThreatScoreM365Model)
assert output_data.Provider == "m365"
@@ -142,9 +137,7 @@ class TestProwlerThreatScoreM365:
findings = [
generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"})
]
output = ProwlerThreatScoreM365(
findings, PROWLER_THREATSCORE_M365, PROWLER_THREATSCORE_M365_NAME
)
output = ProwlerThreatScoreM365(findings, PROWLER_THREATSCORE_M365)
output._file_descriptor = mock_file
with patch.object(mock_file, "close", return_value=None):
@@ -31,7 +31,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high:
result = check.execute()
assert len(result) == 0
def test_defender_severity_alerts_low(self):
def test_defender_severity_alerts_critical(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.security_contacts = {
@@ -41,7 +41,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high:
name="default",
emails="",
phone="",
alert_notifications_minimal_severity="Low",
alert_notifications_minimal_severity="Critical",
alert_notifications_state="On",
notified_roles=["Contributor"],
notified_roles_state="On",
@@ -69,7 +69,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Notifiy alerts are not enabled for severity high in subscription {AZURE_SUBSCRIPTION_ID}."
== f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_ID}."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == "default"
@@ -113,7 +113,51 @@ class Test_defender_ensure_notify_alerts_severity_is_high:
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Notifiy alerts are enabled for severity high in subscription {AZURE_SUBSCRIPTION_ID}."
== f"Notifications are enabled for alerts with a minimum severity of high or lower (High) in subscription {AZURE_SUBSCRIPTION_ID}."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == "default"
assert result[0].resource_id == resource_id
def test_defender_severity_alerts_low(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.security_contacts = {
AZURE_SUBSCRIPTION_ID: {
resource_id: SecurityContacts(
resource_id=resource_id,
name="default",
emails="",
phone="",
alert_notifications_minimal_severity="Low",
alert_notifications_state="On",
notified_roles=["Contributor"],
notified_roles_state="On",
)
}
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.defender.defender_ensure_notify_alerts_severity_is_high.defender_ensure_notify_alerts_severity_is_high.defender_client",
new=defender_client,
),
):
from prowler.providers.azure.services.defender.defender_ensure_notify_alerts_severity_is_high.defender_ensure_notify_alerts_severity_is_high import (
defender_ensure_notify_alerts_severity_is_high,
)
check = defender_ensure_notify_alerts_severity_is_high()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Notifications are enabled for alerts with a minimum severity of high or lower (Low) in subscription {AZURE_SUBSCRIPTION_ID}."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == "default"
@@ -156,7 +200,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Notifiy alerts are not enabled for severity high in subscription {AZURE_SUBSCRIPTION_ID}."
== f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_ID}."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == "default"
@@ -93,7 +93,7 @@ async def mock_entra_get_conditional_access_policy(_):
"include": ["797f4846-ba00-4fd7-ba43-dac1f8f63013"],
"exclude": [],
},
access_controls={"grant": ["MFA"], "block": []},
access_controls={"grant": ["MFA", "compliantDevice"], "block": []},
)
}
}
@@ -216,7 +216,7 @@ class Test_Entra_Service:
)
assert entra_client.conditional_access_policy[DOMAIN]["id-1"].access_controls[
"grant"
] == ["MFA"]
] == ["MFA", "compliantDevice"]
assert (
entra_client.conditional_access_policy[DOMAIN]["id-1"].access_controls[
"block"
@@ -122,7 +122,7 @@ class Test_sqlserver_auditing_enabled:
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has a auditing policy configured."
== f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has an auditing policy configured."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == sql_server_name
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
@@ -30,19 +30,37 @@ class Test_repository_branch_delete_on_merge_enabled_test:
def test_branch_deletion_disabled(self):
repository_client = mock.MagicMock
repo_name = "repo1"
repo_name = "repo2"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
2: Repo(
id=2,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch="main",
full_name="account-name/repo2",
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
securitymd=False,
delete_branch_on_merge=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -63,29 +81,47 @@ class Test_repository_branch_delete_on_merge_enabled_test:
check = repository_branch_delete_on_merge_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == "repo1"
assert result[0].resource_id == 2
assert result[0].resource_name == repo_name
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} does not delete branches on merge."
== f"Repository {repo_name} does not delete branches on merge in default branch ({default_branch})."
)
def test_branch_deletion_enabled(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch="main",
default_branch=Branch(
name=default_branch,
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
allow_force_pushes=False,
branch_deletion=False,
status_checks=True,
enforce_admins=True,
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
),
private=False,
securitymd=True,
delete_branch_on_merge=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=True,
secret_scanning_enabled=True,
dependabot_alerts_enabled=True,
delete_branch_on_merge=True,
),
}
@@ -107,9 +143,9 @@ class Test_repository_branch_delete_on_merge_enabled_test:
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == "repo1"
assert result[0].resource_name == repo_name
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Repository {repo_name} does delete branches on merge."
== f"Repository {repo_name} does delete branches on merge in default branch ({default_branch})."
)
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
@@ -31,7 +31,21 @@ class Test_repository_default_branch_deletion_disabled_test:
def test_allow_branch_deletion_enabled(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
default_branch = Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
)
now = datetime.now(timezone.utc)
repository_client.repositories = {
@@ -45,6 +59,11 @@ class Test_repository_default_branch_deletion_disabled_test:
archived=False,
pushed_at=now,
default_branch_deletion=True,
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -76,7 +95,21 @@ class Test_repository_default_branch_deletion_disabled_test:
def test_allow_branch_deletion_disabled(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
default_branch = Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=False,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
)
now = datetime.now(timezone.utc)
repository_client.repositories = {
@@ -90,6 +123,11 @@ class Test_repository_default_branch_deletion_disabled_test:
archived=False,
pushed_at=now,
default_branch_deletion=False,
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
@@ -28,7 +28,7 @@ class Test_repository_default_branch_disallows_force_push_test:
result = check.execute()
assert len(result) == 0
def test_allow_force_push_enabled(self):
def test_force_push_allowed(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
@@ -38,12 +38,29 @@ class Test_repository_default_branch_disallows_force_push_test:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=default_branch,
allow_force_pushes=True,
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
securitymd=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -69,10 +86,10 @@ class Test_repository_default_branch_disallows_force_push_test:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} does allow force push."
== f"Repository {repo_name} does allow force pushes on default branch ({default_branch})."
)
def test_allow_force_push_disabled(self):
def test_force_push_disallowed(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
@@ -82,12 +99,29 @@ class Test_repository_default_branch_disallows_force_push_test:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
allow_force_pushes=False,
branch_deletion=False,
status_checks=True,
enforce_admins=True,
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
),
private=False,
default_branch=default_branch,
allow_force_pushes=False,
securitymd=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=True,
secret_scanning_enabled=True,
dependabot_alerts_enabled=True,
delete_branch_on_merge=True,
),
}
@@ -113,5 +147,5 @@ class Test_repository_default_branch_disallows_force_push_test:
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Repository {repo_name} does deny force push."
== f"Repository {repo_name} does deny force pushes on default branch ({default_branch})."
)
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
@@ -38,12 +38,29 @@ class Test_repository_default_branch_protection_applies_to_admins_test:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=default_branch,
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
securitymd=False,
enforce_admins=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -82,12 +99,29 @@ class Test_repository_default_branch_protection_applies_to_admins_test:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
allow_force_pushes=False,
branch_deletion=False,
status_checks=True,
enforce_admins=True,
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
),
private=False,
default_branch=default_branch,
enforce_admins=True,
securitymd=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=True,
secret_scanning_enabled=True,
dependabot_alerts_enabled=True,
delete_branch_on_merge=True,
),
}
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
@@ -38,12 +38,29 @@ class Test_repository_default_branch_protection_enabled_test:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=default_branch,
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
default_branch_protection=False,
securitymd=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -82,12 +99,29 @@ class Test_repository_default_branch_protection_enabled_test:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
allow_force_pushes=False,
branch_deletion=False,
status_checks=True,
enforce_admins=True,
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
),
private=False,
default_branch=default_branch,
default_branch_protection=True,
securitymd=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=True,
secret_scanning_enabled=True,
dependabot_alerts_enabled=True,
delete_branch_on_merge=True,
),
}
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
@@ -37,14 +37,29 @@ class Test_repository_default_branch_requires_codeowners_review:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch="main",
default_branch=Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
securitymd=True,
require_pull_request=False,
approval_count=0,
require_code_owner_reviews=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -82,14 +97,29 @@ class Test_repository_default_branch_requires_codeowners_review:
name=repo_name,
owner="account-name",
full_name="account-name/repo2",
default_branch="main",
default_branch=Branch(
name="main",
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
allow_force_pushes=False,
branch_deletion=False,
status_checks=True,
enforce_admins=True,
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
),
private=False,
securitymd=True,
require_pull_request=False,
approval_count=0,
require_code_owner_reviews=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=True,
secret_scanning_enabled=True,
dependabot_alerts_enabled=True,
delete_branch_on_merge=True,
),
}
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
@@ -38,12 +38,29 @@ class Test_repository_default_branch_requires_conversation_resolution_test:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=default_branch,
conversation_resolution=False,
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
private=False,
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -69,7 +86,7 @@ class Test_repository_default_branch_requires_conversation_resolution_test:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} does not require conversation resolution."
== f"Repository {repo_name} does not require conversation resolution on default branch ({default_branch})."
)
def test_conversation_resolution_enabled(self):
@@ -82,12 +99,29 @@ class Test_repository_default_branch_requires_conversation_resolution_test:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
allow_force_pushes=False,
branch_deletion=False,
status_checks=True,
enforce_admins=True,
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
),
private=False,
default_branch=default_branch,
conversation_resolution=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=True,
secret_scanning_enabled=True,
dependabot_alerts_enabled=True,
delete_branch_on_merge=True,
),
}
@@ -113,5 +147,5 @@ class Test_repository_default_branch_requires_conversation_resolution_test:
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Repository {repo_name} does require conversation resolution."
== f"Repository {repo_name} does require conversation resolution on default branch ({default_branch})."
)
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
@@ -38,12 +38,29 @@ class Test_repository_default_branch_requires_linear_history_test:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=default_branch,
required_linear_history=False,
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
securitymd=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -82,12 +99,29 @@ class Test_repository_default_branch_requires_linear_history_test:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
allow_force_pushes=False,
branch_deletion=False,
status_checks=True,
enforce_admins=True,
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
),
private=False,
default_branch=default_branch,
required_linear_history=True,
securitymd=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=True,
secret_scanning_enabled=True,
dependabot_alerts_enabled=True,
delete_branch_on_merge=True,
),
}
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Repo
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
@@ -37,14 +37,29 @@ class Test_repository_default_branch_requires_multiple_approvals:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch_protection=False,
default_branch="main",
default_branch=Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
securitymd=False,
require_pull_request=False,
approval_count=0,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -82,14 +97,29 @@ class Test_repository_default_branch_requires_multiple_approvals:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch_protection=False,
default_branch="master",
default_branch=Branch(
name="master",
protected=False,
default_branch=True,
require_pull_request=True,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
securitymd=False,
require_pull_request=True,
approval_count=0,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}
@@ -127,14 +157,29 @@ class Test_repository_default_branch_requires_multiple_approvals:
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch_protection=True,
default_branch="master",
default_branch=Branch(
name="master",
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=2,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
securitymd=True,
require_pull_request=True,
approval_count=2,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=False,
),
}

Some files were not shown because too many files have changed in this diff Show More