Compare commits

..

2 Commits

Author SHA1 Message Date
pedrooot aad4b13cab fix(ui): show Top Failed Requirements for compliances without section hierarchy
For compliances like RBI that have a flat structure (requirements directly
in framework without categories/sections), the Top Failed Sections chart
was showing empty because the logic only iterated over framework.categories.

This fix:
- Detects flat structure compliances (framework.requirements without categories)
- Shows 'Top Failed Requirements' for flat structures
- Keeps 'Top Failed Sections' for hierarchical structures (like ENS)
- Updates getTopFailedSections to return TopFailedResult with type metadata
- Adds dynamic title to TopFailedSectionsCard component
2025-12-05 13:20:37 +01:00
pedrooot 06aac82e96 feat(compliance): add needed changes for RBI azure 2025-12-05 12:01:58 +01:00
48 changed files with 769 additions and 1900 deletions
-1
View File
@@ -6,7 +6,6 @@ All notable changes to the **Prowler API** are documented in this file.
### Added
- New endpoint to retrieve an overview of the attack surfaces [(#9309)](https://github.com/prowler-cloud/prowler/pull/9309)
- New endpoint `GET /api/v1/overviews/findings_severity/timeseries` to retrieve daily aggregated findings by severity level [(#9363)](https://github.com/prowler-cloud/prowler/pull/9363)
- Lighthouse AI support for Amazon Bedrock API key [(#9343)](https://github.com/prowler-cloud/prowler/pull/9343)
- Exception handler for provider deletions during scans [(#9414)](https://github.com/prowler-cloud/prowler/pull/9414)
- Support to use admin credentials through the read replica database [(#9440)](https://github.com/prowler-cloud/prowler/pull/9440)
-63
View File
@@ -25,7 +25,6 @@ from api.db_utils import (
from api.models import (
AttackSurfaceOverview,
ComplianceRequirementOverview,
DailySeveritySummary,
Finding,
Integration,
Invitation,
@@ -796,68 +795,6 @@ class ScanSummaryFilter(FilterSet):
}
class DailySeveritySummaryFilter(FilterSet):
"""Filter for findings_severity/timeseries endpoint."""
MAX_DATE_RANGE_DAYS = 365
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="provider_id", lookup_expr="in")
provider_type = ChoiceFilter(
field_name="provider__provider", choices=Provider.ProviderChoices.choices
)
provider_type__in = ChoiceInFilter(
field_name="provider__provider", choices=Provider.ProviderChoices.choices
)
date_from = DateFilter(method="filter_noop")
date_to = DateFilter(method="filter_noop")
class Meta:
model = DailySeveritySummary
fields = ["provider_id"]
def filter_noop(self, queryset, name, value):
return queryset
def filter_queryset(self, queryset):
if not self.data.get("date_from"):
raise ValidationError(
[
{
"detail": "This query parameter is required.",
"status": "400",
"source": {"pointer": "filter[date_from]"},
"code": "required",
}
]
)
today = date.today()
date_from = self.form.cleaned_data.get("date_from")
date_to = min(self.form.cleaned_data.get("date_to") or today, today)
if (date_to - date_from).days > self.MAX_DATE_RANGE_DAYS:
raise ValidationError(
[
{
"detail": f"Date range cannot exceed {self.MAX_DATE_RANGE_DAYS} days.",
"status": "400",
"source": {"pointer": "filter[date_from]"},
"code": "invalid",
}
]
)
# View access
self.request._date_from = date_from
self.request._date_to = date_to
# Apply date filter (only lte for fill-forward logic)
queryset = queryset.filter(date__lte=date_to)
return super().filter_queryset(queryset)
class ScanSummarySeverityFilter(ScanSummaryFilter):
"""Filter for findings_severity ScanSummary endpoint - includes status filters"""
@@ -1,96 +0,0 @@
# Generated by Django 5.1.14 on 2025-12-03 13:38
import uuid
import django.db.models.deletion
from django.db import migrations, models
import api.rls
class Migration(migrations.Migration):
dependencies = [
("api", "0060_attack_surface_overview"),
]
operations = [
migrations.CreateModel(
name="DailySeveritySummary",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("date", models.DateField()),
("critical", models.IntegerField(default=0)),
("high", models.IntegerField(default=0)),
("medium", models.IntegerField(default=0)),
("low", models.IntegerField(default=0)),
("informational", models.IntegerField(default=0)),
("muted", models.IntegerField(default=0)),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="daily_severity_summaries",
related_query_name="daily_severity_summary",
to="api.provider",
),
),
(
"scan",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="daily_severity_summaries",
related_query_name="daily_severity_summary",
to="api.scan",
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="api.tenant",
),
),
],
options={
"db_table": "daily_severity_summaries",
"abstract": False,
},
),
migrations.AddIndex(
model_name="dailyseveritysummary",
index=models.Index(
fields=["tenant_id", "id"],
name="dss_tenant_id_idx",
),
),
migrations.AddIndex(
model_name="dailyseveritysummary",
index=models.Index(
fields=["tenant_id", "provider_id"],
name="dss_tenant_provider_idx",
),
),
migrations.AddConstraint(
model_name="dailyseveritysummary",
constraint=models.UniqueConstraint(
fields=("tenant_id", "provider", "date"),
name="unique_daily_severity_summary",
),
),
migrations.AddConstraint(
model_name="dailyseveritysummary",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_dailyseveritysummary",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
-59
View File
@@ -1500,65 +1500,6 @@ class ScanSummary(RowLevelSecurityProtectedModel):
resource_name = "scan-summaries"
class DailySeveritySummary(RowLevelSecurityProtectedModel):
"""
Pre-aggregated daily severity counts per provider.
Used by findings_severity/timeseries endpoint for efficient queries.
"""
objects = ActiveProviderManager()
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
date = models.DateField()
provider = models.ForeignKey(
Provider,
on_delete=models.CASCADE,
related_name="daily_severity_summaries",
related_query_name="daily_severity_summary",
)
scan = models.ForeignKey(
Scan,
on_delete=models.CASCADE,
related_name="daily_severity_summaries",
related_query_name="daily_severity_summary",
)
# Aggregated fail counts by severity
critical = models.IntegerField(default=0)
high = models.IntegerField(default=0)
medium = models.IntegerField(default=0)
low = models.IntegerField(default=0)
informational = models.IntegerField(default=0)
muted = models.IntegerField(default=0)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "daily_severity_summaries"
constraints = [
models.UniqueConstraint(
fields=("tenant_id", "provider", "date"),
name="unique_daily_severity_summary",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
indexes = [
models.Index(
fields=["tenant_id", "id"],
name="dss_tenant_id_idx",
),
models.Index(
fields=["tenant_id", "provider_id"],
name="dss_tenant_provider_idx",
),
]
class Integration(RowLevelSecurityProtectedModel):
class IntegrationChoices(models.TextChoices):
AMAZON_S3 = "amazon_s3", _("Amazon S3")
-202
View File
@@ -4940,154 +4940,6 @@ paths:
schema:
$ref: '#/components/schemas/OverviewSeverityResponse'
description: ''
/api/v1/overviews/findings_severity/timeseries:
get:
operationId: overviews_findings_severity_timeseries_retrieve
description: Retrieve daily aggregated findings data grouped by severity levels
over a date range. Returns one data point per day with counts of failed findings
by severity (critical, high, medium, low, informational) and muted findings.
Days without scans are filled forward with the most recent known values. Use
date_from (required) and date_to filters to specify the range.
summary: Get findings severity data over time
parameters:
- in: query
name: fields[findings-severity-timeseries]
schema:
type: array
items:
type: string
enum:
- id
- critical
- high
- medium
- low
- informational
- muted
- scan_ids
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[date_from]
schema:
type: string
- in: query
name: filter[date_to]
schema:
type: string
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
- in: query
name: filter[provider_id__in]
schema:
type: array
items:
type: string
format: uuid
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_type]
schema:
type: string
enum:
- aws
- azure
- gcp
- github
- iac
- kubernetes
- m365
- mongodbatlas
- oraclecloud
description: |-
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
* `m365` - M365
* `github` - GitHub
* `mongodbatlas` - MongoDB Atlas
* `iac` - IaC
* `oraclecloud` - Oracle Cloud Infrastructure
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
enum:
- aws
- azure
- gcp
- github
- iac
- kubernetes
- m365
- mongodbatlas
- oraclecloud
description: |-
Multiple values may be separated by commas.
* `aws` - AWS
* `azure` - Azure
* `gcp` - GCP
* `kubernetes` - Kubernetes
* `m365` - M365
* `github` - GitHub
* `mongodbatlas` - MongoDB Atlas
* `iac` - IaC
* `oraclecloud` - Oracle Cloud Infrastructure
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
- muted
- -muted
- scan_ids
- -scan_ids
explode: false
tags:
- Overview
security:
- JWT or API Key: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/FindingsSeverityOverTimeResponse'
description: ''
/api/v1/overviews/providers:
get:
operationId: overviews_providers_retrieve
@@ -11247,60 +11099,6 @@ components:
$ref: '#/components/schemas/Finding'
required:
- data
FindingsSeverityOverTime:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
type: string
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
enum:
- findings-severity-over-time
id: {}
attributes:
type: object
properties:
id:
type: string
format: date
critical:
type: integer
high:
type: integer
medium:
type: integer
low:
type: integer
informational:
type: integer
muted:
type: integer
scan_ids:
type: array
items:
type: string
format: uuid
required:
- id
- critical
- high
- medium
- low
- informational
- muted
- scan_ids
FindingsSeverityOverTimeResponse:
type: object
properties:
data:
$ref: '#/components/schemas/FindingsSeverityOverTime'
required:
- data
Integration:
type: object
required:
+1 -266
View File
@@ -3,7 +3,7 @@ import io
import json
import os
import tempfile
from datetime import date, datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
@@ -38,7 +38,6 @@ from api.models import (
AttackSurfaceOverview,
ComplianceOverviewSummary,
ComplianceRequirementOverview,
DailySeveritySummary,
Finding,
Integration,
Invitation,
@@ -6985,270 +6984,6 @@ class TestOverviewViewSet:
assert combined_attributes["medium"] == 4
assert combined_attributes["critical"] == 3
def test_overview_findings_severity_timeseries_requires_date_from(
self, authenticated_client
):
response = authenticated_client.get(
reverse("overview-findings_severity_timeseries")
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "date_from" in response.json()["errors"][0]["source"]["pointer"]
def test_overview_findings_severity_timeseries_invalid_date_format(
self, authenticated_client
):
response = authenticated_client.get(
reverse("overview-findings_severity_timeseries"),
{"filter[date_from]": "invalid-date"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Enter a valid date." in response.json()["errors"][0]["detail"]
def test_overview_findings_severity_timeseries_empty_data(
self, authenticated_client
):
response = authenticated_client.get(
reverse("overview-findings_severity_timeseries"),
{
"filter[date_from]": "2024-01-01",
"filter[date_to]": "2024-01-03",
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
# Should return 3 days with fill-forward (all zeros since no data)
assert len(data) == 3
for item in data:
assert item["attributes"]["critical"] == 0
assert item["attributes"]["high"] == 0
assert item["attributes"]["medium"] == 0
assert item["attributes"]["low"] == 0
assert item["attributes"]["informational"] == 0
assert item["attributes"]["muted"] == 0
assert item["attributes"]["scan_ids"] == []
def test_overview_findings_severity_timeseries_with_data(
self, authenticated_client, tenants_fixture, providers_fixture
):
tenant = tenants_fixture[0]
provider1, provider2, *_ = providers_fixture
# Create scan for day 1
scan1 = Scan.objects.create(
name="severity-over-time-scan-1",
provider=provider1,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
completed_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
)
# Create scan for day 3
scan3 = Scan.objects.create(
name="severity-over-time-scan-3",
provider=provider1,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
completed_at=datetime(2024, 1, 3, 12, 0, 0, tzinfo=timezone.utc),
)
# Create DailySeveritySummary for day 1
DailySeveritySummary.objects.create(
tenant=tenant,
provider=provider1,
scan=scan1,
date=date(2024, 1, 1),
critical=10,
high=20,
medium=30,
low=40,
informational=50,
muted=5,
)
# Create DailySeveritySummary for day 3
DailySeveritySummary.objects.create(
tenant=tenant,
provider=provider1,
scan=scan3,
date=date(2024, 1, 3),
critical=15,
high=25,
medium=35,
low=45,
informational=55,
muted=10,
)
response = authenticated_client.get(
reverse("overview-findings_severity_timeseries"),
{
"filter[date_from]": "2024-01-01",
"filter[date_to]": "2024-01-03",
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 3
# Day 1 - actual data (id is the date)
assert data[0]["id"] == "2024-01-01"
assert data[0]["attributes"]["critical"] == 10
assert data[0]["attributes"]["high"] == 20
assert data[0]["attributes"]["scan_ids"] == [str(scan1.id)]
# Day 2 - fill forward from day 1 (no data for this day)
assert data[1]["id"] == "2024-01-02"
assert data[1]["attributes"]["critical"] == 10
assert data[1]["attributes"]["high"] == 20
assert data[1]["attributes"]["scan_ids"] == [str(scan1.id)]
# Day 3 - actual data
assert data[2]["id"] == "2024-01-03"
assert data[2]["attributes"]["critical"] == 15
assert data[2]["attributes"]["high"] == 25
assert data[2]["attributes"]["scan_ids"] == [str(scan3.id)]
def test_overview_findings_severity_timeseries_aggregates_providers(
self, authenticated_client, tenants_fixture, providers_fixture
):
tenant = tenants_fixture[0]
provider1, provider2, *_ = providers_fixture
# Same day, different providers
scan1 = Scan.objects.create(
name="severity-over-time-scan-p1",
provider=provider1,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
completed_at=datetime(2024, 2, 1, 12, 0, 0, tzinfo=timezone.utc),
)
scan2 = Scan.objects.create(
name="severity-over-time-scan-p2",
provider=provider2,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
completed_at=datetime(2024, 2, 1, 14, 0, 0, tzinfo=timezone.utc),
)
# Create DailySeveritySummary for provider1
DailySeveritySummary.objects.create(
tenant=tenant,
provider=provider1,
scan=scan1,
date=date(2024, 2, 1),
critical=10,
high=20,
medium=30,
low=40,
informational=50,
muted=5,
)
# Create DailySeveritySummary for provider2
DailySeveritySummary.objects.create(
tenant=tenant,
provider=provider2,
scan=scan2,
date=date(2024, 2, 1),
critical=5,
high=10,
medium=15,
low=20,
informational=25,
muted=3,
)
response = authenticated_client.get(
reverse("overview-findings_severity_timeseries"),
{
"filter[date_from]": "2024-02-01",
"filter[date_to]": "2024-02-01",
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 1
# Should aggregate both providers
assert data[0]["attributes"]["critical"] == 15 # 10 + 5
assert data[0]["attributes"]["high"] == 30 # 20 + 10
assert data[0]["attributes"]["medium"] == 45 # 30 + 15
assert data[0]["attributes"]["low"] == 60 # 40 + 20
assert data[0]["attributes"]["informational"] == 75 # 50 + 25
assert data[0]["attributes"]["muted"] == 8 # 5 + 3
# scan_ids should contain both scans (order may vary)
assert set(data[0]["attributes"]["scan_ids"]) == {str(scan1.id), str(scan2.id)}
def test_overview_findings_severity_timeseries_provider_filter(
self, authenticated_client, tenants_fixture, providers_fixture
):
tenant = tenants_fixture[0]
provider1, provider2, *_ = providers_fixture
scan1 = Scan.objects.create(
name="severity-over-time-filter-scan-p1",
provider=provider1,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
completed_at=datetime(2024, 3, 1, 12, 0, 0, tzinfo=timezone.utc),
)
scan2 = Scan.objects.create(
name="severity-over-time-filter-scan-p2",
provider=provider2,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
completed_at=datetime(2024, 3, 1, 14, 0, 0, tzinfo=timezone.utc),
)
# Provider 1 - critical=100
DailySeveritySummary.objects.create(
tenant=tenant,
provider=provider1,
scan=scan1,
date=date(2024, 3, 1),
critical=100,
high=0,
medium=0,
low=0,
informational=0,
muted=0,
)
# Provider 2 - critical=50
DailySeveritySummary.objects.create(
tenant=tenant,
provider=provider2,
scan=scan2,
date=date(2024, 3, 1),
critical=50,
high=0,
medium=0,
low=0,
informational=0,
muted=0,
)
# Filter by provider1 only
response = authenticated_client.get(
reverse("overview-findings_severity_timeseries"),
{
"filter[date_from]": "2024-03-01",
"filter[date_to]": "2024-03-01",
"filter[provider_id]": str(provider1.id),
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 1
assert data[0]["attributes"]["critical"] == 100 # Only provider1
assert data[0]["attributes"]["scan_ids"] == [str(scan1.id)]
def test_overview_attack_surface_no_data(self, authenticated_client):
response = authenticated_client.get(reverse("overview-attack-surface"))
assert response.status_code == status.HTTP_200_OK
-16
View File
@@ -2204,22 +2204,6 @@ class OverviewSeveritySerializer(BaseSerializerV1):
resource_name = "findings-severity-overview"
class FindingsSeverityOverTimeSerializer(BaseSerializerV1):
"""Serializer for daily findings severity trend data."""
id = serializers.DateField(source="date")
critical = serializers.IntegerField()
high = serializers.IntegerField()
medium = serializers.IntegerField()
low = serializers.IntegerField()
informational = serializers.IntegerField()
muted = serializers.IntegerField()
scan_ids = serializers.ListField(child=serializers.UUIDField())
class JSONAPIMeta:
resource_name = "findings-severity-over-time"
class OverviewServiceSerializer(BaseSerializerV1):
id = serializers.CharField(source="service")
total = serializers.IntegerField()
+1 -137
View File
@@ -102,7 +102,6 @@ from api.filters import (
AttackSurfaceOverviewFilter,
ComplianceOverviewFilter,
CustomDjangoFilterBackend,
DailySeveritySummaryFilter,
FindingFilter,
IntegrationFilter,
IntegrationJiraFindingsFilter,
@@ -132,7 +131,6 @@ from api.models import (
AttackSurfaceOverview,
ComplianceOverviewSummary,
ComplianceRequirementOverview,
DailySeveritySummary,
Finding,
Integration,
Invitation,
@@ -186,7 +184,6 @@ from api.v1.serializers import (
FindingDynamicFilterSerializer,
FindingMetadataSerializer,
FindingSerializer,
FindingsSeverityOverTimeSerializer,
IntegrationCreateSerializer,
IntegrationJiraDispatchSerializer,
IntegrationSerializer,
@@ -4012,16 +4009,6 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
),
filters=True,
),
findings_severity_timeseries=extend_schema(
summary="Get findings severity data over time",
description=(
"Retrieve daily aggregated findings data grouped by severity levels over a date range. "
"Returns one data point per day with counts of failed findings by severity (critical, high, "
"medium, low, informational) and muted findings. Days without scans are filled forward with "
"the most recent known values. Use date_from (required) and date_to filters to specify the range."
),
filters=True,
),
attack_surface=extend_schema(
summary="Get attack surface overview",
description="Retrieve aggregated attack surface metrics from latest completed scans per provider.",
@@ -4070,16 +4057,7 @@ class OverviewViewSet(BaseRLSViewSet):
if not role.unlimited_visibility:
self.allowed_providers = providers
tenant_id = self.request.tenant_id
# Return appropriate queryset per action
if self.action == "findings_severity_timeseries":
qs = DailySeveritySummary.objects.filter(tenant_id=tenant_id)
if hasattr(self, "allowed_providers"):
qs = qs.filter(provider_id__in=self.allowed_providers)
return qs
return ScanSummary.all_objects.filter(tenant_id=tenant_id)
return ScanSummary.all_objects.filter(tenant_id=self.request.tenant_id)
def get_serializer_class(self):
if self.action == "providers":
@@ -4090,8 +4068,6 @@ class OverviewViewSet(BaseRLSViewSet):
return OverviewFindingSerializer
elif self.action == "findings_severity":
return OverviewSeveritySerializer
elif self.action == "findings_severity_timeseries":
return FindingsSeverityOverTimeSerializer
elif self.action == "services":
return OverviewServiceSerializer
elif self.action == "regions":
@@ -4109,18 +4085,8 @@ class OverviewViewSet(BaseRLSViewSet):
return ScanSummaryFilter
elif self.action == "findings_severity":
return ScanSummarySeverityFilter
elif self.action == "findings_severity_timeseries":
return DailySeveritySummaryFilter
return None
def filter_queryset(self, queryset):
# Skip OrderingFilter for findings_severity_timeseries (no inserted_at field)
if self.action == "findings_severity_timeseries":
return CustomDjangoFilterBackend().filter_queryset(
self.request, queryset, self
)
return super().filter_queryset(queryset)
@extend_schema(exclude=True)
def list(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
@@ -4397,108 +4363,6 @@ class OverviewViewSet(BaseRLSViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
@action(
detail=False,
methods=["get"],
url_path="findings_severity/timeseries",
url_name="findings_severity_timeseries",
)
def findings_severity_timeseries(self, request):
"""
Daily severity trends for charts. Uses DailySeveritySummary pre-aggregation.
Requires date_from filter.
"""
# Get queryset with RBAC, provider, and date filters applied
# Date validation is handled by DailySeveritySummaryFilter
daily_qs = self.filter_queryset(self.get_queryset())
date_from = request._date_from
date_to = request._date_to
if not daily_qs.exists():
# No data matches filters - return zeros
result = self._generate_zero_result(date_from, date_to)
serializer = self.get_serializer(result, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
# Fetch all data for fill-forward logic
daily_summaries = list(
daily_qs.order_by("provider_id", "-date").values(
"provider_id",
"scan_id",
"date",
"critical",
"high",
"medium",
"low",
"informational",
"muted",
)
)
if not daily_summaries:
result = self._generate_zero_result(date_from, date_to)
serializer = self.get_serializer(result, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
# Build provider_data: {provider_id: [(date, data), ...]} sorted by date desc
provider_data = defaultdict(list)
for summary in daily_summaries:
provider_data[summary["provider_id"]].append(summary)
# For each day, find the latest data per provider and sum values
result = []
current_date = date_from
while current_date <= date_to:
day_totals = {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"informational": 0,
"muted": 0,
}
day_scan_ids = []
for provider_id, summaries in provider_data.items():
# Find the latest data for this provider <= current_date
for summary in summaries: # Already sorted by date desc
if summary["date"] <= current_date:
day_totals["critical"] += summary["critical"] or 0
day_totals["high"] += summary["high"] or 0
day_totals["medium"] += summary["medium"] or 0
day_totals["low"] += summary["low"] or 0
day_totals["informational"] += summary["informational"] or 0
day_totals["muted"] += summary["muted"] or 0
day_scan_ids.append(summary["scan_id"])
break # Found the latest data for this provider
result.append(
{"date": current_date, "scan_ids": day_scan_ids, **day_totals}
)
current_date += timedelta(days=1)
serializer = self.get_serializer(result, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def _generate_zero_result(self, date_from, date_to):
"""Generate a list of zero-filled results for each date in range."""
result = []
current_date = date_from
zero_values = {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"informational": 0,
"muted": 0,
"scan_ids": [],
}
while current_date <= date_to:
result.append({"date": current_date, **zero_values})
current_date += timedelta(days=1)
return result
@extend_schema(
summary="Get ThreatScore snapshots",
description=(
-101
View File
@@ -1,18 +1,14 @@
from collections import defaultdict
from django.db.models import Sum
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
from api.models import (
ComplianceOverviewSummary,
ComplianceRequirementOverview,
DailySeveritySummary,
Resource,
ResourceFindingMapping,
ResourceScanSummary,
Scan,
ScanSummary,
StateChoices,
)
@@ -179,100 +175,3 @@ def backfill_compliance_summaries(tenant_id: str, scan_id: str):
)
return {"status": "backfilled", "inserted": len(summary_objects)}
def backfill_daily_severity_summaries(tenant_id: str, days: int = None):
"""
Backfill DailySeveritySummary from completed scans.
Groups by provider+date, keeps latest scan per day.
"""
from datetime import timedelta
from django.utils import timezone
created_count = 0
updated_count = 0
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
scan_filter = {
"tenant_id": tenant_id,
"state": StateChoices.COMPLETED,
"completed_at__isnull": False,
}
if days is not None:
cutoff_date = timezone.now() - timedelta(days=days)
scan_filter["completed_at__gte"] = cutoff_date
completed_scans = (
Scan.objects.filter(**scan_filter)
.order_by("provider_id", "-completed_at")
.values("id", "provider_id", "completed_at")
)
if not completed_scans:
return {"status": "no scans to backfill"}
# Keep only latest scan per provider/day
latest_scans_by_day = {}
for scan in completed_scans:
key = (scan["provider_id"], scan["completed_at"].date())
if key not in latest_scans_by_day:
latest_scans_by_day[key] = scan
# Process each provider/day
for (provider_id, scan_date), scan in latest_scans_by_day.items():
scan_id = scan["id"]
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
severity_totals = (
ScanSummary.objects.filter(
tenant_id=tenant_id,
scan_id=scan_id,
)
.values("severity")
.annotate(total_fail=Sum("fail"), total_muted=Sum("muted"))
)
severity_data = {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"informational": 0,
"muted": 0,
}
for row in severity_totals:
severity = row["severity"]
if severity in severity_data:
severity_data[severity] = row["total_fail"] or 0
severity_data["muted"] += row["total_muted"] or 0
with rls_transaction(tenant_id):
_, created = DailySeveritySummary.objects.update_or_create(
tenant_id=tenant_id,
provider_id=provider_id,
date=scan_date,
defaults={
"scan_id": scan_id,
"critical": severity_data["critical"],
"high": severity_data["high"],
"medium": severity_data["medium"],
"low": severity_data["low"],
"informational": severity_data["informational"],
"muted": severity_data["muted"],
},
)
if created:
created_count += 1
else:
updated_count += 1
return {
"status": "backfilled",
"created": created_count,
"updated": updated_count,
"total_days": len(latest_scans_by_day),
}
-70
View File
@@ -29,7 +29,6 @@ from api.models import (
AttackSurfaceOverview,
ComplianceOverviewSummary,
ComplianceRequirementOverview,
DailySeveritySummary,
Finding,
MuteRule,
Processor,
@@ -1349,72 +1348,3 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
)
else:
logger.info(f"No attack surface overview records created for scan {scan_id}")
def aggregate_daily_severity(tenant_id: str, scan_id: str):
"""Aggregate scan severity counts into DailySeveritySummary (one record per provider/day)."""
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
scan = Scan.objects.filter(
tenant_id=tenant_id,
id=scan_id,
state=StateChoices.COMPLETED,
).first()
if not scan:
logger.warning(f"Scan {scan_id} not found or not completed")
return {"status": "scan is not completed"}
provider_id = scan.provider_id
scan_date = scan.completed_at.date()
severity_totals = (
ScanSummary.objects.filter(
tenant_id=tenant_id,
scan_id=scan_id,
)
.values("severity")
.annotate(total_fail=Sum("fail"), total_muted=Sum("muted"))
)
severity_data = {
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
"informational": 0,
"muted": 0,
}
for row in severity_totals:
severity = row["severity"]
if severity in severity_data:
severity_data[severity] = row["total_fail"] or 0
severity_data["muted"] += row["total_muted"] or 0
with rls_transaction(tenant_id):
summary, created = DailySeveritySummary.objects.update_or_create(
tenant_id=tenant_id,
provider_id=provider_id,
date=scan_date,
defaults={
"scan_id": scan_id,
"critical": severity_data["critical"],
"high": severity_data["high"],
"medium": severity_data["medium"],
"low": severity_data["low"],
"informational": severity_data["informational"],
"muted": severity_data["muted"],
},
)
action = "created" if created else "updated"
logger.info(
f"Daily severity summary {action} for provider {provider_id} on {scan_date}"
)
return {
"status": action,
"provider_id": str(provider_id),
"date": str(scan_date),
"severity_data": severity_data,
}
+2 -20
View File
@@ -10,7 +10,6 @@ from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIR
from django_celery_beat.models import PeriodicTask
from tasks.jobs.backfill import (
backfill_compliance_summaries,
backfill_daily_severity_summaries,
backfill_resource_scan_summaries,
)
from tasks.jobs.connection import (
@@ -39,7 +38,6 @@ from tasks.jobs.muting import mute_historical_findings
from tasks.jobs.report import generate_compliance_reports_job
from tasks.jobs.scan import (
aggregate_attack_surface,
aggregate_daily_severity,
aggregate_findings,
create_compliance_requirements,
perform_prowler_scan,
@@ -77,11 +75,8 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str)
)
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
group(
aggregate_daily_severity_task.si(tenant_id=tenant_id, scan_id=scan_id),
generate_outputs_task.si(
scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id
),
generate_outputs_task.si(
scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id
),
group(
# Use optimized task that generates both reports with shared queries
@@ -528,12 +523,6 @@ def backfill_compliance_summaries_task(tenant_id: str, scan_id: str):
return backfill_compliance_summaries(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(name="backfill-daily-severity-summaries", queue="backfill")
def backfill_daily_severity_summaries_task(tenant_id: str, days: int = None):
"""Backfill DailySeveritySummary from historical scans. Use days param to limit scope."""
return backfill_daily_severity_summaries(tenant_id=tenant_id, days=days)
@shared_task(base=RLSTask, name="scan-compliance-overviews", queue="compliance")
@handle_provider_deletion
def create_compliance_requirements_task(tenant_id: str, scan_id: str):
@@ -567,13 +556,6 @@ def aggregate_attack_surface_task(tenant_id: str, scan_id: str):
return aggregate_attack_surface(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(name="scan-daily-severity", queue="overview")
@handle_provider_deletion
def aggregate_daily_severity_task(tenant_id: str, scan_id: str):
"""Aggregate scan severity into DailySeveritySummary for findings_severity/timeseries endpoint."""
return aggregate_daily_severity(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(base=RLSTask, name="lighthouse-connection-check")
@set_tenant
def check_lighthouse_connection_task(lighthouse_config_id: str, tenant_id: str = None):
-1
View File
@@ -13,7 +13,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `compute_instance_deletion_protection_enabled` check for GCP provider [(#9358)](https://github.com/prowler-cloud/prowler/pull/9358)
### Changed
- Update AWS Macie service metadata to new format [(#9265)](https://github.com/prowler-cloud/prowler/pull/9265)
- Update AWS Lightsail service metadata to new format [(#9264)](https://github.com/prowler-cloud/prowler/pull/9264)
- Update AWS GuardDuty service metadata to new format [(#9259)](https://github.com/prowler-cloud/prowler/pull/9259)
- Update AWS Network Firewall service metadata to new format [(#9382)](https://github.com/prowler-cloud/prowler/pull/9382)
@@ -0,0 +1,248 @@
{
"Framework": "RBI-Cyber-Security-Framework",
"Name": "Reserve Bank of India (RBI) Cyber Security Framework",
"Version": "",
"Provider": "Azure",
"Description": "The Reserve Bank had prescribed a set of baseline cyber security controls for primary (Urban) cooperative banks (UCBs) in October 2018. On further examination, it has been decided to prescribe a comprehensive cyber security framework for the UCBs, as a graded approach, based on their digital depth and interconnectedness with the payment systems landscape, digital products offered by them and assessment of cyber security risk. The framework would mandate implementation of progressively stronger security measures based on the nature, variety and scale of digital product offerings of banks.",
"Requirements": [
{
"Id": "annex_i_1_1",
"Name": "Annex I (1.1)",
"Description": "UCBs should maintain an up-to-date business IT Asset Inventory Register containing the following fields, as a minimum: a) Details of the IT Asset (viz., hardware/software/network devices, key personnel, services, etc.), b. Details of systems where customer data are stored, c. Associated business applications, if any, d. Criticality of the IT asset (For example, High/Medium/Low).",
"Attributes": [
{
"ItemId": "annex_i_1_1",
"Service": "vm"
}
],
"Checks": [
"vm_ensure_using_approved_images",
"vm_ensure_using_managed_disks",
"vm_trusted_launch_enabled",
"aks_cluster_rbac_enabled",
"aks_clusters_created_with_private_nodes",
"appinsights_ensure_is_configured",
"containerregistry_admin_user_disabled"
]
},
{
"Id": "annex_i_1_3",
"Name": "Annex I (1.3)",
"Description": "Appropriately manage and provide protection within and outside UCB/network, keeping in mind how the data/information is stored, transmitted, processed, accessed and put to use within/outside the UCB's network, and level of risk they are exposed to depending on the sensitivity of the data/information.",
"Attributes": [
{
"ItemId": "annex_i_1_3",
"Service": "azure"
}
],
"Checks": [
"keyvault_key_rotation_enabled",
"keyvault_access_only_through_private_endpoints",
"keyvault_private_endpoints",
"keyvault_rbac_enabled",
"app_function_not_publicly_accessible",
"app_ensure_http_is_redirected_to_https",
"app_minimum_tls_version_12",
"storage_blob_public_access_level_is_disabled",
"storage_secure_transfer_required_is_enabled",
"storage_ensure_encryption_with_customer_managed_keys",
"storage_ensure_minimum_tls_version_12",
"storage_default_network_access_rule_is_denied",
"storage_ensure_private_endpoints_in_storage_accounts",
"network_ssh_internet_access_restricted",
"sqlserver_unrestricted_inbound_access",
"sqlserver_tde_encryption_enabled",
"sqlserver_tde_encrypted_with_cmk",
"cosmosdb_account_use_private_endpoints",
"cosmosdb_account_firewall_use_selected_networks",
"mysql_flexible_server_ssl_connection_enabled",
"mysql_flexible_server_minimum_tls_version_12",
"postgresql_flexible_server_enforce_ssl_enabled",
"aks_clusters_public_access_disabled",
"containerregistry_not_publicly_accessible",
"containerregistry_uses_private_link",
"aisearch_service_not_publicly_accessible"
]
},
{
"Id": "annex_i_5_1",
"Name": "Annex I (5.1)",
"Description": "The firewall configurations should be set to the highest security level and evaluation of critical device (such as firewall, network switches, security devices, etc.) configurations should be done periodically.",
"Attributes": [
{
"ItemId": "annex_i_5_1",
"Service": "network"
}
],
"Checks": [
"network_rdp_internet_access_restricted",
"network_http_internet_access_restricted",
"network_udp_internet_access_restricted",
"network_ssh_internet_access_restricted",
"network_flow_log_captured_sent",
"network_flow_log_more_than_90_days",
"network_watcher_enabled",
"network_bastion_host_exists",
"aks_network_policy_enabled",
"storage_default_network_access_rule_is_denied"
]
},
{
"Id": "annex_i_6",
"Name": "Annex I (6)",
"Description": "Put in place systems and processes to identify, track, manage and monitor the status of patches to servers, operating system and application software running at the systems used by the UCB officials (end-users). Implement and update antivirus protection for all servers and applicable end points preferably through a centralised system.",
"Attributes": [
{
"ItemId": "annex_i_6",
"Service": "defender"
}
],
"Checks": [
"defender_ensure_system_updates_are_applied",
"defender_assessments_vm_endpoint_protection_installed",
"defender_ensure_defender_for_server_is_on",
"defender_ensure_defender_for_app_services_is_on",
"defender_ensure_defender_for_sql_servers_is_on",
"defender_ensure_defender_for_azure_sql_databases_is_on",
"defender_ensure_defender_for_storage_is_on",
"defender_ensure_defender_for_containers_is_on",
"defender_ensure_defender_for_keyvault_is_on",
"defender_ensure_defender_for_arm_is_on",
"defender_ensure_defender_for_dns_is_on",
"defender_ensure_defender_for_databases_is_on",
"defender_ensure_defender_for_cosmosdb_is_on",
"defender_container_images_scan_enabled",
"defender_container_images_resolved_vulnerabilities",
"defender_auto_provisioning_vulnerabilty_assessments_machines_on",
"vm_backup_enabled",
"app_ensure_java_version_is_latest",
"app_ensure_php_version_is_latest",
"app_ensure_python_version_is_latest"
]
},
{
"Id": "annex_i_7_1",
"Name": "Annex I (7.1)",
"Description": "Disallow administrative rights on end-user workstations/PCs/laptops and provide access rights on a 'need to know' and 'need to do' basis.",
"Attributes": [
{
"ItemId": "annex_i_7_1",
"Service": "iam"
}
],
"Checks": [
"iam_role_user_access_admin_restricted",
"iam_subscription_roles_owner_custom_not_created",
"iam_custom_role_has_permissions_to_administer_resource_locks",
"entra_global_admin_in_less_than_five_users",
"entra_policy_ensure_default_user_cannot_create_apps",
"entra_policy_ensure_default_user_cannot_create_tenants",
"entra_policy_default_users_cannot_create_security_groups",
"entra_policy_guest_invite_only_for_admin_roles",
"entra_policy_guest_users_access_restrictions",
"app_function_identity_without_admin_privileges"
]
},
{
"Id": "annex_i_7_2",
"Name": "Annex I (7.2)",
"Description": "Passwords should be set as complex and lengthy and users should not use same passwords for all the applications/systems/devices.",
"Attributes": [
{
"ItemId": "annex_i_7_2",
"Service": "entra"
}
],
"Checks": [
"entra_non_privileged_user_has_mfa",
"entra_privileged_user_has_mfa",
"entra_policy_user_consent_for_verified_apps",
"entra_policy_restricts_user_consent_for_apps",
"entra_user_with_vm_access_has_mfa",
"entra_security_defaults_enabled",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_trusted_named_locations_exists",
"sqlserver_azuread_administrator_enabled",
"postgresql_flexible_server_entra_id_authentication_enabled",
"cosmosdb_account_use_aad_and_rbac"
]
},
{
"Id": "annex_i_7_3",
"Name": "Annex I (7.3)",
"Description": "Remote Desktop Protocol (RDP) which allows others to access the computer remotely over a network or over the internet should be always disabled and should be enabled only with the approval of the authorised officer of the UCB. Logs for such remote access shall be enabled and monitored for suspicious activities.",
"Attributes": [
{
"ItemId": "annex_i_7_3",
"Service": "network"
}
],
"Checks": [
"network_rdp_internet_access_restricted",
"vm_jit_access_enabled",
"network_bastion_host_exists",
"vm_linux_enforce_ssh_authentication"
]
},
{
"Id": "annex_i_7_4",
"Name": "Annex I (7.4)",
"Description": "Implement appropriate (e.g. centralised) systems and controls to allow, manage, log and monitor privileged/super user/administrative access to critical systems (servers/databases, applications, network devices etc.)",
"Attributes": [
{
"ItemId": "annex_i_7_4",
"Service": "monitor"
}
],
"Checks": [
"monitor_alert_create_update_nsg",
"monitor_alert_delete_nsg",
"monitor_diagnostic_setting_with_appropriate_categories",
"monitor_diagnostic_settings_exists",
"monitor_alert_create_policy_assignment",
"monitor_alert_delete_policy_assignment",
"monitor_alert_create_update_security_solution",
"monitor_alert_delete_security_solution",
"monitor_alert_create_update_sqlserver_fr",
"monitor_alert_delete_sqlserver_fr",
"monitor_alert_create_update_public_ip_address_rule",
"monitor_alert_delete_public_ip_address_rule",
"monitor_alert_service_health_exists",
"monitor_storage_account_with_activity_logs_cmk_encrypted",
"monitor_storage_account_with_activity_logs_is_private",
"keyvault_logging_enabled",
"sqlserver_auditing_enabled",
"sqlserver_auditing_retention_90_days",
"app_http_logs_enabled",
"app_function_application_insights_enabled",
"defender_additional_email_configured_with_a_security_contact",
"defender_ensure_notify_alerts_severity_is_high",
"defender_ensure_notify_emails_to_owners",
"defender_ensure_mcas_is_enabled",
"defender_ensure_wdatp_is_enabled"
]
},
{
"Id": "annex_i_12",
"Name": "Annex I (12)",
"Description": "Take periodic back up of the important data and store this data 'off line' (i.e., transferring important files to a storage device that can be detached from a computer/system after copying all the files).",
"Attributes": [
{
"ItemId": "annex_i_12",
"Service": "azure"
}
],
"Checks": [
"vm_backup_enabled",
"vm_sufficient_daily_backup_retention_period",
"storage_ensure_file_shares_soft_delete_is_enabled",
"storage_blob_versioning_is_enabled",
"storage_ensure_soft_delete_is_enabled",
"storage_geo_redundant_enabled",
"keyvault_recoverable",
"sqlserver_vulnerability_assessment_enabled",
"sqlserver_va_periodic_recurring_scans_enabled"
]
}
]
}
@@ -1206,13 +1206,7 @@
"awstransform": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-west-2",
"us-east-1"
],
"aws-cn": [],
@@ -3225,7 +3219,6 @@
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"eu-central-1",
"eu-central-2",
@@ -6053,6 +6046,27 @@
]
}
},
"iotfleethub": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-north-1",
"eu-west-1",
"eu-west-2",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
"aws-us-gov": []
}
},
"iotfleetwise": {
"regions": {
"aws": [
@@ -8077,15 +8091,6 @@
"aws-us-gov": []
}
},
"nova-act": {
"regions": {
"aws": [
"us-east-1"
],
"aws-cn": [],
"aws-us-gov": []
}
},
"oam": {
"regions": {
"aws": [
@@ -8917,6 +8922,25 @@
"aws-us-gov": []
}
},
"qldb-session": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-west-1",
"eu-west-2",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
"aws-us-gov": []
}
},
"quicksight": {
"regions": {
"aws": [
@@ -9354,7 +9378,6 @@
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
@@ -9987,28 +10010,6 @@
]
}
},
"s3vectors": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-north-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
"aws-us-gov": []
}
},
"sagemaker": {
"regions": {
"aws": [
@@ -1,39 +1,31 @@
{
"Provider": "aws",
"CheckID": "macie_automated_sensitive_data_discovery_enabled",
"CheckTitle": "Macie automated sensitive data discovery is enabled",
"CheckTitle": "Check if Macie automated sensitive data discovery is enabled.",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "macie",
"SubServiceName": "",
"ResourceIdTemplate": "",
"ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id",
"Severity": "high",
"ResourceType": "Other",
"Description": "**Amazon Macie** administrator account has **automated sensitive data discovery** enabled for S3 data. The evaluation confirms the feature's status for the account in each Region.",
"Risk": "Without continuous discovery, sensitive S3 objects remain unclassified and unnoticed, weakening **confidentiality**. Over-permissive or public access can persist undetected, enabling **data exfiltration** and delaying containment and **forensic** response.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/config/latest/developerguide/macie-auto-sensitive-data-discovery-check.html",
"https://docs.aws.amazon.com/securityhub/latest/userguide/macie-controls.html#macie-2",
"https://docs.aws.amazon.com/macie/latest/user/discovery-asdd-account-enable.html"
],
"ResourceType": "AwsAccount",
"Description": "Check if automated sensitive data discovery is enabled for an Amazon Macie account. The control fails if it isn't enabled.",
"Risk": "Without automated sensitive data discovery, there could be delays in identifying sensitive data, leading to data exposure risks in Amazon S3 buckets.",
"RelatedUrl": "https://docs.aws.amazon.com/config/latest/developerguide/macie-auto-sensitive-data-discovery-check.html",
"Remediation": {
"Code": {
"CLI": "aws macie2 update-automated-discovery-configuration --status ENABLED --region <REGION>",
"CLI": "aws macie2 update-automated-discovery-configuration --status ENABLED",
"NativeIaC": "",
"Other": "1. In the AWS Console, open Amazon Macie\n2. Select the correct Region from the Region selector\n3. Go to Settings > Automated sensitive data discovery\n4. Click Enable under Status (choose My account if prompted)\n5. Repeat in other Regions where Macie is enabled if needed",
"Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/macie-controls.html#macie-2",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable and maintain `automated sensitive data discovery` for the Macie administrator across required Regions. Include relevant buckets, tune identifiers and allow lists to reduce noise, and route findings to monitoring. Complement with **least privilege** on S3 and **defense in depth** for data protection.",
"Url": "https://hub.prowler.com/check/macie_automated_sensitive_data_discovery_enabled"
"Text": "To enable and configure automated sensitive data discovery jobs for S3 buckets, refer to the Configuring automated sensitive data discovery tutorial.",
"Url": "https://docs.aws.amazon.com/macie/latest/user/discovery-asdd-account-enable.html"
}
},
"Categories": [
"secrets"
],
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
@@ -1,36 +1,31 @@
{
"Provider": "aws",
"CheckID": "macie_is_enabled",
"CheckTitle": "Amazon Macie is enabled",
"CheckTitle": "Check if Amazon Macie is enabled.",
"CheckType": [
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"Software and Configuration Checks/AWS Security Best Practices"
"Data Protection"
],
"ServiceName": "macie",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceIdTemplate": "arn:partition:access-analyzer:region:account-id:analyzer/resource-id",
"Severity": "low",
"ResourceType": "Other",
"Description": "**Amazon Macie** status is assessed per region with **S3** presence to determine if sensitive data discovery is operational. The outcome reflects whether Macie is active or in a `PAUSED`/not enabled state for the account and region.",
"Risk": "Without active Macie, sensitive data in **S3** can remain unclassified and exposed. Misconfigured access and public buckets may go undetected, enabling data exfiltration and secret leakage. This degrades confidentiality and widens breach blast radius by reducing visibility into where sensitive data resides.",
"Description": "Check if Amazon Macie is enabled.",
"Risk": "Amazon Macie is a fully managed data security and data privacy service that uses machine learning and pattern matching to help you discover, monitor and protect your sensitive data in AWS.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://aws.amazon.com/macie/getting-started/"
],
"Remediation": {
"Code": {
"CLI": "aws macie2 enable-macie --region <REGION>",
"NativeIaC": "```yaml\n# CloudFormation: Enable Amazon Macie in this region\nResources:\n MacieSession:\n Type: AWS::Macie::Session\n Properties:\n Status: ENABLED # Critical: Enables Macie for the account in this region\n```",
"Other": "1. Sign in to the AWS Management Console and switch to the target region\n2. Open Amazon Macie\n3. Click Get started or Enable Macie\n4. If Macie shows Suspended/Paused, click Resume Macie\n5. Repeat in each region with S3 buckets as needed",
"Terraform": "```hcl\n# Enables Amazon Macie in this region\nresource \"aws_macie2_account\" \"main\" {\n # Critical: Creating this resource enables Macie for the account in the region\n}\n```"
"CLI": "aws macie2 enable-macie",
"NativeIaC": "",
"Other": "",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable and maintain **Amazon Macie** in all regions hosting **S3** data. Use continuous sensitive data discovery, apply custom classifications for your data types, and route findings to monitoring. Enforce least privilege for Macie access and strengthen defense in depth with restrictive bucket policies and access controls.",
"Url": "https://hub.prowler.com/check/macie_is_enabled"
"Text": "Enable Amazon Macie and create appropriate jobs to discover sensitive data.",
"Url": "https://aws.amazon.com/macie/getting-started/"
}
},
"Categories": [
"secrets",
"forensics-ready"
],
"DependsOn": [],
-2
View File
@@ -6,8 +6,6 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- Risk Plot component with interactive legend and severity navigation to Overview page [(#9469)](https://github.com/prowler-cloud/prowler/pull/9469)
- Navigation progress bar for page transitions using Next.js `onRouterTransitionStart` [(#9465)](https://github.com/prowler-cloud/prowler/pull/9465)
- Finding Severity Over Time chart component to Overview page [(#9405)](https://github.com/prowler-cloud/prowler/pull/9405)
- Attack Surface component to Overview page [(#9412)](https://github.com/prowler-cloud/prowler/pull/9412)
-1
View File
@@ -3,7 +3,6 @@ export * from "./attack-surface";
export * from "./findings";
export * from "./providers";
export * from "./regions";
export * from "./risk-plot";
export * from "./services";
export * from "./severity-trends";
export * from "./threat-score";
-4
View File
@@ -1,4 +0,0 @@
// Risk Plot Actions
export * from "./risk-plot";
export * from "./risk-plot.adapter";
export * from "./types/risk-plot.types";
@@ -1,94 +0,0 @@
import { getProviderDisplayName } from "@/types/providers";
import type {
ProviderRiskData,
RiskPlotDataResponse,
RiskPlotPoint,
} from "./types/risk-plot.types";
/**
* Calculates percentage with proper rounding.
*/
function calculatePercentage(value: number, total: number): number {
if (total === 0) return 0;
return Math.round((value / total) * 100);
}
/**
* Adapts raw provider risk data to the format expected by RiskPlotClient.
*
* @param providersRiskData - Array of risk data per provider from API
* @returns Formatted data for the Risk Plot scatter chart
*/
export function adaptToRiskPlotData(
providersRiskData: ProviderRiskData[],
): RiskPlotDataResponse {
const points: RiskPlotPoint[] = [];
const providersWithoutData: RiskPlotDataResponse["providersWithoutData"] = [];
for (const providerData of providersRiskData) {
// Skip providers without ThreatScore data (no completed scans)
if (providerData.overallScore === null) {
providersWithoutData.push({
id: providerData.providerId,
name: providerData.providerName,
type: providerData.providerType,
});
continue;
}
// Convert provider type to display name (aws -> AWS, gcp -> Google, etc.)
const providerDisplayName = getProviderDisplayName(
providerData.providerType,
);
// Build severity data for the horizontal bar chart with percentages
let severityData;
let totalFailedFindings = 0;
if (providerData.severity) {
const { critical, high, medium, low, informational } =
providerData.severity;
totalFailedFindings = critical + high + medium + low + informational;
severityData = [
{
name: "Critical",
value: critical,
percentage: calculatePercentage(critical, totalFailedFindings),
},
{
name: "High",
value: high,
percentage: calculatePercentage(high, totalFailedFindings),
},
{
name: "Medium",
value: medium,
percentage: calculatePercentage(medium, totalFailedFindings),
},
{
name: "Low",
value: low,
percentage: calculatePercentage(low, totalFailedFindings),
},
{
name: "Info",
value: informational,
percentage: calculatePercentage(informational, totalFailedFindings),
},
];
}
points.push({
x: providerData.overallScore ?? 0,
y: totalFailedFindings,
provider: providerDisplayName,
name: providerData.providerName,
providerId: providerData.providerId,
severityData,
});
}
return { points, providersWithoutData };
}
@@ -1,69 +0,0 @@
"use server";
import { getFindingsBySeverity } from "@/actions/overview/findings";
import { getThreatScore } from "@/actions/overview/threat-score";
import { ProviderProps } from "@/types/providers";
import { ProviderRiskData } from "./types/risk-plot.types";
/**
* Fetches risk data for a single provider.
* Combines ThreatScore and Severity data in parallel.
*/
export async function getProviderRiskData(
provider: ProviderProps,
): Promise<ProviderRiskData> {
const providerId = provider.id;
const providerType = provider.attributes.provider;
const providerName = provider.attributes.alias || provider.attributes.uid;
// Fetch ThreatScore and Severity in parallel
const [threatScoreResponse, severityResponse] = await Promise.all([
getThreatScore({
filters: {
provider_id: providerId,
include: "provider",
},
}),
getFindingsBySeverity({
filters: {
"filter[provider_id]": providerId,
"filter[status]": "FAIL",
},
}),
]);
// Extract ThreatScore data
// When filtering by single provider, API returns array with one item (not aggregated)
const threatScoreData = threatScoreResponse?.data?.[0]?.attributes;
const overallScore = threatScoreData?.overall_score
? parseFloat(threatScoreData.overall_score)
: null;
const failedFindings = threatScoreData?.failed_findings ?? 0;
// Extract Severity data
const severityData = severityResponse?.data?.attributes ?? null;
return {
providerId,
providerType,
providerName,
overallScore,
failedFindings,
severity: severityData,
};
}
/**
* Fetches risk data for multiple providers in parallel.
* Used by the Risk Plot SSR component.
*/
export async function getProvidersRiskData(
providers: ProviderProps[],
): Promise<ProviderRiskData[]> {
const riskDataPromises = providers.map((provider) =>
getProviderRiskData(provider),
);
return Promise.all(riskDataPromises);
}
@@ -1,58 +0,0 @@
// Risk Plot Types
// Data structures for the Risk Plot scatter chart
import type { BarDataPoint } from "@/components/graphs/types";
/**
* Represents a single point in the Risk Plot scatter chart.
* Each point represents a provider/account with its risk metrics.
*/
export interface RiskPlotPoint {
/** ThreatScore (0-100 scale, higher = better) */
x: number;
/** Total failed findings count */
y: number;
/** Provider type display name (AWS, Azure, Google, etc.) */
provider: string;
/** Provider alias or UID (account identifier) */
name: string;
/** Provider ID for filtering/navigation */
providerId: string;
/** Severity breakdown for the horizontal bar chart */
severityData?: BarDataPoint[];
}
/**
* Raw data from the API combined for a single provider.
* Used internally before transformation to RiskPlotPoint.
*/
export interface ProviderRiskData {
providerId: string;
providerType: string;
providerName: string;
/** ThreatScore overall_score (0-100 scale) */
overallScore: number | null;
/** Failed findings from ThreatScore snapshot */
failedFindings: number;
/** Severity breakdown */
severity: {
critical: number;
high: number;
medium: number;
low: number;
informational: number;
} | null;
}
/**
* Response structure for risk plot data fetching.
*/
export interface RiskPlotDataResponse {
points: RiskPlotPoint[];
/** Providers that have no data or no completed scans */
providersWithoutData: Array<{
id: string;
name: string;
type: string;
}>;
}
@@ -1,9 +1,5 @@
"use server";
import {
getDateFromForTimeRange,
type TimeRange,
} from "@/app/(prowler)/_new-overview/severity-over-time/_constants/time-range.constants";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
@@ -13,6 +9,20 @@ import {
FindingsSeverityOverTimeResponse,
} from "./types";
const TIME_RANGE_VALUES = {
FIVE_DAYS: "5D",
ONE_WEEK: "1W",
ONE_MONTH: "1M",
} as const;
type TimeRange = (typeof TIME_RANGE_VALUES)[keyof typeof TIME_RANGE_VALUES];
const TIME_RANGE_DAYS: Record<TimeRange, number> = {
"5D": 5,
"1W": 7,
"1M": 30,
};
export type SeverityTrendsResult =
| { status: "success"; data: AdaptedSeverityTrendsResponse }
| { status: "empty" }
@@ -66,9 +76,21 @@ export const getSeverityTrendsByTimeRange = async ({
timeRange: TimeRange;
filters?: Record<string, string | string[] | undefined>;
}): Promise<SeverityTrendsResult> => {
const days = TIME_RANGE_DAYS[timeRange];
if (!days) {
console.error("Invalid time range provided");
return { status: "error" };
}
const endDate = new Date();
const startDate = new Date(endDate.getTime() - days * 24 * 60 * 60 * 1000);
const dateFrom = startDate.toISOString().split("T")[0];
const dateFilters = {
...filters,
"filter[date_from]": getDateFromForTimeRange(timeRange),
date_from: dateFrom,
};
return getFindingsSeverityTrends({ filters: dateFilters });
+2 -4
View File
@@ -3,10 +3,9 @@ import "@/styles/globals.css";
import { GoogleTagManager } from "@next/third-parties/google";
import { Metadata, Viewport } from "next";
import { redirect } from "next/navigation";
import { ReactNode } from "react";
import { auth } from "@/auth.config";
import { NavigationProgress, Toaster } from "@/components/ui";
import { Toaster } from "@/components/ui";
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib";
@@ -34,7 +33,7 @@ export const viewport: Viewport = {
export default async function RootLayout({
children,
}: {
children: ReactNode;
children: React.ReactNode;
}) {
const session = await auth();
@@ -53,7 +52,6 @@ export default async function RootLayout({
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<NavigationProgress />
{children}
<Toaster />
<GoogleTagManager
@@ -11,15 +11,15 @@ export const GRAPH_TABS = [
id: "threat-map",
label: "Threat Map",
},
{
id: "risk-plot",
label: "Risk Plot",
},
// TODO: Uncomment when ready to enable other tabs
// {
// id: "risk-radar",
// label: "Risk Radar",
// },
// {
// id: "risk-plot",
// label: "Risk Plot",
// },
] as const;
export type TabId = (typeof GRAPH_TABS)[number]["id"];
@@ -7,9 +7,9 @@ import { GraphsTabsClient } from "./_components/graphs-tabs-client";
import { GRAPH_TABS, type TabId } from "./_config/graphs-tabs-config";
import { FindingsViewSSR } from "./findings-view";
import { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
import { RiskPlotSSR } from "./risk-plot/risk-plot.ssr";
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
// TODO: Uncomment when ready to enable other tabs
// import { RiskPlotView } from "./risk-plot/risk-plot-view";
// import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
const LoadingFallback = () => (
@@ -25,9 +25,9 @@ const GRAPH_COMPONENTS: Record<TabId, GraphComponent> = {
findings: FindingsViewSSR as GraphComponent,
"risk-pipeline": RiskPipelineViewSSR as GraphComponent,
"threat-map": ThreatMapViewSSR as GraphComponent,
"risk-plot": RiskPlotSSR as GraphComponent,
// TODO: Uncomment when ready to enable other tabs
// "risk-radar": RiskRadarViewSSR as GraphComponent,
// "risk-plot": RiskPlotView as GraphComponent,
};
interface GraphsTabsWrapperProps {
@@ -1,19 +1,9 @@
"use client";
/**
* Risk Plot Client Component
*
* NOTE: This component uses CSS variables (var()) for Recharts styling.
* Recharts SVG-based components (Scatter, XAxis, YAxis, CartesianGrid, etc.)
* do not support Tailwind classes and require raw color values or CSS variables.
* This is a documented limitation of the Recharts library.
* @see https://recharts.org/en-US/api
*/
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import {
CartesianGrid,
Legend,
ResponsiveContainer,
Scatter,
ScatterChart,
@@ -22,7 +12,6 @@ import {
YAxis,
} from "recharts";
import type { RiskPlotPoint } from "@/actions/overview/risk-plot";
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { AlertPill } from "@/components/graphs/shared/alert-pill";
import { ChartLegend } from "@/components/graphs/shared/chart-legend";
@@ -30,83 +19,69 @@ import {
AXIS_FONT_SIZE,
CustomXAxisTick,
} from "@/components/graphs/shared/custom-axis-tick";
import { getSeverityColorByRiskScore } from "@/components/graphs/shared/utils";
import type { BarDataPoint } from "@/components/graphs/types";
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
import { SEVERITY_FILTER_MAP } from "@/types/severities";
// Threat Score colors (0-100 scale, higher = better)
const THREAT_COLORS = {
DANGER: "var(--bg-fail-primary)", // 0-30
WARNING: "var(--bg-warning-primary)", // 31-60
SUCCESS: "var(--bg-pass-primary)", // 61-100
} as const;
/**
* Get color based on ThreatScore (0-100 scale, higher = better)
*/
function getThreatScoreColor(score: number): string {
if (score > 60) return THREAT_COLORS.SUCCESS;
if (score > 30) return THREAT_COLORS.WARNING;
return THREAT_COLORS.DANGER;
}
// Provider colors from globals.css
const PROVIDER_COLORS: Record<string, string> = {
AWS: "var(--bg-data-aws)",
Azure: "var(--bg-data-azure)",
"Google Cloud": "var(--bg-data-gcp)",
Kubernetes: "var(--bg-data-kubernetes)",
"Microsoft 365": "var(--bg-data-m365)",
GitHub: "var(--bg-data-github)",
"MongoDB Atlas": "var(--bg-data-azure)",
"Infrastructure as Code": "var(--bg-data-kubernetes)",
"Oracle Cloud Infrastructure": "var(--bg-data-gcp)",
const PROVIDER_COLORS = {
AWS: "var(--color-bg-data-aws)",
Azure: "var(--color-bg-data-azure)",
Google: "var(--color-bg-data-gcp)",
};
export interface ScatterPoint {
x: number;
y: number;
provider: string;
name: string;
severityData?: BarDataPoint[];
}
interface RiskPlotClientProps {
data: RiskPlotPoint[];
data: ScatterPoint[];
}
interface TooltipProps {
active?: boolean;
payload?: Array<{ payload: RiskPlotPoint }>;
payload?: Array<{ payload: ScatterPoint }>;
}
// Props that Recharts passes to the shape component
interface RechartsScatterDotProps {
interface ScatterDotProps {
cx: number;
cy: number;
payload: RiskPlotPoint;
payload: ScatterPoint;
selectedPoint: ScatterPoint | null;
onSelectPoint: (point: ScatterPoint) => void;
allData: ScatterPoint[];
}
// Extended props for our custom scatter dot component
interface ScatterDotProps extends RechartsScatterDotProps {
selectedPoint: RiskPlotPoint | null;
onSelectPoint: (point: RiskPlotPoint) => void;
allData: RiskPlotPoint[];
selectedProvider: string | null;
interface LegendProps {
payload?: Array<{ value: string; color: string }>;
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
if (!active || !payload?.length) return null;
if (active && payload && payload.length) {
const data = payload[0].payload;
const severityColor = getSeverityColorByRiskScore(data.x);
const { name, x, y } = payload[0].payload;
const scoreColor = getThreatScoreColor(x);
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
{name}
</p>
<p className="text-text-neutral-secondary text-sm font-medium">
<span style={{ color: scoreColor, fontWeight: "bold" }}>{x}%</span>{" "}
Threat Score
</p>
<div className="mt-2">
<AlertPill value={y} />
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
{data.name}
</p>
<p className="text-text-neutral-secondary text-sm font-medium">
{/* Dynamic color from getSeverityColorByRiskScore - required inline style */}
<span style={{ color: severityColor, fontWeight: "bold" }}>
{data.x}
</span>{" "}
Risk Score
</p>
<div className="mt-2">
<AlertPill value={data.y} />
</div>
</div>
</div>
);
);
}
return null;
};
const CustomScatterDot = ({
@@ -116,31 +91,24 @@ const CustomScatterDot = ({
selectedPoint,
onSelectPoint,
allData,
selectedProvider,
}: ScatterDotProps) => {
const isSelected = selectedPoint?.name === payload.name;
const size = isSelected ? 18 : 8;
const selectedColor = "var(--bg-button-primary)";
const selectedColor = "var(--bg-button-primary)"; // emerald-400
const fill = isSelected
? selectedColor
: PROVIDER_COLORS[payload.provider] || "var(--color-text-neutral-tertiary)";
const isFaded =
selectedProvider !== null && payload.provider !== selectedProvider;
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
"var(--color-text-neutral-tertiary)";
const handleClick = () => {
const fullDataItem = allData?.find((d) => d.name === payload.name);
const fullDataItem = allData?.find(
(d: ScatterPoint) => d.name === payload.name,
);
onSelectPoint?.(fullDataItem || payload);
};
return (
<g
style={{
cursor: "pointer",
opacity: isFaded ? 0.2 : 1,
transition: "opacity 0.2s",
}}
onClick={handleClick}
>
<g style={{ cursor: "pointer" }} onClick={handleClick}>
{isSelected && (
<>
<circle
@@ -175,86 +143,60 @@ const CustomScatterDot = ({
);
};
/**
* Factory function that creates a scatter dot shape component with closure over selection state.
* Recharts shape prop types the callback parameter as `unknown` due to its flexible API.
* We safely cast to RechartsScatterDotProps since we know the actual shape of props passed by Scatter.
* @see https://recharts.org/en-US/api/Scatter#shape
*/
const CustomLegend = ({ payload }: LegendProps) => {
const items =
payload?.map((entry: { value: string; color: string }) => ({
label: entry.value,
color: entry.color,
})) || [];
return <ChartLegend items={items} />;
};
function createScatterDotShape(
selectedPoint: RiskPlotPoint | null,
onSelectPoint: (point: RiskPlotPoint) => void,
allData: RiskPlotPoint[],
selectedProvider: string | null,
): (props: unknown) => React.JSX.Element {
const ScatterDotShape = (props: unknown) => (
<CustomScatterDot
{...(props as RechartsScatterDotProps)}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
allData={allData}
selectedProvider={selectedProvider}
/>
);
selectedPoint: ScatterPoint | null,
onSelectPoint: (point: ScatterPoint) => void,
allData: ScatterPoint[],
) {
const ScatterDotShape = (props: unknown) => {
const dotProps = props as Omit<
ScatterDotProps,
"selectedPoint" | "onSelectPoint" | "allData"
>;
return (
<CustomScatterDot
{...dotProps}
selectedPoint={selectedPoint}
onSelectPoint={onSelectPoint}
allData={allData}
/>
);
};
ScatterDotShape.displayName = "ScatterDotShape";
return ScatterDotShape;
}
export function RiskPlotClient({ data }: RiskPlotClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [selectedPoint, setSelectedPoint] = useState<RiskPlotPoint | null>(
null,
);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [selectedPoint, setSelectedPoint] = useState<ScatterPoint | null>(null);
// Group data by provider for separate Scatter series
const dataByProvider = data.reduce<Record<string, RiskPlotPoint[]>>(
const dataByProvider = data.reduce(
(acc, point) => {
(acc[point.provider] ??= []).push(point);
const provider = point.provider;
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(point);
return acc;
},
{},
{} as Record<string, typeof data>,
);
const providers = Object.keys(dataByProvider);
const handleSelectPoint = (point: RiskPlotPoint) => {
setSelectedPoint((current) =>
current?.name === point.name ? null : point,
);
};
const handleProviderClick = (provider: string) => {
setSelectedProvider((current) => (current === provider ? null : provider));
};
const handleBarClick = (dataPoint: BarDataPoint) => {
if (!selectedPoint) return;
// Build the URL with current filters
const params = new URLSearchParams(searchParams.toString());
// Transform provider filters (provider_id__in -> provider__in)
mapProviderFiltersForFindings(params);
// Add severity filter
const severity = SEVERITY_FILTER_MAP[dataPoint.name];
if (severity) {
params.set("filter[severity__in]", severity);
const handleSelectPoint = (point: ScatterPoint) => {
if (selectedPoint?.name === point.name) {
setSelectedPoint(null);
} else {
setSelectedPoint(point);
}
// Add provider filter for the selected point
params.set("filter[provider__in]", selectedPoint.providerId);
// Add exclude muted findings filter
params.set("filter[muted]", "false");
// Filter by FAIL findings
params.set("filter[status__in]", "FAIL");
// Navigate to findings page
router.push(`/findings?${params.toString()}`);
};
return (
@@ -262,14 +204,26 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
<div className="flex flex-1 gap-12">
{/* Plot Section - in Card */}
<div className="flex basis-[70%] flex-col">
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex flex-1 flex-col rounded-lg border p-4">
<div
className="flex flex-1 flex-col rounded-lg border p-4"
style={{
borderColor: "var(--border-neutral-primary)",
backgroundColor: "var(--bg-neutral-secondary)",
}}
>
<div className="mb-4">
<h3 className="text-text-neutral-primary text-lg font-semibold">
<h3
className="text-lg font-semibold"
style={{ color: "var(--text-neutral-primary)" }}
>
Risk Plot
</h3>
</div>
<div className="relative min-h-[400px] w-full flex-1">
<div
className="relative w-full flex-1"
style={{ minHeight: "400px" }}
>
<ResponsiveContainer width="100%" height="100%">
<ScatterChart
margin={{ top: 20, right: 30, bottom: 60, left: 60 }}
@@ -283,16 +237,16 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
<XAxis
type="number"
dataKey="x"
name="Threat Score"
name="Risk Score"
label={{
value: "Threat Score",
value: "Risk Score",
position: "bottom",
offset: 10,
fill: "var(--color-text-neutral-secondary)",
}}
tick={CustomXAxisTick}
tickLine={false}
domain={[0, 100]}
domain={[0, 10]}
axisLine={false}
/>
<YAxis
@@ -314,43 +268,30 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
axisLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
content={<CustomLegend />}
wrapperStyle={{ paddingTop: "40px" }}
/>
{Object.entries(dataByProvider).map(([provider, points]) => (
<Scatter
key={provider}
name={provider}
data={points}
fill={
PROVIDER_COLORS[provider] ||
"var(--color-text-neutral-tertiary)"
PROVIDER_COLORS[
provider as keyof typeof PROVIDER_COLORS
] || "var(--color-text-neutral-tertiary)"
}
shape={createScatterDotShape(
selectedPoint,
handleSelectPoint,
data,
selectedProvider,
)}
/>
))}
</ScatterChart>
</ResponsiveContainer>
</div>
{/* Interactive Legend - below chart */}
<div className="mt-4 flex flex-col items-start gap-2">
<p className="text-text-neutral-tertiary pl-2 text-xs">
Click to filter by provider.
</p>
<ChartLegend
items={providers.map((p) => ({
label: p,
color:
PROVIDER_COLORS[p] || "var(--color-text-neutral-tertiary)",
dataKey: p,
}))}
selectedItem={selectedProvider}
onItemClick={handleProviderClick}
/>
</div>
</div>
</div>
@@ -359,22 +300,28 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
{selectedPoint && selectedPoint.severityData ? (
<div className="flex w-full flex-col">
<div className="mb-4">
<h4 className="text-text-neutral-primary text-base font-semibold">
<h4
className="text-base font-semibold"
style={{ color: "var(--text-neutral-primary)" }}
>
{selectedPoint.name}
</h4>
<p className="text-text-neutral-tertiary text-xs">
Threat Score: {selectedPoint.x}% | Failed Findings:{" "}
<p
className="text-xs"
style={{ color: "var(--text-neutral-tertiary)" }}
>
Risk Score: {selectedPoint.x} | Failed Findings:{" "}
{selectedPoint.y}
</p>
</div>
<HorizontalBarChart
data={selectedPoint.severityData}
onBarClick={handleBarClick}
/>
<HorizontalBarChart data={selectedPoint.severityData} />
</div>
) : (
<div className="flex w-full items-center justify-center text-center">
<p className="text-text-neutral-tertiary text-sm">
<p
className="text-sm"
style={{ color: "var(--text-neutral-tertiary)" }}
>
Select a point on the plot to view details
</p>
</div>
@@ -0,0 +1,191 @@
import { RiskPlotClient, type ScatterPoint } from "./risk-plot-client";
// Mock data - Risk Score (0-10) vs Failed Findings count
const mockScatterData: ScatterPoint[] = [
{
x: 9.2,
y: 1456,
provider: "AWS",
name: "Amazon RDS",
severityData: [
{ name: "Critical", value: 456 },
{ name: "High", value: 600 },
{ name: "Medium", value: 250 },
{ name: "Low", value: 120 },
{ name: "Info", value: 30 },
],
},
{
x: 8.5,
y: 892,
provider: "AWS",
name: "Amazon EC2",
severityData: [
{ name: "Critical", value: 280 },
{ name: "High", value: 350 },
{ name: "Medium", value: 180 },
{ name: "Low", value: 70 },
{ name: "Info", value: 12 },
],
},
{
x: 7.1,
y: 445,
provider: "AWS",
name: "Amazon S3",
severityData: [
{ name: "Critical", value: 140 },
{ name: "High", value: 180 },
{ name: "Medium", value: 90 },
{ name: "Low", value: 30 },
{ name: "Info", value: 5 },
],
},
{
x: 6.3,
y: 678,
provider: "AWS",
name: "AWS Lambda",
severityData: [
{ name: "Critical", value: 214 },
{ name: "High", value: 270 },
{ name: "Medium", value: 135 },
{ name: "Low", value: 54 },
{ name: "Info", value: 5 },
],
},
{
x: 4.2,
y: 156,
provider: "AWS",
name: "AWS Backup",
severityData: [
{ name: "Critical", value: 49 },
{ name: "High", value: 62 },
{ name: "Medium", value: 31 },
{ name: "Low", value: 12 },
{ name: "Info", value: 2 },
],
},
{
x: 8.8,
y: 1023,
provider: "Azure",
name: "Azure SQL Database",
severityData: [
{ name: "Critical", value: 323 },
{ name: "High", value: 410 },
{ name: "Medium", value: 205 },
{ name: "Low", value: 82 },
{ name: "Info", value: 3 },
],
},
{
x: 7.9,
y: 834,
provider: "Azure",
name: "Azure Virtual Machines",
severityData: [
{ name: "Critical", value: 263 },
{ name: "High", value: 334 },
{ name: "Medium", value: 167 },
{ name: "Low", value: 67 },
{ name: "Info", value: 3 },
],
},
{
x: 6.4,
y: 567,
provider: "Azure",
name: "Azure Storage",
severityData: [
{ name: "Critical", value: 179 },
{ name: "High", value: 227 },
{ name: "Medium", value: 113 },
{ name: "Low", value: 45 },
{ name: "Info", value: 3 },
],
},
{
x: 5.1,
y: 289,
provider: "Azure",
name: "Azure Key Vault",
severityData: [
{ name: "Critical", value: 91 },
{ name: "High", value: 115 },
{ name: "Medium", value: 58 },
{ name: "Low", value: 23 },
{ name: "Info", value: 2 },
],
},
{
x: 7.6,
y: 712,
provider: "Google",
name: "Cloud SQL",
severityData: [
{ name: "Critical", value: 225 },
{ name: "High", value: 285 },
{ name: "Medium", value: 142 },
{ name: "Low", value: 57 },
{ name: "Info", value: 3 },
],
},
{
x: 6.9,
y: 623,
provider: "Google",
name: "Compute Engine",
severityData: [
{ name: "Critical", value: 197 },
{ name: "High", value: 249 },
{ name: "Medium", value: 124 },
{ name: "Low", value: 50 },
{ name: "Info", value: 3 },
],
},
{
x: 5.8,
y: 412,
provider: "Google",
name: "Cloud Storage",
severityData: [
{ name: "Critical", value: 130 },
{ name: "High", value: 165 },
{ name: "Medium", value: 82 },
{ name: "Low", value: 33 },
{ name: "Info", value: 2 },
],
},
{
x: 4.5,
y: 198,
provider: "Google",
name: "Cloud Run",
severityData: [
{ name: "Critical", value: 63 },
{ name: "High", value: 79 },
{ name: "Medium", value: 39 },
{ name: "Low", value: 16 },
{ name: "Info", value: 1 },
],
},
{
x: 8.9,
y: 945,
provider: "AWS",
name: "Amazon RDS Aurora",
severityData: [
{ name: "Critical", value: 299 },
{ name: "High", value: 378 },
{ name: "Medium", value: 189 },
{ name: "Low", value: 76 },
{ name: "Info", value: 3 },
],
},
];
export function RiskPlotView() {
return <RiskPlotClient data={mockScatterData} />;
}
@@ -1,91 +0,0 @@
import { Info } from "lucide-react";
import {
adaptToRiskPlotData,
getProvidersRiskData,
} from "@/actions/overview/risk-plot";
import { getProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
import { RiskPlotClient } from "./risk-plot-client";
export async function RiskPlotSSR({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const filters = pickFilterParams(searchParams);
const providerTypeFilter = filters["filter[provider_type__in]"];
const providerIdFilter = filters["filter[provider_id__in]"];
// Fetch all providers
const providersListResponse = await getProviders({ pageSize: 200 });
const allProviders = providersListResponse?.data || [];
// Filter providers based on search params
let filteredProviders = allProviders;
if (providerIdFilter) {
// Filter by specific provider IDs
const selectedIds = String(providerIdFilter)
.split(",")
.map((id) => id.trim());
filteredProviders = allProviders.filter((p) => selectedIds.includes(p.id));
} else if (providerTypeFilter) {
// Filter by provider types
const selectedTypes = String(providerTypeFilter)
.split(",")
.map((t) => t.trim().toLowerCase());
filteredProviders = allProviders.filter((p) =>
selectedTypes.includes(p.attributes.provider.toLowerCase()),
);
}
// No providers to show
if (filteredProviders.length === 0) {
return (
<div className="flex h-[460px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2 text-center">
<Info size={48} className="text-text-neutral-tertiary" />
<p className="text-text-neutral-secondary text-sm">
No providers available for the selected filters
</p>
</div>
</div>
);
}
// Fetch risk data for all filtered providers in parallel
const providersRiskData = await getProvidersRiskData(filteredProviders);
// Transform to chart format
const { points, providersWithoutData } =
adaptToRiskPlotData(providersRiskData);
// No data available
if (points.length === 0) {
return (
<div className="flex h-[460px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2 text-center">
<Info size={48} className="text-text-neutral-tertiary" />
<p className="text-text-neutral-secondary text-sm">
No risk data available for the selected providers
</p>
{providersWithoutData.length > 0 && (
<p className="text-text-neutral-tertiary text-xs">
{providersWithoutData.length} provider(s) have no completed scans
</p>
)}
</div>
</div>
);
}
return (
<div className="w-full flex-1 overflow-visible">
<RiskPlotClient data={points} />
</div>
);
}
@@ -13,7 +13,6 @@ import {
SeverityLevel,
} from "@/types/severities";
import { DEFAULT_TIME_RANGE } from "../_constants/time-range.constants";
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface FindingSeverityOverTimeProps {
@@ -25,7 +24,7 @@ export const FindingSeverityOverTime = ({
}: FindingSeverityOverTimeProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
const [timeRange, setTimeRange] = useState<TimeRange>("5D");
const [data, setData] = useState<LineDataPoint[]>(initialData);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -2,12 +2,14 @@
import { cn } from "@/lib/utils";
import {
TIME_RANGE_OPTIONS,
type TimeRange,
} from "../_constants/time-range.constants";
const TIME_RANGE_OPTIONS = {
FIVE_DAYS: "5D",
ONE_WEEK: "1W",
ONE_MONTH: "1M",
} as const;
export type { TimeRange };
export type TimeRange =
(typeof TIME_RANGE_OPTIONS)[keyof typeof TIME_RANGE_OPTIONS];
interface TimeRangeSelectorProps {
value: TimeRange;
@@ -1 +0,0 @@
export * from "./time-range.constants";
@@ -1,23 +0,0 @@
export const TIME_RANGE_OPTIONS = {
FIVE_DAYS: "5D",
ONE_WEEK: "1W",
ONE_MONTH: "1M",
} as const;
export type TimeRange =
(typeof TIME_RANGE_OPTIONS)[keyof typeof TIME_RANGE_OPTIONS];
export const TIME_RANGE_DAYS: Record<TimeRange, number> = {
"5D": 5,
"1W": 7,
"1M": 30,
};
export const DEFAULT_TIME_RANGE: TimeRange = "5D";
export const getDateFromForTimeRange = (timeRange: TimeRange): string => {
const days = TIME_RANGE_DAYS[timeRange];
const date = new Date();
date.setDate(date.getDate() - days);
return date.toISOString().split("T")[0];
};
@@ -1,11 +1,10 @@
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { getFindingsSeverityTrends } from "@/actions/overview/severity-trends";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
import { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton";
import { DEFAULT_TIME_RANGE } from "./_constants/time-range.constants";
export { FindingSeverityOverTimeSkeleton };
@@ -26,11 +25,7 @@ export const FindingSeverityOverTimeSSR = async ({
searchParams,
}: SSRComponentProps) => {
const filters = pickFilterParams(searchParams);
const result = await getSeverityTrendsByTimeRange({
timeRange: DEFAULT_TIME_RANGE,
filters,
});
const result = await getFindingsSeverityTrends({ filters });
if (result.status === "error") {
return <EmptyState message="Failed to load severity trends data" />;
@@ -195,7 +195,7 @@ const SSRComplianceContent = async ({
{ pass: 0, fail: 0, manual: 0 },
);
const accordionItems = mapper.toAccordionItems(data, scanId);
const topFailedSections = mapper.getTopFailedSections(data);
const topFailedResult = mapper.getTopFailedSections(data);
return (
<div className="flex flex-col gap-8">
@@ -205,7 +205,10 @@ const SSRComplianceContent = async ({
fail={totalRequirements.fail}
manual={totalRequirements.manual}
/>
<TopFailedSectionsCard sections={topFailedSections} />
<TopFailedSectionsCard
sections={topFailedResult.items}
dataType={topFailedResult.type}
/>
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
</div>
+2 -4
View File
@@ -2,11 +2,10 @@ import "@/styles/globals.css";
import * as Sentry from "@sentry/nextjs";
import { Metadata, Viewport } from "next";
import { ReactNode } from "react";
import React from "react";
import { getProviders } from "@/actions/providers";
import MainLayout from "@/components/ui/main-layout/main-layout";
import { NavigationProgress } from "@/components/ui/navigation-progress";
import { Toaster } from "@/components/ui/toast";
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
@@ -39,7 +38,7 @@ export const viewport: Viewport = {
export default async function RootLayout({
children,
}: {
children: ReactNode;
children: React.ReactNode;
}) {
const providersData = await getProviders({ page: 1, pageSize: 1 });
const hasProviders = !!(providersData?.data && providersData.data.length > 0);
@@ -55,7 +54,6 @@ export default async function RootLayout({
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<NavigationProgress />
<StoreInitializer values={{ hasProviders }} />
<MainLayout>{children}</MainLayout>
<Toaster />
@@ -3,14 +3,16 @@
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
import { BarDataPoint } from "@/components/graphs/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { FailedSection } from "@/types/compliance";
import { FailedSection, TopFailedDataType } from "@/types/compliance";
interface TopFailedSectionsCardProps {
sections: FailedSection[];
dataType?: TopFailedDataType;
}
export function TopFailedSectionsCard({
sections,
dataType = "sections",
}: TopFailedSectionsCardProps) {
// Transform FailedSection[] to BarDataPoint[]
const total = sections.reduce((sum, section) => sum + section.total, 0);
@@ -22,13 +24,18 @@ export function TopFailedSectionsCard({
color: "var(--bg-fail-primary)",
}));
const title =
dataType === "requirements"
? "Top Failed Requirements"
: "Top Failed Sections";
return (
<Card
variant="base"
className="flex min-h-[372px] w-full flex-col sm:min-w-[500px]"
>
<CardHeader>
<CardTitle>Top Failed Sections</CardTitle>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 items-center justify-start">
<HorizontalBarChart data={barData} />
+2 -2
View File
@@ -32,11 +32,11 @@ export function AlertPill({
>
<AlertTriangle
size={iconSize}
style={{ color: "var(--color-text-error-primary)" }}
style={{ color: "var(--color-text-text-error)" }}
/>
<span
className={cn(textSizeClass, "font-semibold")}
style={{ color: "var(--color-text-error-primary)" }}
style={{ color: "var(--color-text-text-error)" }}
>
{value}
</span>
-1
View File
@@ -12,7 +12,6 @@ export * from "./feedback-banner/feedback-banner";
export * from "./headers/navigation-header";
export * from "./label/Label";
export * from "./main-layout/main-layout";
export * from "./navigation-progress";
export * from "./select";
export * from "./sidebar";
export * from "./toast";
@@ -1,7 +0,0 @@
export { NavigationProgress } from "./navigation-progress";
export {
cancelProgress,
completeProgress,
startProgress,
useNavigationProgress,
} from "./use-navigation-progress";
@@ -1,42 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib";
import { useNavigationProgress } from "./use-navigation-progress";
const HIDE_DELAY_MS = 200;
export function NavigationProgress() {
const { isLoading, progress } = useNavigationProgress();
const [visible, setVisible] = useState(false);
useEffect(() => {
if (isLoading) return setVisible(true);
const timeout = setTimeout(() => setVisible(false), HIDE_DELAY_MS);
return () => clearTimeout(timeout);
}, [isLoading]);
if (!visible) return null;
return (
<div
className="fixed top-0 left-0 z-[99999] h-[3px] w-full"
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Page loading progress"
>
<div
className={cn(
"bg-button-primary h-full transition-all duration-200 ease-out",
isLoading && "shadow-progress-glow",
)}
style={{ width: `${progress}%` }}
/>
</div>
);
}
@@ -1,106 +0,0 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useSyncExternalStore } from "react";
interface ProgressState {
isLoading: boolean;
progress: number;
}
// Global state
let state: ProgressState = { isLoading: false, progress: 0 };
const listeners = new Set<() => void>();
let progressInterval: ReturnType<typeof setInterval> | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
// Cached server snapshot to avoid infinite loop with useSyncExternalStore
const SERVER_SNAPSHOT: ProgressState = { isLoading: false, progress: 0 };
function notify() {
listeners.forEach((listener) => listener());
}
function setState(newState: ProgressState) {
state = newState;
notify();
}
function clearTimers() {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
}
/**
* Start the progress bar animation.
* Progress increases quickly at first, then slows down as it approaches 90%.
*/
export function startProgress() {
clearTimers();
setState({ isLoading: true, progress: 0 });
progressInterval = setInterval(() => {
if (state.progress < 90) {
const increment = (90 - state.progress) * 0.1;
setState({
...state,
progress: Math.min(90, state.progress + increment),
});
}
}, 100);
}
/**
* Complete the progress bar animation.
* Jumps to 100% and then hides after a brief delay.
*/
export function completeProgress() {
clearTimers();
setState({ isLoading: false, progress: 100 });
timeoutId = setTimeout(() => {
setState({ isLoading: false, progress: 0 });
timeoutId = null;
}, 200);
}
/**
* Cancel the progress bar immediately without animation.
*/
export function cancelProgress() {
clearTimers();
setState({ isLoading: false, progress: 0 });
}
/**
* Hook to access progress bar state.
* Automatically completes progress when URL changes.
*/
export function useNavigationProgress() {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentState = useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => state,
() => SERVER_SNAPSHOT,
);
// Complete progress when URL changes (only if currently loading)
useEffect(() => {
if (state.isLoading) {
completeProgress();
}
}, [pathname, searchParams]);
return currentState;
}
-44
View File
@@ -1,44 +0,0 @@
/**
* Next.js Client Instrumentation
*
* This file runs on the client before React hydration.
* Used to set up navigation progress tracking.
*
* @see https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
*/
import {
cancelProgress,
startProgress,
} from "@/components/ui/navigation-progress/use-navigation-progress";
const NAVIGATION_TYPE = {
PUSH: "push",
REPLACE: "replace",
TRAVERSE: "traverse",
} as const;
type NavigationType = (typeof NAVIGATION_TYPE)[keyof typeof NAVIGATION_TYPE];
function getCurrentUrl(): string {
return window.location.pathname + window.location.search;
}
/**
* Called by Next.js when router navigation begins.
* Triggers the navigation progress bar.
*/
export function onRouterTransitionStart(
url: string,
_navigationType: NavigationType,
) {
const currentUrl = getCurrentUrl();
if (url === currentUrl) {
// Same URL - cancel any ongoing progress
cancelProgress();
} else {
// Different URL - start progress
startProgress();
}
}
+50 -9
View File
@@ -1,12 +1,12 @@
import {
CategoryData,
FailedSection,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementItemData,
RequirementsData,
RequirementStatus,
TopFailedResult,
} from "@/types/compliance";
export const updateCounters = (
@@ -24,9 +24,48 @@ export const updateCounters = (
export const getTopFailedSections = (
mappedData: Framework[],
): FailedSection[] => {
): TopFailedResult => {
const failedSectionMap = new Map();
// Check if we have a flat structure (requirements directly in framework)
const hasFlatStructure = mappedData.some((framework) => {
const directRequirements = (framework as any).requirements || [];
return directRequirements.length > 0 && framework.categories.length === 0;
});
if (hasFlatStructure) {
// Handle flat structure: count failed requirements directly
mappedData.forEach((framework) => {
const directRequirements =
((framework as any).requirements as Requirement[]) || [];
directRequirements.forEach((requirement) => {
if (requirement.status === REQUIREMENT_STATUS.FAIL) {
const requirementName = requirement.name;
if (!failedSectionMap.has(requirementName)) {
failedSectionMap.set(requirementName, { total: 0, types: {} });
}
const requirementData = failedSectionMap.get(requirementName);
requirementData.total += 1;
const type = (requirement.type as string) || "Fails";
requirementData.types[type] = (requirementData.types[type] || 0) + 1;
}
});
});
return {
items: Array.from(failedSectionMap.entries())
.map(([name, data]) => ({ name, ...data }))
.sort((a, b) => b.total - a.total)
.slice(0, 5),
type: "requirements",
};
}
// Handle hierarchical structure: count by category (section)
mappedData.forEach((framework) => {
framework.categories.forEach((category) => {
category.controls.forEach((control) => {
@@ -41,10 +80,9 @@ export const getTopFailedSections = (
const sectionData = failedSectionMap.get(sectionName);
sectionData.total += 1;
const type = requirement.type || "Fails";
const type = (requirement.type as string) || "Fails";
sectionData.types[type as string] =
(sectionData.types[type as string] || 0) + 1;
sectionData.types[type] = (sectionData.types[type] || 0) + 1;
}
});
});
@@ -52,10 +90,13 @@ export const getTopFailedSections = (
});
// Convert in descending order and slice top 5
return Array.from(failedSectionMap.entries())
.map(([name, data]) => ({ name, ...data }))
.sort((a, b) => b.total - a.total)
.slice(0, 5); // Top 5
return {
items: Array.from(failedSectionMap.entries())
.map(([name, data]) => ({ name, ...data }))
.sort((a, b) => b.total - a.total)
.slice(0, 5),
type: "sections",
};
};
export const calculateCategoryHeatmapData = (
+2 -2
View File
@@ -14,10 +14,10 @@ import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import {
AttributesData,
CategoryData,
FailedSection,
Framework,
Requirement,
RequirementsData,
TopFailedResult,
} from "@/types/compliance";
import {
@@ -74,7 +74,7 @@ export interface ComplianceMapper {
data: Framework[],
scanId: string | undefined,
) => AccordionItemProps[];
getTopFailedSections: (mappedData: Framework[]) => FailedSection[];
getTopFailedSections: (mappedData: Framework[]) => TopFailedResult;
calculateCategoryHeatmapData: (complianceData: Framework[]) => CategoryData[];
getDetailsComponent: (requirement: Requirement) => React.ReactNode;
}
+9 -6
View File
@@ -5,13 +5,13 @@ import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
CategoryData,
FailedSection,
Framework,
MITREAttributesMetadata,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
TopFailedResult,
} from "@/types/compliance";
import {
@@ -149,7 +149,7 @@ export const toAccordionItems = (
// Custom function for MITRE to get top failed sections grouped by tactics
export const getTopFailedSections = (
mappedData: Framework[],
): FailedSection[] => {
): TopFailedResult => {
const failedSectionMap = new Map();
mappedData.forEach((framework) => {
@@ -175,10 +175,13 @@ export const getTopFailedSections = (
});
// Convert in descending order and slice top 5
return Array.from(failedSectionMap.entries())
.map(([name, data]) => ({ name, ...data }))
.sort((a, b) => b.total - a.total)
.slice(0, 5); // Top 5
return {
items: Array.from(failedSectionMap.entries())
.map(([name, data]) => ({ name, ...data }))
.sort((a, b) => b.total - a.total)
.slice(0, 5),
type: "sections",
};
};
// Custom function for MITRE to calculate category heatmap data grouped by tactics
-9
View File
@@ -75,9 +75,6 @@
/* Chart Dots */
--chart-dots: var(--color-neutral-200);
/* Progress Bar */
--shadow-progress-glow: 0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
}
/* ===== DARK THEME ===== */
@@ -147,9 +144,6 @@
/* Chart Dots */
--chart-dots: var(--text-neutral-primary);
/* Progress Bar */
--shadow-progress-glow: 0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
}
/* ===== TAILWIND THEME MAPPINGS ===== */
@@ -217,9 +211,6 @@
--color-bg-warning: var(--bg-warning-primary);
--color-bg-fail: var(--bg-fail-primary);
--color-bg-fail-secondary: var(--bg-fail-secondary);
/* Shadows */
--shadow-progress-glow: var(--shadow-progress-glow);
}
/* ===== CONTAINER UTILITY ===== */
+7
View File
@@ -76,6 +76,13 @@ export interface FailedSection {
types?: { [key: string]: number };
}
export type TopFailedDataType = "sections" | "requirements";
export interface TopFailedResult {
items: FailedSection[];
type: TopFailedDataType;
}
export interface RequirementsTotals {
pass: number;
fail: number;