feat(overviews): Add API overview endpoints for findings and severity (#5910)

This commit is contained in:
Víctor Fernández Poyatos
2024-11-27 15:51:05 +01:00
committed by GitHub
parent f576b24fc8
commit b69a0d5137
14 changed files with 1132 additions and 198 deletions
+48 -15
View File
@@ -4,47 +4,48 @@ 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,
ComplianceOverview,
Finding,
Invitation,
Membership,
Provider,
ProviderGroup,
ProviderSecret,
Resource,
ResourceTag,
Scan,
Task,
StateChoices,
Finding,
ScanSummary,
SeverityChoices,
StateChoices,
StatusChoices,
ProviderSecret,
Invitation,
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
@@ -57,6 +58,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
@@ -482,3 +490,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"],
}
+99 -20
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,
@@ -1482,4 +1484,81 @@ class Migration(migrations.Migration):
name="comp_ov_cp_id_req_fail_idx",
),
),
migrations.CreateModel(
name="ScanSummary",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("check_id", models.CharField(max_length=100)),
("service", models.TextField()),
(
"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)),
(
"scan",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="aggregations",
related_query_name="aggregation",
to="api.scan",
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "scan_summaries",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="scansummary",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_scansummary",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
migrations.AddConstraint(
model_name="scansummary",
constraint=models.UniqueConstraint(
fields=("tenant", "scan", "check_id", "service", "severity", "region"),
name="unique_scan_summary",
),
),
]
+63 -17
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
@@ -11,35 +11,33 @@ from django.core.validators import MinLengthValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_celery_results.models import TaskResult
from prowler.lib.check.models import Severity
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())
@@ -856,3 +854,51 @@ class ComplianceOverview(RowLevelSecurityProtectedModel):
class JSONAPIMeta:
resource_name = "compliance-overviews"
class ScanSummary(RowLevelSecurityProtectedModel):
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"
+440 -6
View File
@@ -780,16 +780,332 @@ paths:
schema:
$ref: '#/components/schemas/OpenApiResponseResponse'
description: ''
/api/v1/overviews/findings:
get:
operationId: overviews_findings_retrieve
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.
summary: Get aggregated findings data
parameters:
- in: query
name: fields[findings-overview]
schema:
type: array
items:
type: string
enum:
- id
- new
- changed
- unchanged
- fail_new
- fail_changed
- pass_new
- pass_changed
- muted_new
- muted_changed
- total
- fail
- muted
- pass
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[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[muted_findings]
schema:
type: boolean
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
- in: query
name: filter[provider_type]
schema:
type: string
enum:
- aws
- azure
- gcp
- kubernetes
description: |-
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
enum:
- aws
- azure
- gcp
- kubernetes
description: |-
Multiple values may be separated by commas.
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
explode: false
style: form
- 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
- name: filter[search]
required: false
in: query
description: A search term.
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:
- id
- -id
- new
- -new
- changed
- -changed
- unchanged
- -unchanged
- fail_new
- -fail_new
- fail_changed
- -fail_changed
- pass_new
- -pass_new
- pass_changed
- -pass_changed
- muted_new
- -muted_new
- muted_changed
- -muted_changed
- total
- -total
- fail
- -fail
- muted
- -muted
- pass
- -pass
explode: false
tags:
- Overview
security:
- jwtAuth: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/OverviewFindingResponse'
description: ''
/api/v1/overviews/findings_severity:
get:
operationId: overviews_findings_severity_retrieve
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.
summary: Get findings data by severity
parameters:
- in: query
name: fields[findings-severity-overview]
schema:
type: array
items:
type: string
enum:
- id
- critical
- high
- medium
- low
- informational
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[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[muted_findings]
schema:
type: boolean
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
- in: query
name: filter[provider_type]
schema:
type: string
enum:
- aws
- azure
- gcp
- kubernetes
description: |-
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
enum:
- aws
- azure
- gcp
- kubernetes
description: |-
Multiple values may be separated by commas.
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
explode: false
style: form
- 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
- name: filter[search]
required: false
in: query
description: A search term.
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:
- id
- -id
- critical
- -critical
- high
- -high
- medium
- -medium
- low
- -low
- informational
- -informational
explode: false
tags:
- Overview
security:
- jwtAuth: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/OverviewSeverityResponse'
description: ''
/api/v1/overviews/providers:
get:
operationId: overviews_providers_retrieve
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: List aggregated overview data for providers
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.
summary: Get aggregated provider data
parameters:
- in: query
name: fields[provider-overviews]
name: fields[providers-overview]
schema:
type: array
items:
@@ -4400,6 +4716,77 @@ components:
$ref: '#/components/schemas/Membership'
required:
- data
OverviewFinding:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
allOf:
- $ref: '#/components/schemas/OverviewFindingTypeEnum'
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:
id:
type: string
default: n/a
new:
type: integer
changed:
type: integer
unchanged:
type: integer
fail_new:
type: integer
fail_changed:
type: integer
pass_new:
type: integer
pass_changed:
type: integer
muted_new:
type: integer
muted_changed:
type: integer
total:
type: integer
fail:
type: integer
muted:
type: integer
pass:
type: integer
required:
- new
- changed
- unchanged
- fail_new
- fail_changed
- pass_new
- pass_changed
- muted_new
- muted_changed
- total
- fail
- muted
- pass
OverviewFindingResponse:
type: object
properties:
data:
$ref: '#/components/schemas/OverviewFinding'
required:
- data
OverviewFindingTypeEnum:
type: string
enum:
- findings-overview
OverviewProvider:
type: object
required:
@@ -4449,7 +4836,54 @@ components:
OverviewProviderTypeEnum:
type: string
enum:
- provider-overviews
- providers-overview
OverviewSeverity:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
allOf:
- $ref: '#/components/schemas/OverviewSeverityTypeEnum'
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:
id:
type: string
default: n/a
critical:
type: integer
high:
type: integer
medium:
type: integer
low:
type: integer
informational:
type: integer
required:
- critical
- high
- medium
- low
- informational
OverviewSeverityResponse:
type: object
properties:
data:
$ref: '#/components/schemas/OverviewSeverity'
required:
- data
OverviewSeverityTypeEnum:
type: string
enum:
- findings-severity-overview
PaginatedComplianceOverviewList:
type: object
required:
+11 -14
View File
@@ -1,29 +1,24 @@
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,
Invitation,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
ProviderSecret,
Invitation,
Scan,
User,
)
from api.rls import Tenant
from conftest import (
API_JSON_CONTENT_TYPE,
TEST_PASSWORD,
TEST_USER,
)
TODAY = str(datetime.today().date())
@@ -1794,7 +1789,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,
@@ -3009,9 +3004,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)
)
},
)
@@ -3262,3 +3257,5 @@ class TestOverviewViewSet:
assert response.json()["data"][0]["attributes"]["resources"]["total"] == len(
resources_fixture
)
# TODO Add more tests for the rest of overviews
+52 -11
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,24 +14,23 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.tokens import RefreshToken
from api.models import (
StateChoices,
User,
ComplianceOverview,
Finding,
Invitation,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
Task,
ProviderSecret,
Resource,
ResourceTag,
Finding,
ProviderSecret,
Invitation,
ComplianceOverview,
Scan,
StateChoices,
Task,
User,
)
from api.rls import Tenant
# Tokens
@@ -1234,7 +1233,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"}
@@ -1270,3 +1269,45 @@ 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"}
+190 -67
View File
@@ -2,20 +2,20 @@ from celery.result import AsyncResult
from django.conf import settings as django_settings
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.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,
@@ -23,82 +23,87 @@ 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 Response
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
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 BaseTenantViewset, BaseRLSViewSet, BaseUserViewset
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,
ComplianceOverviewFilter,
)
from api.models import (
StatusChoices,
User,
ComplianceOverview,
Finding,
Invitation,
Membership,
Provider,
ProviderGroup,
ProviderGroupMembership,
Scan,
Task,
Resource,
Finding,
ProviderSecret,
Invitation,
ComplianceOverview,
Resource,
Scan,
ScanSummary,
SeverityChoices,
StatusChoices,
Task,
User,
)
from api.pagination import ComplianceOverviewPagination
from api.rls import Tenant
from api.utils import validate_invitation
from api.uuid_utils import datetime_to_uuid7
from api.v1.serializers import (
TokenSerializer,
TokenRefreshSerializer,
UserSerializer,
UserCreateSerializer,
UserUpdateSerializer,
ComplianceOverviewFullSerializer,
ComplianceOverviewSerializer,
FindingSerializer,
InvitationAcceptSerializer,
InvitationCreateSerializer,
InvitationSerializer,
InvitationUpdateSerializer,
MembershipSerializer,
OverviewFindingSerializer,
OverviewProviderSerializer,
OverviewSeveritySerializer,
ProviderCreateSerializer,
ProviderGroupMembershipUpdateSerializer,
ProviderGroupSerializer,
ProviderGroupUpdateSerializer,
ProviderGroupMembershipUpdateSerializer,
ProviderSerializer,
ProviderCreateSerializer,
ProviderUpdateSerializer,
TenantSerializer,
TaskSerializer,
ScanSerializer,
ScanCreateSerializer,
ScanUpdateSerializer,
ResourceSerializer,
FindingSerializer,
ProviderSecretCreateSerializer,
ProviderSecretSerializer,
ProviderSecretUpdateSerializer,
ProviderSecretCreateSerializer,
InvitationSerializer,
InvitationCreateSerializer,
InvitationUpdateSerializer,
InvitationAcceptSerializer,
ComplianceOverviewSerializer,
ComplianceOverviewFullSerializer,
OverviewProviderSerializer,
)
from tasks.beat import schedule_provider_scan
from tasks.tasks import (
check_provider_connection_task,
delete_provider_task,
perform_scan_task,
ProviderSerializer,
ProviderUpdateSerializer,
ResourceSerializer,
ScanCreateSerializer,
ScanSerializer,
ScanUpdateSerializer,
TaskSerializer,
TenantSerializer,
TokenRefreshSerializer,
TokenSerializer,
UserCreateSerializer,
UserSerializer,
UserUpdateSerializer,
)
CACHE_DECORATOR = cache_control(
@@ -803,12 +808,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
@@ -1295,26 +1306,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")
@@ -1366,7 +1418,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(
{
@@ -1382,3 +1434,74 @@ 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(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(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)
+161 -7
View File
@@ -3,8 +3,7 @@ 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 django.db.models import Case, Count, IntegerField, Sum, When
from api.compliance import (
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
@@ -12,17 +11,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__)
@@ -268,7 +270,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 +323,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)
+9 -4
View File
@@ -1,12 +1,12 @@
from celery import shared_task
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 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")
@@ -110,3 +110,8 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
scan_id=str(scan_instance.id),
provider_id=provider_id,
)
@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)
+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