Merge branch 'master' into feat-rbac

This commit is contained in:
Adrián Jesús Peña Rodríguez committed 2024-12-04 12:51:39 +01:00
commit 9723b8fac1
168 files changed
+7057 -1689

No files matched your search

+33 -3
View File
@@ -1,12 +1,13 @@
import secrets
from contextlib import contextmanager
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta, timezone
from django.conf import settings
from django.contrib.auth.models import BaseUserManager
from django.db import models, transaction, connection
from django.core.paginator import Paginator
from django.db import connection, models, transaction
from psycopg2 import connect as psycopg2_connect
from psycopg2.extensions import new_type, register_type, register_adapter, AsIs
from psycopg2.extensions import AsIs, new_type, register_adapter, register_type
DB_USER = settings.DATABASES["default"]["USER"] if not settings.TESTING else "test"
DB_PASSWORD = (
@@ -88,6 +89,35 @@ def generate_random_token(length: int = 14, symbols: str | None = None) -> str:
return "".join(secrets.choice(symbols or _symbols) for _ in range(length))
def batch_delete(queryset, batch_size=5000):
"""
Deletes objects in batches and returns the total number of deletions and a summary.
Args:
queryset (QuerySet): The queryset of objects to delete.
batch_size (int): The number of objects to delete in each batch.
Returns:
tuple: (total_deleted, deletion_summary)
"""
total_deleted = 0
deletion_summary = {}
paginator = Paginator(queryset.order_by("id").only("id"), batch_size)
for page_num in paginator.page_range:
batch_ids = [obj.id for obj in paginator.page(page_num).object_list]
deleted_count, deleted_info = queryset.filter(id__in=batch_ids).delete()
total_deleted += deleted_count
for model_label, count in deleted_info.items():
deletion_summary[model_label] = deletion_summary.get(model_label, 0) + count
return total_deleted, deletion_summary
# Postgres Enums
+51 -12
View File
@@ -4,48 +4,49 @@ from django.conf import settings
from django.db.models import Q
from django_filters.rest_framework import (
BaseInFilter,
FilterSet,
BooleanFilter,
CharFilter,
UUIDFilter,
DateFilter,
ChoiceFilter,
DateFilter,
FilterSet,
UUIDFilter,
)
from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
from rest_framework_json_api.serializers import ValidationError
from api.db_utils import (
ProviderEnumField,
FindingDeltaEnumField,
StatusEnumField,
SeverityEnumField,
InvitationStateEnumField,
ProviderEnumField,
SeverityEnumField,
StatusEnumField,
)
from api.models import (
User,
Finding,
Membership,
Provider,
ProviderGroup,
Resource,
ResourceTag,
Scan,
Task,
StateChoices,
Finding,
ScanSummary,
SeverityChoices,
StateChoices,
StatusChoices,
ProviderSecret,
Invitation,
Role,
ComplianceOverview,
Task,
User,
)
from api.rls import Tenant
from api.uuid_utils import (
datetime_to_uuid7,
uuid7_start,
transform_into_uuid7,
uuid7_end,
uuid7_range,
transform_into_uuid7,
uuid7_start,
)
from api.v1.serializers import TaskBase
@@ -58,6 +59,13 @@ class CustomDjangoFilterBackend(DjangoFilterBackend):
"""
return None
def get_filterset_class(self, view, queryset=None):
# Check if the view has 'get_filterset_class' method
if hasattr(view, "get_filterset_class"):
return view.get_filterset_class()
# Fallback to the default implementation
return super().get_filterset_class(view, queryset)
class UUIDInFilter(BaseInFilter, UUIDFilter):
pass
@@ -157,7 +165,12 @@ class ScanFilter(ProviderRelationshipFilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
completed_at = DateFilter(field_name="completed_at", lookup_expr="date")
started_at = DateFilter(field_name="started_at", lookup_expr="date")
next_scan_at = DateFilter(field_name="next_scan_at", lookup_expr="date")
trigger = ChoiceFilter(choices=Scan.TriggerChoices.choices)
state = ChoiceFilter(choices=StateChoices.choices)
state__in = ChoiceInFilter(
field_name="state", choices=StateChoices.choices, lookup_expr="in"
)
class Meta:
model = Scan
@@ -165,6 +178,7 @@ class ScanFilter(ProviderRelationshipFilterSet):
"provider": ["exact", "in"],
"name": ["exact", "icontains"],
"started_at": ["gte", "lte"],
"next_scan_at": ["gte", "lte"],
"trigger": ["exact"],
}
@@ -497,3 +511,28 @@ class ComplianceOverviewFilter(FilterSet):
"version": ["exact", "icontains"],
"region": ["exact", "icontains", "in"],
}
class ScanSummaryFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
provider_id = UUIDFilter(field_name="scan__provider__id", lookup_expr="exact")
provider_type = ChoiceFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
)
provider_type__in = ChoiceInFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
)
region = CharFilter(field_name="region")
muted_findings = BooleanFilter(method="filter_muted_findings")
def filter_muted_findings(self, queryset, name, value):
if not value:
return queryset.exclude(muted__gt=0)
return queryset
class Meta:
model = ScanSummary
fields = {
"inserted_at": ["date", "gte", "lte"],
"region": ["exact", "icontains", "in"],
}
+126 -45
View File
@@ -22,36 +22,36 @@ from uuid6 import uuid7
import api.rls
from api.db_utils import (
PostgresEnumMigration,
MemberRoleEnumField,
DB_PROWLER_PASSWORD,
DB_PROWLER_USER,
POSTGRES_TENANT_VAR,
POSTGRES_USER_VAR,
TASK_RUNNER_DB_TABLE,
InvitationStateEnum,
InvitationStateEnumField,
MemberRoleEnum,
MemberRoleEnumField,
PostgresEnumMigration,
ProviderEnum,
ProviderEnumField,
ProviderSecretTypeEnum,
ProviderSecretTypeEnumField,
ScanTriggerEnum,
StateEnumField,
StateEnum,
ScanTriggerEnumField,
InvitationStateEnum,
InvitationStateEnumField,
StateEnum,
StateEnumField,
register_enum,
DB_PROWLER_USER,
DB_PROWLER_PASSWORD,
TASK_RUNNER_DB_TABLE,
POSTGRES_TENANT_VAR,
POSTGRES_USER_VAR,
)
from api.models import (
Provider,
Scan,
StateChoices,
Finding,
StatusChoices,
SeverityChoices,
Membership,
ProviderSecret,
Invitation,
Membership,
Provider,
ProviderSecret,
Scan,
SeverityChoices,
StateChoices,
StatusChoices,
)
DB_NAME = settings.DATABASES["default"]["NAME"]
@@ -289,7 +289,8 @@ class Migration(migrations.Migration):
),
),
# Enable tenants RLS based on memberships
migrations.RunSQL(f"""
migrations.RunSQL(
f"""
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
-- Policy for SELECT
@@ -364,7 +365,8 @@ class Migration(migrations.Migration):
FOR INSERT
TO {DB_PROWLER_USER}
WITH CHECK (true);
"""),
"""
),
# Create and register ProviderEnum type
migrations.RunPython(
ProviderEnumMigration.create_enum_type,
@@ -385,6 +387,7 @@ class Migration(migrations.Migration):
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("is_deleted", models.BooleanField(default=False)),
(
"provider",
ProviderEnumField(
@@ -676,6 +679,7 @@ class Migration(migrations.Migration):
("updated_at", models.DateTimeField(auto_now=True)),
("started_at", models.DateTimeField(null=True, blank=True)),
("completed_at", models.DateTimeField(null=True, blank=True)),
("next_scan_at", models.DateTimeField(null=True, blank=True)),
(
"provider",
models.ForeignKey(
@@ -1091,7 +1095,7 @@ class Migration(migrations.Migration):
},
bases=(PostgresPartitionedModel,),
managers=[
("objects", PostgresManager()),
("objects", api.models.ActiveProviderPartitionedManager()),
],
),
migrations.RunSQL(
@@ -1575,6 +1579,67 @@ class Migration(migrations.Migration):
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.CreateModel(
name="InvitationRoleRelationship",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"invitation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.invitation"
),
),
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.role"
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "role_invitation_relationship",
},
),
migrations.AddConstraint(
model_name="invitationrolerelationship",
constraint=models.UniqueConstraint(
fields=("role_id", "invitation_id"),
name="unique_role_invitation_relationship",
),
),
migrations.AddConstraint(
model_name="invitationrolerelationship",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_invitationrolerelationship",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddField(
model_name="role",
name="invitations",
field=models.ManyToManyField(
related_name="roles",
through="api.InvitationRoleRelationship",
to="api.invitation",
),
),
migrations.CreateModel(
name="ComplianceOverview",
fields=[
@@ -1655,7 +1720,7 @@ class Migration(migrations.Migration):
),
),
migrations.CreateModel(
name="InvitationRoleRelationship",
name="ScanSummary",
fields=[
(
"id",
@@ -1667,17 +1732,41 @@ class Migration(migrations.Migration):
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("check_id", models.CharField(max_length=100)),
("service", models.TextField()),
(
"invitation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.invitation"
"severity",
api.db_utils.SeverityEnumField(
choices=[
("critical", "Critical"),
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("informational", "Informational"),
]
),
),
("region", models.TextField()),
("_pass", models.IntegerField(db_column="pass", default=0)),
("fail", models.IntegerField(default=0)),
("muted", models.IntegerField(default=0)),
("total", models.IntegerField(default=0)),
("new", models.IntegerField(default=0)),
("changed", models.IntegerField(default=0)),
("unchanged", models.IntegerField(default=0)),
("fail_new", models.IntegerField(default=0)),
("fail_changed", models.IntegerField(default=0)),
("pass_new", models.IntegerField(default=0)),
("pass_changed", models.IntegerField(default=0)),
("muted_new", models.IntegerField(default=0)),
("muted_changed", models.IntegerField(default=0)),
(
"role",
"scan",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.role"
on_delete=django.db.models.deletion.CASCADE,
related_name="aggregations",
related_query_name="aggregation",
to="api.scan",
),
),
(
@@ -1688,31 +1777,23 @@ class Migration(migrations.Migration):
),
],
options={
"db_table": "role_invitation_relationship",
"db_table": "scan_summaries",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="invitationrolerelationship",
constraint=models.UniqueConstraint(
fields=("role_id", "invitation_id"),
name="unique_role_invitation_relationship",
),
),
migrations.AddConstraint(
model_name="invitationrolerelationship",
model_name="scansummary",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_invitationrolerelationship",
name="rls_on_scansummary",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddField(
model_name="role",
name="invitations",
field=models.ManyToManyField(
related_name="roles",
through="api.InvitationRoleRelationship",
to="api.invitation",
migrations.AddConstraint(
model_name="scansummary",
constraint=models.UniqueConstraint(
fields=("tenant", "scan", "check_id", "service", "severity", "region"),
name="unique_scan_summary",
),
),
]
+116 -20
View File
@@ -1,6 +1,6 @@
import json
import re
from uuid import uuid4, UUID
from uuid import UUID, uuid4
from cryptography.fernet import Fernet
from django.conf import settings
@@ -9,37 +9,37 @@ from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_celery_results.models import TaskResult
from prowler.lib.check.models import Severity
from psqlextra.manager import PostgresManager
from psqlextra.models import PostgresPartitionedModel
from psqlextra.types import PostgresPartitioningMethod
from uuid6 import uuid7
from api.db_utils import (
MemberRoleEnumField,
enum_to_choices,
ProviderEnumField,
StateEnumField,
ScanTriggerEnumField,
FindingDeltaEnumField,
SeverityEnumField,
StatusEnumField,
CustomUserManager,
ProviderSecretTypeEnumField,
FindingDeltaEnumField,
InvitationStateEnumField,
one_week_from_now,
MemberRoleEnumField,
ProviderEnumField,
ProviderSecretTypeEnumField,
ScanTriggerEnumField,
SeverityEnumField,
StateEnumField,
StatusEnumField,
enum_to_choices,
generate_random_token,
one_week_from_now,
)
from api.exceptions import ModelValidationError
from api.rls import (
RowLevelSecurityProtectedModel,
)
from api.rls import (
Tenant,
RowLevelSecurityConstraint,
BaseSecurityConstraint,
RowLevelSecurityConstraint,
RowLevelSecurityProtectedModel,
Tenant,
)
from prowler.lib.check.models import Severity
fernet = Fernet(settings.SECRETS_ENCRYPTION_KEY.encode())
@@ -69,6 +69,24 @@ class StateChoices(models.TextChoices):
CANCELLED = "cancelled", _("Cancelled")
class ActiveProviderManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(self.active_provider_filter())
def active_provider_filter(self):
if self.model is Provider:
return Q(is_deleted=False)
elif self.model in [Finding, ComplianceOverview, ScanSummary]:
return Q(scan__provider__is_deleted=False)
else:
return Q(provider__is_deleted=False)
class ActiveProviderPartitionedManager(PostgresManager, ActiveProviderManager):
def get_queryset(self):
return super().get_queryset().filter(self.active_provider_filter())
class User(AbstractBaseUser):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.CharField(max_length=150, validators=[MinLengthValidator(3)])
@@ -149,6 +167,9 @@ class Membership(models.Model):
class Provider(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
class ProviderChoices(models.TextChoices):
AWS = "aws", _("AWS")
AZURE = "azure", _("Azure")
@@ -189,10 +210,14 @@ class Provider(RowLevelSecurityProtectedModel):
@staticmethod
def validate_kubernetes_uid(value):
if not re.match(r"^[a-z0-9]([-a-z0-9]{1,61}[a-z0-9])?$", value):
if not re.match(
r"(^[a-z0-9]([-a-z0-9]{1,61}[a-z0-9])?$)|(^arn:aws(-cn|-us-gov|-iso|-iso-b)?:[a-zA-Z0-9\-]+:([a-z]{2}-[a-z]+-\d{1})?:(\d{12})?:[a-zA-Z0-9\-_\/:\.\*]+(:\d+)?$)",
value,
):
raise ModelValidationError(
detail="K8s provider ID must be up to 63 characters, start and end with a lowercase letter or number, "
"and contain only lowercase alphanumeric characters and hyphens.",
detail="The value must either be a valid Kubernetes UID (up to 63 characters, "
"starting and ending with a lowercase letter or number, containing only "
"lowercase alphanumeric characters and hyphens) or a valid EKS ARN.",
code="kubernetes-uid",
pointer="/data/attributes/uid",
)
@@ -200,6 +225,7 @@ class Provider(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
is_deleted = models.BooleanField(default=False)
provider = ProviderEnumField(
choices=ProviderChoices.choices, default=ProviderChoices.AWS
)
@@ -272,6 +298,9 @@ class ProviderGroup(RowLevelSecurityProtectedModel):
class ProviderGroupMembership(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
provider = models.ForeignKey(
Provider,
@@ -336,6 +365,9 @@ class Task(RowLevelSecurityProtectedModel):
class Scan(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
class TriggerChoices(models.TextChoices):
SCHEDULED = "scheduled", _("Scheduled")
MANUAL = "manual", _("Manual")
@@ -371,6 +403,7 @@ class Scan(RowLevelSecurityProtectedModel):
updated_at = models.DateTimeField(auto_now=True, editable=False)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
next_scan_at = models.DateTimeField(null=True, blank=True)
# TODO: mutelist foreign key
class Meta(RowLevelSecurityProtectedModel.Meta):
@@ -433,6 +466,9 @@ class ResourceTag(RowLevelSecurityProtectedModel):
class Resource(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
@@ -559,6 +595,9 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
Note when creating migrations, you must use `python manage.py pgmakemigrations` to create the migrations.
"""
objects = ActiveProviderPartitionedManager()
all_objects = models.Manager()
class PartitioningMeta:
method = PostgresPartitioningMethod.RANGE
key = ["id"]
@@ -710,6 +749,9 @@ class ResourceFindingMapping(PostgresPartitionedModel, RowLevelSecurityProtected
class ProviderSecret(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
class TypeChoices(models.TextChoices):
STATIC = "static", _("Key-value pairs")
ROLE = "role", _("Role assumption")
@@ -925,6 +967,9 @@ class InvitationRoleRelationship(RowLevelSecurityProtectedModel):
class ComplianceOverview(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
compliance_id = models.CharField(max_length=100, blank=False, null=False)
@@ -971,3 +1016,54 @@ class ComplianceOverview(RowLevelSecurityProtectedModel):
class JSONAPIMeta:
resource_name = "compliance-overviews"
class ScanSummary(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
check_id = models.CharField(max_length=100, blank=False, null=False)
service = models.TextField(blank=False)
severity = SeverityEnumField(choices=SeverityChoices)
region = models.TextField(blank=False)
_pass = models.IntegerField(db_column="pass", default=0)
fail = models.IntegerField(default=0)
muted = models.IntegerField(default=0)
total = models.IntegerField(default=0)
new = models.IntegerField(default=0)
changed = models.IntegerField(default=0)
unchanged = models.IntegerField(default=0)
fail_new = models.IntegerField(default=0)
fail_changed = models.IntegerField(default=0)
pass_new = models.IntegerField(default=0)
pass_changed = models.IntegerField(default=0)
muted_new = models.IntegerField(default=0)
muted_changed = models.IntegerField(default=0)
scan = models.ForeignKey(
Scan,
on_delete=models.CASCADE,
related_name="aggregations",
related_query_name="aggregation",
)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "scan_summaries"
constraints = [
models.UniqueConstraint(
fields=("tenant", "scan", "check_id", "service", "severity", "region"),
name="unique_scan_summary",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class JSONAPIMeta:
resource_name = "scan-summaries"
File diff suppressed because it is too large. Load diff
+160 -14
View File
@@ -1,32 +1,28 @@
import json
from datetime import datetime
from datetime import timezone, timedelta
from datetime import datetime, timedelta, timezone
from unittest.mock import ANY, Mock, patch
import jwt
import pytest
from conftest import API_JSON_CONTENT_TYPE, TEST_PASSWORD, TEST_USER
from django.urls import reverse
from rest_framework import status
from api.models import (
User,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Role,
RoleProviderGroupRelationship,
Scan,
ProviderSecret,
Invitation,
UserRoleRelationship,
ProviderSecret,
Scan,
StateChoices,
User,
)
from api.rls import Tenant
from conftest import (
API_JSON_CONTENT_TYPE,
TEST_PASSWORD,
TEST_USER,
)
TODAY = str(datetime.today().date())
@@ -799,11 +795,50 @@ class TestMembershipViewSet:
@pytest.mark.django_db
class TestProviderViewSet:
@pytest.fixture(scope="function")
def create_provider_group_relationship(
self, tenants_fixture, providers_fixture, provider_groups_fixture
):
tenant, *_ = tenants_fixture
provider1, *_ = providers_fixture
provider_group1, *_ = provider_groups_fixture
provider_group_membership = ProviderGroupMembership.objects.create(
tenant=tenant, provider=provider1, provider_group=provider_group1
)
return provider_group_membership
def test_providers_list(self, authenticated_client, providers_fixture):
response = authenticated_client.get(reverse("provider-list"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(providers_fixture)
@pytest.mark.parametrize(
"include_values, expected_resources",
[
("provider_groups", ["provider-groups"]),
],
)
def test_providers_list_include(
self,
include_values,
expected_resources,
authenticated_client,
providers_fixture,
create_provider_group_relationship,
):
response = authenticated_client.get(
reverse("provider-list"), {"include": include_values}
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(providers_fixture)
assert "included" in response.json()
included_data = response.json()["included"]
for expected_type in expected_resources:
assert any(
d.get("type") == expected_type for d in included_data
), f"Expected type '{expected_type}' not found in included data"
def test_providers_retrieve(self, authenticated_client, providers_fixture):
provider1, *_ = providers_fixture
response = authenticated_client.get(
@@ -1805,7 +1840,7 @@ class TestScanViewSet:
],
)
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.perform_scan_task.delay")
@patch("api.v1.views.perform_scan_task.apply_async")
def test_scans_create_valid(
self,
mock_perform_scan_task,
@@ -1949,6 +1984,10 @@ class TestScanViewSet:
("started_at.gte", "2024-01-01", 3),
("started_at.lte", "2024-01-01", 0),
("trigger", Scan.TriggerChoices.MANUAL, 1),
("state", StateChoices.AVAILABLE, 2),
("state", StateChoices.FAILED, 1),
("state.in", f"{StateChoices.FAILED},{StateChoices.AVAILABLE}", 3),
("trigger", Scan.TriggerChoices.MANUAL, 1),
]
),
)
@@ -2466,6 +2505,72 @@ class TestFindingViewSet:
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_findings_services_regions_retrieve(
self, authenticated_client, findings_fixture
):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{"filter[inserted_at]": finding_1.updated_at.strftime("%Y-%m-%d")},
)
data = response.json()
expected_services = {"ec2", "s3"}
expected_regions = {"eu-west-1", "us-east-1"}
assert data["data"]["type"] == "finding-dynamic-filters"
assert data["data"]["id"] is None
assert set(data["data"]["attributes"]["services"]) == expected_services
assert set(data["data"]["attributes"]["regions"]) == expected_regions
def test_findings_services_regions_severity_retrieve(
self, authenticated_client, findings_fixture
):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{
"filter[severity__in]": ["low", "medium"],
"filter[inserted_at]": finding_1.updated_at.strftime("%Y-%m-%d"),
},
)
data = response.json()
expected_services = {"s3"}
expected_regions = {"eu-west-1"}
assert data["data"]["type"] == "finding-dynamic-filters"
assert data["data"]["id"] is None
assert set(data["data"]["attributes"]["services"]) == expected_services
assert set(data["data"]["attributes"]["regions"]) == expected_regions
def test_findings_services_regions_future_date(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{"filter[inserted_at]": "2048-01-01"},
)
data = response.json()
assert data["data"]["type"] == "finding-dynamic-filters"
assert data["data"]["id"] is None
assert data["data"]["attributes"]["services"] == []
assert data["data"]["attributes"]["regions"] == []
def test_findings_services_regions_invalid_date(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{"filter[inserted_at]": "2048-01-011"},
)
assert response.json() == {
"errors": [
{
"detail": "Enter a valid date.",
"status": "400",
"source": {"pointer": "/data/attributes/inserted_at"},
"code": "invalid",
}
]
}
@pytest.mark.django_db
class TestJWTFields:
@@ -3036,9 +3141,9 @@ class TestInvitationViewSet:
response = authenticated_client.get(
reverse("invitation-list"),
{
f"filter[{filter_name}]": filter_value
if filter_name != "inviter"
else str(user.id)
f"filter[{filter_name}]": (
filter_value if filter_name != "inviter" else str(user.id)
)
},
)
@@ -3717,3 +3822,44 @@ class TestOverviewViewSet:
assert response.json()["data"][0]["attributes"]["resources"]["total"] == len(
resources_fixture
)
# TODO Add more tests for the rest of overviews
@pytest.mark.django_db
class TestScheduleViewSet:
@pytest.mark.parametrize("method", ["get", "post"])
def test_schedule_invalid_method_list(self, method, authenticated_client):
response = getattr(authenticated_client, method)(reverse("schedule-list"))
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.schedule_provider_scan")
def test_schedule_daily(
self,
mock_schedule_scan,
mock_task_get,
authenticated_client,
providers_fixture,
tasks_fixture,
):
provider, *_ = providers_fixture
prowler_task = tasks_fixture[0]
mock_schedule_scan.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
json_payload = {
"provider_id": str(provider.id),
}
response = authenticated_client.post(
reverse("schedule-daily"), data=json_payload, format="json"
)
assert response.status_code == status.HTTP_202_ACCEPTED
def test_schedule_daily_provider_does_not_exist(self, authenticated_client):
json_payload = {
"provider_id": "4846c2f9-84b2-442b-94dd-3082e8eb9584",
}
response = authenticated_client.post(
reverse("schedule-daily"), data=json_payload, format="json"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
+87 -7
View File
@@ -1,5 +1,5 @@
import json
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta, timezone
from django.conf import settings
from django.contrib.auth import authenticate
@@ -14,14 +14,10 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.tokens import RefreshToken
from api.models import (
StateChoices,
User,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
Task,
Resource,
ResourceTag,
Finding,
@@ -32,10 +28,13 @@ from api.models import (
RoleProviderGroupRelationship,
UserRoleRelationship,
ComplianceOverview,
Scan,
StateChoices,
Task,
User,
)
from api.rls import Tenant
# Tokens
@@ -476,6 +475,12 @@ class ProviderGroupSerializer(RLSSerializer, BaseWriteSerializer):
}
class ProviderGroupIncludedSerializer(RLSSerializer, BaseWriteSerializer):
class Meta:
model = ProviderGroup
fields = ["id", "name"]
class ProviderGroupUpdateSerializer(RLSSerializer, BaseWriteSerializer):
"""
Serializer for updating the ProviderGroup model.
@@ -539,6 +544,10 @@ class ProviderSerializer(RLSSerializer):
provider = ProviderEnumSerializerField()
connection = serializers.SerializerMethodField(read_only=True)
included_serializers = {
"provider_groups": "api.v1.serializers.ProviderGroupIncludedSerializer",
}
class Meta:
model = Provider
fields = [
@@ -624,9 +633,11 @@ class ScanSerializer(RLSSerializer):
"duration",
"provider",
"task",
"inserted_at",
"started_at",
"completed_at",
"scheduled_at",
"next_scan_at",
"url",
]
@@ -795,6 +806,14 @@ class FindingSerializer(RLSSerializer):
}
class FindingDynamicFilterSerializer(serializers.Serializer):
services = serializers.ListField(child=serializers.CharField(), allow_empty=True)
regions = serializers.ListField(child=serializers.CharField(), allow_empty=True)
class Meta:
resource_name = "finding-dynamic-filters"
# Provider secrets
class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
@staticmethod
@@ -1517,7 +1536,7 @@ class OverviewProviderSerializer(serializers.Serializer):
resources = serializers.SerializerMethodField(read_only=True)
class JSONAPIMeta:
resource_name = "provider-overviews"
resource_name = "providers-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
@@ -1553,3 +1572,64 @@ class OverviewProviderSerializer(serializers.Serializer):
return {
"total": obj["total_resources"],
}
class OverviewFindingSerializer(serializers.Serializer):
id = serializers.CharField(default="n/a")
new = serializers.IntegerField()
changed = serializers.IntegerField()
unchanged = serializers.IntegerField()
fail_new = serializers.IntegerField()
fail_changed = serializers.IntegerField()
pass_new = serializers.IntegerField()
pass_changed = serializers.IntegerField()
muted_new = serializers.IntegerField()
muted_changed = serializers.IntegerField()
total = serializers.IntegerField()
_pass = serializers.IntegerField()
fail = serializers.IntegerField()
muted = serializers.IntegerField()
class JSONAPIMeta:
resource_name = "findings-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["pass"] = self.fields.pop("_pass")
class OverviewSeveritySerializer(serializers.Serializer):
id = serializers.CharField(default="n/a")
critical = serializers.IntegerField()
high = serializers.IntegerField()
medium = serializers.IntegerField()
low = serializers.IntegerField()
informational = serializers.IntegerField()
class JSONAPIMeta:
resource_name = "findings-severity-overview"
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
# Schedules
class ScheduleDailyCreateSerializer(serializers.Serializer):
provider_id = serializers.UUIDField(required=True)
class JSONAPIMeta:
resource_name = "daily-schedules"
# TODO: DRY this when we have more time
def validate(self, data):
if hasattr(self, "initial_data"):
initial_data = set(self.initial_data.keys()) - {"id", "type"}
unknown_keys = initial_data - set(self.fields.keys())
if unknown_keys:
raise ValidationError(f"Invalid fields: {unknown_keys}")
return data
+12 -10
View File
@@ -1,20 +1,12 @@
from django.urls import path, include
from django.urls import include, path
from drf_spectacular.views import SpectacularRedocView
from rest_framework_nested import routers
from api.v1.views import (
CustomTokenObtainView,
CustomTokenRefreshView,
SchemaView,
UserViewSet,
TenantViewSet,
TenantMembersViewSet,
MembershipViewSet,
ProviderViewSet,
ScanViewSet,
TaskViewSet,
ResourceViewSet,
FindingViewSet,
MembershipViewSet,
ProviderGroupViewSet,
ProviderSecretViewSet,
InvitationViewSet,
@@ -24,6 +16,15 @@ from api.v1.views import (
UserRoleRelationshipView,
OverviewViewSet,
ComplianceOverviewViewSet,
ProviderViewSet,
ResourceViewSet,
ScanViewSet,
ScheduleViewSet,
SchemaView,
TaskViewSet,
TenantMembersViewSet,
TenantViewSet,
UserViewSet,
)
router = routers.DefaultRouter(trailing_slash=False)
@@ -41,6 +42,7 @@ router.register(
r"compliance-overviews", ComplianceOverviewViewSet, basename="complianceoverview"
)
router.register(r"overviews", OverviewViewSet, basename="overview")
router.register(r"schedules", ScheduleViewSet, basename="schedule")
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
tenants_router.register(
+276 -60
View File
@@ -1,22 +1,23 @@
from celery.result import AsyncResult
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 Prefetch, Subquery, OuterRef, Count, Q, F
from django.db.models import Count, F, OuterRef, Prefetch, Q, Subquery, Sum
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from drf_spectacular.settings import spectacular_settings
from drf_spectacular_jsonapi.schemas.openapi import JsonApiAutoSchema
from drf_spectacular.utils import (
extend_schema,
extend_schema_view,
OpenApiParameter,
OpenApiResponse,
OpenApiTypes,
extend_schema,
extend_schema_view,
)
from drf_spectacular.views import SpectacularAPIView
from rest_framework import status, permissions
from rest_framework import permissions, status
from rest_framework.decorators import action
from rest_framework.exceptions import (
MethodNotAllowed,
@@ -24,46 +25,57 @@ from rest_framework.exceptions import (
PermissionDenied,
ValidationError,
)
from rest_framework.generics import get_object_or_404, GenericAPIView
from rest_framework.generics import GenericAPIView, get_object_or_404
from rest_framework_json_api.views import RelationshipView, Response
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework.permissions import SAFE_METHODS
from api.base_views import BaseTenantViewset, BaseRLSViewSet, BaseUserViewset
from tasks.beat import schedule_provider_scan
from tasks.tasks import (
check_provider_connection_task,
delete_provider_task,
perform_scan_summary_task,
perform_scan_task,
)
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
from api.db_router import MainRouter
from api.filters import (
ComplianceOverviewFilter,
FindingFilter,
InvitationFilter,
MembershipFilter,
ProviderFilter,
ProviderGroupFilter,
TenantFilter,
MembershipFilter,
ScanFilter,
TaskFilter,
ResourceFilter,
FindingFilter,
ProviderSecretFilter,
InvitationFilter,
ResourceFilter,
ScanFilter,
ScanSummaryFilter,
TaskFilter,
TenantFilter,
UserFilter,
RoleFilter,
ComplianceOverviewFilter,
)
from api.models import (
StatusChoices,
User,
UserRoleRelationship,
ComplianceOverview,
Finding,
Invitation,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
Task,
Resource,
Finding,
ProviderSecret,
Invitation,
Role,
RoleProviderGroupRelationship,
ComplianceOverview,
Resource,
Scan,
ScanSummary,
SeverityChoices,
StateChoices,
Task,
)
from api.pagination import ComplianceOverviewPagination
from api.rbac.permissions import DISABLE_RBAC, HasPermissions, Permissions
@@ -77,13 +89,24 @@ from api.v1.serializers import (
UserCreateSerializer,
UserUpdateSerializer,
UserRoleRelationshipSerializer,
ComplianceOverviewFullSerializer,
ComplianceOverviewSerializer,
FindingDynamicFilterSerializer,
FindingSerializer,
InvitationAcceptSerializer,
InvitationCreateSerializer,
InvitationSerializer,
InvitationUpdateSerializer,
MembershipSerializer,
OverviewFindingSerializer,
OverviewProviderSerializer,
OverviewSeveritySerializer,
ProviderCreateSerializer,
ProviderGroupMembershipUpdateSerializer,
ProviderGroupSerializer,
ProviderGroupUpdateSerializer,
ProviderGroupMembershipUpdateSerializer,
RoleProviderGroupRelationshipSerializer,
ProviderSerializer,
ProviderCreateSerializer,
ProviderUpdateSerializer,
TenantSerializer,
TaskSerializer,
@@ -91,28 +114,16 @@ from api.v1.serializers import (
ScanCreateSerializer,
ScanUpdateSerializer,
ResourceSerializer,
FindingSerializer,
ProviderSecretSerializer,
ProviderSecretUpdateSerializer,
ProviderSecretCreateSerializer,
InvitationSerializer,
InvitationCreateSerializer,
InvitationUpdateSerializer,
InvitationAcceptSerializer,
RoleSerializer,
RoleCreateSerializer,
RoleUpdateSerializer,
ComplianceOverviewSerializer,
ComplianceOverviewFullSerializer,
OverviewProviderSerializer,
)
from tasks.beat import schedule_provider_scan
from tasks.tasks import (
check_provider_connection_task,
delete_provider_task,
perform_scan_task,
ScheduleDailyCreateSerializer,
)
CACHE_DECORATOR = cache_control(
max_age=django_settings.CACHE_MAX_AGE,
stale_while_revalidate=django_settings.CACHE_STALE_WHILE_REVALIDATE,
@@ -920,7 +931,10 @@ class ProviderViewSet(BaseRLSViewSet):
)
def destroy(self, request, *args, pk=None, **kwargs):
get_object_or_404(Provider, pk=pk)
provider = get_object_or_404(Provider, pk=pk)
provider.is_deleted = True
provider.save()
with transaction.atomic():
task = delete_provider_task.delay(
provider_id=pk, tenant_id=request.tenant_id
@@ -937,14 +951,6 @@ class ProviderViewSet(BaseRLSViewSet):
},
)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
provider = serializer.save()
# Schedule a daily scan for the new provider
schedule_provider_scan(provider)
return Response(data=serializer.data, status=status.HTTP_201_CREATED)
@extend_schema_view(
list=extend_schema(
@@ -1059,12 +1065,18 @@ class ScanViewSet(BaseRLSViewSet):
with transaction.atomic():
scan = input_serializer.save()
with transaction.atomic():
task = perform_scan_task.delay(
tenant_id=request.tenant_id,
scan_id=str(scan.id),
provider_id=str(scan.provider_id),
# Disabled for now
# checks_to_execute=scan.scanner_args.get("checks_to_execute"),
task = perform_scan_task.apply_async(
kwargs={
"tenant_id": request.tenant_id,
"scan_id": str(scan.id),
"provider_id": str(scan.provider_id),
# Disabled for now
# checks_to_execute=scan.scanner_args.get("checks_to_execute"),
},
link=perform_scan_summary_task.si(
tenant_id=request.tenant_id,
scan_id=str(scan.id),
),
)
scan.task_id = task.id
@@ -1259,6 +1271,13 @@ class ResourceViewSet(BaseRLSViewSet):
summary="Retrieve data from a specific finding",
description="Fetch detailed information about a specific finding by its ID.",
),
findings_services_regions=extend_schema(
tags=["Finding"],
summary="Retrieve the services and regions that are impacted by findings",
description="Fetch services and regions affected in findings.",
responses={201: OpenApiResponse(response=MembershipSerializer)},
filters=True,
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
@@ -1293,6 +1312,12 @@ class FindingViewSet(BaseRLSViewSet):
self.required_permissions = ResourceViewSet.required_permissions
super().initial(request, *args, **kwargs)
def get_serializer_class(self):
if self.action == "findings_services_regions":
return FindingDynamicFilterSerializer
return super().get_serializer_class()
def get_queryset(self):
user = self.request.user
user_roles = user.roles.all()
@@ -1343,6 +1368,27 @@ class FindingViewSet(BaseRLSViewSet):
return None
return datetime_to_uuid7(inserted_at)
@action(detail=False, methods=["get"], url_name="findings_services_regions")
def findings_services_regions(self, request):
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
result = filtered_queryset.aggregate(
services=ArrayAgg("resources__service", flat=True, distinct=True),
regions=ArrayAgg("resources__region", flat=True, distinct=True),
)
if result["services"] is None:
result["services"] = []
if result["regions"] is None:
result["regions"] = []
serializer = self.get_serializer(
data=result,
)
serializer.is_valid(raise_exception=True)
return Response(data=serializer.data, status=status.HTTP_200_OK)
@extend_schema_view(
list=extend_schema(
@@ -1770,26 +1816,67 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
@extend_schema(tags=["Overview"])
@extend_schema_view(
providers=extend_schema(
summary="List aggregated overview data for providers",
description="Fetch aggregated summaries of the latest findings and resources for each provider. "
"This includes counts of passed, failed, and manual findings, as well as the total number "
"of resources managed by each provider.",
summary="Get aggregated provider data",
description=(
"Retrieve an aggregated overview of findings and resources grouped by providers. "
"The response includes the count of passed, failed, and manual findings, along with "
"the total number of resources managed by each provider. Only the latest findings for "
"each provider are considered in the aggregation to ensure accurate and up-to-date insights."
),
),
findings=extend_schema(
summary="Get aggregated findings data",
description=(
"Fetch aggregated findings data across all providers, grouped by various metrics such as "
"passed, failed, muted, and total findings. This endpoint calculates summary statistics "
"based on the latest scans for each provider and applies any provided filters, such as "
"region, provider type, and scan date."
),
filters=True,
),
findings_severity=extend_schema(
summary="Get findings data by severity",
description=(
"Retrieve an aggregated summary of findings grouped by severity levels, such as low, medium, "
"high, and critical. The response includes the total count of findings for each severity, "
"considering only the latest scans for each provider. Additional filters can be applied to "
"narrow down results by region, provider type, or other attributes."
),
filters=True,
),
)
@method_decorator(CACHE_DECORATOR, name="list")
class OverviewViewSet(BaseRLSViewSet):
queryset = ComplianceOverview.objects.all()
http_method_names = ["get"]
ordering = ["compliance_id"]
ordering = ["-id"]
def get_queryset(self):
return Finding.objects.all()
if self.action == "providers":
return Finding.objects.all()
elif self.action == "findings":
return ScanSummary.objects.all()
elif self.action == "findings_severity":
return ScanSummary.objects.all()
else:
return super().get_queryset()
def get_serializer_class(self):
if self.action == "providers":
return OverviewProviderSerializer
elif self.action == "findings":
return OverviewFindingSerializer
elif self.action == "findings_severity":
return OverviewSeveritySerializer
return super().get_serializer_class()
def get_filterset_class(self):
if self.action == "providers":
return None
elif self.action in ["findings", "findings_severity"]:
return ScanSummaryFilter
return None
@extend_schema(exclude=True)
def list(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
@@ -1841,7 +1928,7 @@ class OverviewViewSet(BaseRLSViewSet):
for res in resources_aggregated
if res["provider__provider"] == provider
),
0, # Default to 0 if no resources are found
0,
)
overview.append(
{
@@ -1857,3 +1944,132 @@ class OverviewViewSet(BaseRLSViewSet):
serializer = OverviewProviderSerializer(overview, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_name="findings")
def findings(self, request):
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
latest_scan_subquery = (
Scan.objects.filter(
state=StateChoices.COMPLETED, provider_id=OuterRef("scan__provider_id")
)
.order_by("-id")
.values("id")[:1]
)
annotated_queryset = filtered_queryset.annotate(
latest_scan_id=Subquery(latest_scan_subquery)
)
filtered_queryset = annotated_queryset.filter(scan_id=F("latest_scan_id"))
aggregated_totals = filtered_queryset.aggregate(
_pass=Sum("_pass") or 0,
fail=Sum("fail") or 0,
muted=Sum("muted") or 0,
total=Sum("total") or 0,
new=Sum("new") or 0,
changed=Sum("changed") or 0,
unchanged=Sum("unchanged") or 0,
fail_new=Sum("fail_new") or 0,
fail_changed=Sum("fail_changed") or 0,
pass_new=Sum("pass_new") or 0,
pass_changed=Sum("pass_changed") or 0,
muted_new=Sum("muted_new") or 0,
muted_changed=Sum("muted_changed") or 0,
)
for key in aggregated_totals:
if aggregated_totals[key] is None:
aggregated_totals[key] = 0
serializer = self.get_serializer(aggregated_totals)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_name="findings_severity")
def findings_severity(self, request):
queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)
latest_scan_subquery = (
Scan.objects.filter(
state=StateChoices.COMPLETED, provider_id=OuterRef("scan__provider_id")
)
.order_by("-id")
.values("id")[:1]
)
annotated_queryset = filtered_queryset.annotate(
latest_scan_id=Subquery(latest_scan_subquery)
)
filtered_queryset = annotated_queryset.filter(scan_id=F("latest_scan_id"))
severity_counts = (
filtered_queryset.values("severity")
.annotate(count=Sum("total"))
.order_by("severity")
)
severity_data = {sev[0]: 0 for sev in SeverityChoices}
for item in severity_counts:
severity_data[item["severity"]] = item["count"]
serializer = OverviewSeveritySerializer(severity_data)
return Response(serializer.data, status=status.HTTP_200_OK)
@extend_schema(tags=["Schedule"])
@extend_schema_view(
daily=extend_schema(
summary="Create a daily schedule scan for a given provider",
description="Schedules a daily scan for the specified provider. This endpoint creates a periodic task "
"that will execute a scan every 24 hours.",
request=ScheduleDailyCreateSerializer,
responses={202: OpenApiResponse(response=TaskSerializer)},
)
)
class ScheduleViewSet(BaseRLSViewSet):
# TODO: change to Schedule when implemented
queryset = Task.objects.none()
http_method_names = ["post"]
def get_queryset(self):
return super().get_queryset()
def get_serializer_class(self):
if self.action == "daily":
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
return ScheduleDailyCreateSerializer
return super().get_serializer_class()
@extend_schema(exclude=True)
def create(self, request, *args, **kwargs):
raise MethodNotAllowed(method="POST")
@action(detail=False, methods=["post"], url_name="daily")
def daily(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
provider_id = serializer.validated_data["provider_id"]
provider_instance = get_object_or_404(Provider, pk=provider_id)
with transaction.atomic():
task = schedule_provider_scan(provider_instance)
prowler_task = Task.objects.get(id=task.id)
self.response_serializer_class = TaskSerializer
output_serializer = self.get_serializer(prowler_task)
return Response(
data=output_serializer.data,
status=status.HTTP_202_ACCEPTED,
headers={
"Content-Location": reverse(
"task-detail", kwargs={"pk": prowler_task.id}
)
},
)
+3 -2
View File
@@ -3,7 +3,7 @@ from celery import Celery, Task
celery_app = Celery("tasks")
celery_app.config_from_object("django.conf:settings", namespace="CELERY")
celery_app.conf.update(result_extended=True)
celery_app.conf.update(result_extended=True, result_expires=None)
celery_app.autodiscover_tasks(["api"])
@@ -20,9 +20,10 @@ class RLSTask(Task):
shadow=None,
**options,
):
from api.models import Task as APITask
from django_celery_results.models import TaskResult
from api.models import Task as APITask
result = super().apply_async(
args=args,
kwargs=kwargs,
@@ -9,3 +9,5 @@ CELERY_RESULT_BACKEND = "django-db"
CELERY_TASK_TRACK_STARTED = True
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_DEADLOCK_ATTEMPTS = env.int("DJANGO_CELERY_DEADLOCK_ATTEMPTS", default=5)
+26 -4
View File
@@ -1,7 +1,9 @@
import json
from datetime import datetime, timedelta, timezone
from django.utils import timezone
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from django_celery_beat.models import IntervalSchedule, PeriodicTask
from rest_framework_json_api.serializers import ValidationError
from tasks.tasks import perform_scheduled_scan_task
from api.models import Provider
@@ -16,7 +18,7 @@ def schedule_provider_scan(provider_instance: Provider):
task_name = f"scan-perform-scheduled-{provider_instance.id}"
# Schedule the task
PeriodicTask.objects.create(
_, created = PeriodicTask.objects.get_or_create(
interval=schedule,
name=task_name,
task="scan-perform-scheduled",
@@ -26,6 +28,26 @@ def schedule_provider_scan(provider_instance: Provider):
"provider_id": str(provider_instance.id),
}
),
start_time=provider_instance.inserted_at + timezone.timedelta(hours=24),
one_off=False,
defaults={
"start_time": datetime.now(timezone.utc) + timedelta(hours=24),
},
)
if not created:
raise ValidationError(
[
{
"detail": "There is already a scheduled scan for this provider.",
"status": 400,
"source": {"pointer": "/data/attributes/provider_id"},
"code": "invalid",
}
]
)
return perform_scheduled_scan_task.apply_async(
kwargs={
"tenant_id": str(provider_instance.tenant_id),
"provider_id": str(provider_instance.id),
},
)
+38 -12
View File
@@ -1,25 +1,51 @@
from celery.utils.log import get_task_logger
from django.db import transaction
from api.db_utils import batch_delete
from api.models import Finding, Provider, Resource, Scan, ScanSummary
logger = get_task_logger(__name__)
def delete_instance(model, pk: str):
def delete_provider(pk: str):
"""
Deletes an instance of the specified model.
This function retrieves an instance of the provided model using its primary key
and deletes it from the database.
Gracefully deletes an instance of a provider along with its related data.
Args:
model (Model): The Django model class from which to delete an instance.
pk (str): The primary key of the instance to delete.
pk (str): The primary key of the Provider instance to delete.
Returns:
tuple: A tuple containing the number of objects deleted and a dictionary
with the count of deleted objects per model,
including related models if applicable.
dict: A dictionary with the count of deleted objects per model,
including related models.
Raises:
model.DoesNotExist: If no instance with the provided primary key exists.
Provider.DoesNotExist: If no instance with the provided primary key exists.
"""
return model.objects.get(pk=pk).delete()
instance = Provider.all_objects.get(pk=pk)
deletion_summary = {}
with transaction.atomic():
# Delete Scan Summaries
scan_summaries_qs = ScanSummary.all_objects.filter(scan__provider=instance)
_, scans_summ_summary = batch_delete(scan_summaries_qs)
deletion_summary.update(scans_summ_summary)
# Delete Findings
findings_qs = Finding.all_objects.filter(scan__provider=instance)
_, findings_summary = batch_delete(findings_qs)
deletion_summary.update(findings_summary)
# Delete Resources
resources_qs = Resource.all_objects.filter(provider=instance)
_, resources_summary = batch_delete(resources_qs)
deletion_summary.update(resources_summary)
# Delete Scans
scans_qs = Scan.all_objects.filter(provider=instance)
_, scans_summary = batch_delete(scans_qs)
deletion_summary.update(scans_summary)
provider_deleted_count, provider_summary = instance.delete()
deletion_summary.update(provider_summary)
return deletion_summary
+223 -54
View File
@@ -3,8 +3,9 @@ from copy import deepcopy
from datetime import datetime, timezone
from celery.utils.log import get_task_logger
from prowler.lib.outputs.finding import Finding as ProwlerFinding
from prowler.lib.scan.scan import Scan as ProwlerScan
from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
from django.db import IntegrityError, OperationalError
from django.db.models import Case, Count, IntegerField, Sum, When
from api.compliance import (
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
@@ -12,17 +13,20 @@ from api.compliance import (
)
from api.db_utils import tenant_transaction
from api.models import (
Provider,
Scan,
ComplianceOverview,
Finding,
Provider,
Resource,
ResourceTag,
StatusChoices as FindingStatus,
Scan,
ScanSummary,
StateChoices,
ComplianceOverview,
)
from api.models import StatusChoices as FindingStatus
from api.utils import initialize_prowler_provider
from api.v1.serializers import ScanTaskSerializer
from prowler.lib.outputs.finding import Finding as ProwlerFinding
from prowler.lib.scan.scan import Scan as ProwlerScan
logger = get_task_logger(__name__)
@@ -149,43 +153,57 @@ def perform_prowler_scan(
last_status_cache = {}
for progress, findings in prowler_scan.scan():
with tenant_transaction(tenant_id):
for finding in findings:
# Process resource
resource_uid = finding.resource_uid
if resource_uid not in resource_cache:
# Get or create the resource
resource_instance, _ = Resource.objects.get_or_create(
tenant_id=tenant_id,
provider=provider_instance,
uid=resource_uid,
defaults={
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
"name": finding.resource_name,
},
)
resource_cache[resource_uid] = resource_instance
else:
resource_instance = resource_cache[resource_uid]
for finding in findings:
for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
try:
with tenant_transaction(tenant_id):
# Process resource
resource_uid = finding.resource_uid
if resource_uid not in resource_cache:
# Get or create the resource
resource_instance, _ = Resource.objects.get_or_create(
tenant_id=tenant_id,
provider=provider_instance,
uid=resource_uid,
defaults={
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
"name": finding.resource_name,
},
)
resource_cache[resource_uid] = resource_instance
else:
resource_instance = resource_cache[resource_uid]
# Update resource fields if necessary
updated_fields = []
if resource_instance.region != finding.region:
resource_instance.region = finding.region
updated_fields.append("region")
if resource_instance.service != finding.service_name:
resource_instance.service = finding.service_name
updated_fields.append("service")
if resource_instance.type != finding.resource_type:
resource_instance.type = finding.resource_type
updated_fields.append("type")
if updated_fields:
resource_instance.save(update_fields=updated_fields)
# Update resource fields if necessary
updated_fields = []
if resource_instance.region != finding.region:
resource_instance.region = finding.region
updated_fields.append("region")
if resource_instance.service != finding.service_name:
resource_instance.service = finding.service_name
updated_fields.append("service")
if resource_instance.type != finding.resource_type:
resource_instance.type = finding.resource_type
updated_fields.append("type")
if updated_fields:
with tenant_transaction(tenant_id):
resource_instance.save(update_fields=updated_fields)
except (OperationalError, IntegrityError) as db_err:
if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
logger.warning(
f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
f"detected when processing resource {resource_uid} on scan {scan_id}. Retrying..."
)
time.sleep(0.1 * (2**attempt))
continue
else:
raise db_err
# Update tags
tags = []
# Update tags
tags = []
with tenant_transaction(tenant_id):
for key, value in finding.resource_tags.items():
tag_key = (key, value)
if tag_key not in tag_cache:
@@ -198,11 +216,10 @@ def perform_prowler_scan(
tags.append(tag_instance)
resource_instance.upsert_or_delete_tags(tags=tags)
unique_resources.add(
(resource_instance.uid, resource_instance.region)
)
unique_resources.add((resource_instance.uid, resource_instance.region))
# Process finding
# Process finding
with tenant_transaction(tenant_id):
finding_uid = finding.uid
if finding_uid not in last_status_cache:
most_recent_finding = (
@@ -239,15 +256,15 @@ def perform_prowler_scan(
)
finding_instance.add_resources([resource_instance])
# Update compliance data if applicable
if not generate_compliance or finding.status.value == "MUTED":
continue
# Update compliance data if applicable
if not generate_compliance or 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
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 progress
with tenant_transaction(tenant_id):
@@ -268,7 +285,7 @@ def perform_prowler_scan(
scan_instance.unique_resource_count = len(unique_resources)
scan_instance.save()
if generate_compliance:
if exception is None and generate_compliance:
try:
regions = prowler_provider.get_regions()
except AttributeError:
@@ -321,3 +338,155 @@ def perform_prowler_scan(
serializer = ScanTaskSerializer(instance=scan_instance)
return serializer.data
def aggregate_findings(tenant_id: str, scan_id: str):
"""
Aggregates findings for a given scan and stores the results in the ScanSummary table.
This function retrieves all findings associated with a given `scan_id` and calculates various
metrics such as counts of failed, passed, and muted findings, as well as their deltas (new,
changed, unchanged). The results are grouped by `check_id`, `service`, `severity`, and `region`.
These aggregated metrics are then stored in the `ScanSummary` table.
Args:
tenant_id (str): The ID of the tenant to which the scan belongs.
scan_id (str): The ID of the scan for which findings need to be aggregated.
Aggregated Metrics:
- fail: Total number of failed findings.
- _pass: Total number of passed findings.
- muted: Total number of muted findings.
- total: Total number of findings.
- new: Total number of new findings.
- changed: Total number of changed findings.
- unchanged: Total number of unchanged findings.
- fail_new: Failed findings with a delta of 'new'.
- fail_changed: Failed findings with a delta of 'changed'.
- pass_new: Passed findings with a delta of 'new'.
- pass_changed: Passed findings with a delta of 'changed'.
- muted_new: Muted findings with a delta of 'new'.
- muted_changed: Muted findings with a delta of 'changed'.
"""
with tenant_transaction(tenant_id):
findings = Finding.objects.filter(scan_id=scan_id)
aggregation = findings.values(
"check_id",
"resources__service",
"severity",
"resources__region",
).annotate(
fail=Sum(
Case(
When(status="FAIL", then=1),
default=0,
output_field=IntegerField(),
)
),
_pass=Sum(
Case(
When(status="PASS", then=1),
default=0,
output_field=IntegerField(),
)
),
muted=Sum(
Case(
When(status="MUTED", then=1),
default=0,
output_field=IntegerField(),
)
),
total=Count("id"),
new=Sum(
Case(
When(delta="new", then=1),
default=0,
output_field=IntegerField(),
)
),
changed=Sum(
Case(
When(delta="changed", then=1),
default=0,
output_field=IntegerField(),
)
),
unchanged=Sum(
Case(
When(delta__isnull=True, then=1),
default=0,
output_field=IntegerField(),
)
),
fail_new=Sum(
Case(
When(delta="new", status="FAIL", then=1),
default=0,
output_field=IntegerField(),
)
),
fail_changed=Sum(
Case(
When(delta="changed", status="FAIL", then=1),
default=0,
output_field=IntegerField(),
)
),
pass_new=Sum(
Case(
When(delta="new", status="PASS", then=1),
default=0,
output_field=IntegerField(),
)
),
pass_changed=Sum(
Case(
When(delta="changed", status="PASS", then=1),
default=0,
output_field=IntegerField(),
)
),
muted_new=Sum(
Case(
When(delta="new", status="MUTED", then=1),
default=0,
output_field=IntegerField(),
)
),
muted_changed=Sum(
Case(
When(delta="changed", status="MUTED", then=1),
default=0,
output_field=IntegerField(),
)
),
)
with tenant_transaction(tenant_id):
scan_aggregations = {
ScanSummary(
tenant_id=tenant_id,
scan_id=scan_id,
check_id=agg["check_id"],
service=agg["resources__service"],
severity=agg["severity"],
region=agg["resources__region"],
fail=agg["fail"],
_pass=agg["_pass"],
muted=agg["muted"],
total=agg["total"],
new=agg["new"],
changed=agg["changed"],
unchanged=agg["unchanged"],
fail_new=agg["fail_new"],
fail_changed=agg["fail_changed"],
pass_new=agg["pass_new"],
pass_changed=agg["pass_changed"],
muted_new=agg["muted_new"],
muted_changed=agg["muted_changed"],
)
for agg in aggregation
}
ScanSummary.objects.bulk_create(scan_aggregations, batch_size=3000)
+30 -6
View File
@@ -1,12 +1,15 @@
from datetime import datetime, timedelta, timezone
from celery import shared_task
from config.celery import RLSTask
from django_celery_beat.models import PeriodicTask
from tasks.jobs.connection import check_provider_connection
from tasks.jobs.deletion import delete_provider
from tasks.jobs.scan import aggregate_findings, perform_prowler_scan
from api.db_utils import tenant_transaction
from api.decorators import set_tenant
from api.models import Provider, Scan
from config.celery import RLSTask
from tasks.jobs.connection import check_provider_connection
from tasks.jobs.deletion import delete_instance
from tasks.jobs.scan import perform_prowler_scan
@shared_task(base=RLSTask, name="provider-connection-check")
@@ -32,6 +35,8 @@ def delete_provider_task(provider_id: str):
"""
Task to delete a specific Provider instance.
It will delete in batches all the related resources first.
Args:
provider_id (str): The primary key of the `Provider` instance to be deleted.
@@ -41,7 +46,7 @@ def delete_provider_task(provider_id: str):
- A dictionary with the count of deleted instances per model,
including related models if cascading deletes were triggered.
"""
return delete_instance(model=Provider, pk=provider_id)
return delete_provider(pk=provider_id)
@shared_task(base=RLSTask, name="scan-perform", queue="scans")
@@ -96,17 +101,36 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
with tenant_transaction(tenant_id):
provider_instance = Provider.objects.get(pk=provider_id)
periodic_task_instance = PeriodicTask.objects.get(
name=f"scan-perform-scheduled-{provider_id}"
)
next_scan_date = datetime.combine(
datetime.now(timezone.utc), periodic_task_instance.start_time.time()
) + timedelta(hours=24)
scan_instance = Scan.objects.create(
tenant_id=tenant_id,
name="Daily scheduled scan",
provider=provider_instance,
trigger=Scan.TriggerChoices.SCHEDULED,
next_scan_at=next_scan_date,
task_id=task_id,
)
return perform_prowler_scan(
result = perform_prowler_scan(
tenant_id=tenant_id,
scan_id=str(scan_instance.id),
provider_id=provider_id,
)
perform_scan_summary_task.apply_async(
kwargs={
"tenant_id": tenant_id,
"scan_id": str(scan_instance.id),
}
)
return result
@shared_task(name="scan-summary")
def perform_scan_summary_task(tenant_id: str, scan_id: str):
return aggregate_findings(tenant_id=tenant_id, scan_id=scan_id)
+53
View File
@@ -0,0 +1,53 @@
import json
from unittest.mock import patch
import pytest
from django_celery_beat.models import IntervalSchedule, PeriodicTask
from rest_framework_json_api.serializers import ValidationError
from tasks.beat import schedule_provider_scan
@pytest.mark.django_db
class TestScheduleProviderScan:
def test_schedule_provider_scan_success(self, providers_fixture):
provider_instance, *_ = providers_fixture
with patch(
"tasks.tasks.perform_scheduled_scan_task.apply_async"
) as mock_apply_async:
result = schedule_provider_scan(provider_instance)
assert result is not None
mock_apply_async.assert_called_once_with(
kwargs={
"tenant_id": str(provider_instance.tenant_id),
"provider_id": str(provider_instance.id),
},
)
task_name = f"scan-perform-scheduled-{provider_instance.id}"
periodic_task = PeriodicTask.objects.get(name=task_name)
assert periodic_task is not None
assert periodic_task.interval.every == 24
assert periodic_task.interval.period == IntervalSchedule.HOURS
assert periodic_task.task == "scan-perform-scheduled"
assert json.loads(periodic_task.kwargs) == {
"tenant_id": str(provider_instance.tenant_id),
"provider_id": str(provider_instance.id),
}
def test_schedule_provider_scan_already_exists(self, providers_fixture):
provider_instance, *_ = providers_fixture
# First, schedule the scan
with patch("tasks.tasks.perform_scheduled_scan_task.apply_async"):
schedule_provider_scan(provider_instance)
# Now, try scheduling again, should raise ValidationError
with pytest.raises(ValidationError) as exc_info:
schedule_provider_scan(provider_instance)
assert "There is already a scheduled scan for this provider." in str(
exc_info.value
)
+3 -3
View File
@@ -1,15 +1,15 @@
import pytest
from django.core.exceptions import ObjectDoesNotExist
from tasks.jobs.deletion import delete_provider
from api.models import Provider
from tasks.jobs.deletion import delete_instance
@pytest.mark.django_db
class TestDeleteInstance:
def test_delete_instance_success(self, providers_fixture):
instance = providers_fixture[0]
result = delete_instance(Provider, instance.id)
result = delete_provider(instance.id)
assert result
with pytest.raises(ObjectDoesNotExist):
@@ -19,4 +19,4 @@ class TestDeleteInstance:
non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645"
with pytest.raises(ObjectDoesNotExist):
delete_instance(Provider, non_existent_pk)
delete_provider(non_existent_pk)
+14 -11
View File
@@ -1,19 +1,19 @@
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
import pytest
from api.models import (
StateChoices,
Severity,
Finding,
Resource,
StatusChoices,
Provider,
)
from tasks.jobs.scan import (
perform_prowler_scan,
_create_finding_delta,
_store_resources,
perform_prowler_scan,
)
from api.models import (
Finding,
Provider,
Resource,
Severity,
StateChoices,
StatusChoices,
)
@@ -358,3 +358,6 @@ class TestPerformScan:
assert resource == resource_instance
assert resource_uid_tuple == (resource_instance.uid, resource_instance.region)
# TODO Add tests for aggregations