From abaacd7dbf4e7fed579b19f89c2b4d5d02d0ac38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pe=C3=B1a?= Date: Tue, 7 Apr 2026 16:41:08 +0200 Subject: [PATCH] feat(api): finding group first_seen_at semantics and resource delta (#10595) --- api/CHANGELOG.md | 1 + api/src/backend/api/v1/serializers.py | 1 + api/src/backend/api/v1/views.py | 42 +++++++++++++++++++++++++++ api/src/backend/tasks/jobs/scan.py | 4 ++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 49d856302e..4704532d22 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to the **Prowler API** are documented in this file. - Membership `post_delete` signal using raw FK ids to avoid `DoesNotExist` during cascade deletions [(#10497)](https://github.com/prowler-cloud/prowler/pull/10497) - Finding group resources endpoints returning false 404 when filters match no results, and `sort` parameter being ignored [(#10510)](https://github.com/prowler-cloud/prowler/pull/10510) - Jira integration failing with `JiraInvalidIssueTypeError` on non-English Jira instances due to hardcoded `"Task"` issue type; now dynamically fetches available issue types per project [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) +- Finding group `first_seen_at` now reflects when a new finding appeared in the scan instead of the oldest carry-forward date across all unchanged findings [(#10595)](https://github.com/prowler-cloud/prowler/pull/10595) - Attack Paths: Remove `clear_cache` call from read-only query endpoints; cache clearing belongs to the scan/ingestion flow, not API queries [(#10586)](https://github.com/prowler-cloud/prowler/pull/10586) ### 🔐 Security diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 185a8e047a..6af4d01000 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -4216,6 +4216,7 @@ class FindingGroupResourceSerializer(BaseSerializerV1): provider = serializers.SerializerMethodField() status = serializers.CharField() severity = serializers.CharField() + delta = serializers.CharField(required=False, allow_null=True) first_seen_at = serializers.DateTimeField(required=False, allow_null=True) last_seen_at = serializers.DateTimeField(required=False, allow_null=True) muted_reason = serializers.CharField(required=False, allow_null=True) diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 32c7834274..38875ccc2f 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -7234,6 +7234,7 @@ class FindingGroupViewSet(BaseRLSViewSet): _RESOURCE_SORT_MAP = { "status": "status_order", "severity": "severity_order", + "delta": "delta_order", "first_seen_at": "first_seen_at", "last_seen_at": "last_seen_at", "resource.uid": "resource_uid", @@ -7370,6 +7371,22 @@ class FindingGroupViewSet(BaseRLSViewSet): output_field=IntegerField(), ) ), + delta_order=Max( + Case( + When( + finding__delta="new", + finding__muted=False, + then=Value(2), + ), + When( + finding__delta="changed", + finding__muted=False, + then=Value(1), + ), + default=Value(0), + output_field=IntegerField(), + ) + ), first_seen_at=Min("finding__first_seen_at"), last_seen_at=Max("finding__inserted_at"), # Max() on muted_reason / check_metadata is safe because @@ -7402,6 +7419,22 @@ class FindingGroupViewSet(BaseRLSViewSet): output_field=IntegerField(), ) ), + "delta_order": lambda: Max( + Case( + When( + finding__delta="new", + finding__muted=False, + then=Value(2), + ), + When( + finding__delta="changed", + finding__muted=False, + then=Value(1), + ), + default=Value(0), + output_field=IntegerField(), + ) + ), "first_seen_at": lambda: Min("finding__first_seen_at"), "last_seen_at": lambda: Max("finding__inserted_at"), "resource_uid": lambda: Max("resource__uid"), @@ -7448,6 +7481,14 @@ class FindingGroupViewSet(BaseRLSViewSet): else: status = "MUTED" + delta_order = row.get("delta_order", 0) + if delta_order == 2: + delta = "new" + elif delta_order == 1: + delta = "changed" + else: + delta = None + results.append( { "resource_id": row["resource_id"], @@ -7463,6 +7504,7 @@ class FindingGroupViewSet(BaseRLSViewSet): "severity": SEVERITY_ORDER_REVERSE.get( severity_order, "informational" ), + "delta": delta, "first_seen_at": row["first_seen_at"], "last_seen_at": row["last_seen_at"], "muted_reason": row.get("muted_reason"), diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 3a96e892e1..364b12d146 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -1824,7 +1824,9 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str): filter=Q(status="FAIL", muted=False), ), # Use prefixed names to avoid conflict with model field names - agg_first_seen_at=Min("first_seen_at"), + agg_first_seen_at=Min( + "first_seen_at", filter=Q(delta="new", muted=False) + ), agg_last_seen_at=Max("inserted_at"), agg_failing_since=Min( "first_seen_at", filter=Q(status="FAIL", muted=False)