diff --git a/docs/content/asset_modelling/locations/PRO__migrating_from_endpoints.md b/docs/content/asset_modelling/locations/PRO__migrating_from_endpoints.md index 683f4b6a560..9da37cbeb87 100644 --- a/docs/content/asset_modelling/locations/PRO__migrating_from_endpoints.md +++ b/docs/content/asset_modelling/locations/PRO__migrating_from_endpoints.md @@ -97,6 +97,7 @@ A few things behave differently from the original Endpoint API: - **Single status instead of flags.** Locations have one status at a time. If your code relied on a Finding being *both* `mitigated=True` *and* `false_positive=True` simultaneously on an Endpoint_Status, that is no longer representable — the migration picks the highest-priority flag (the order shown in the table above). - **`endpoint` field on Endpoint_Status.** The legacy `endpoint` field is reconstructed by looking up the matching Asset Reference. In rare cases where a Finding's Asset no longer matches its Location's Asset references, this field may be null. +- **`mitigated` returned a date before 3.2.400.** On releases before 3.2.400 the `mitigated` field of `/api/v2/endpoint_status/` returned the record's creation date instead of a boolean. Because a date string is truthy, `mitigated_time` and `mitigated_by` answered as though every status was mitigated. From 3.2.400 the field is a boolean that is true only when the Location status is `Mitigated`. If you poll for mitigated statuses, upgrade before you trust this field. - **Pagination and ordering.** Available ordering fields on the read-compat shim are `host`, `product`, `id`, and `active_finding_count`. If your client orders by another field, switch to one of these or move to the new Locations endpoints. ## Tags and Metadata diff --git a/dojo/location/api/endpoint_compat.py b/dojo/location/api/endpoint_compat.py index ccfc156a9bc..c31d6ceeda9 100644 --- a/dojo/location/api/endpoint_compat.py +++ b/dojo/location/api/endpoint_compat.py @@ -273,7 +273,7 @@ def get_date(self, obj) -> datetime.date | None: return obj.created.date() if obj.created else None def get_mitigated(self, obj) -> bool | None: - return obj.created.date() if obj.created else None + return obj.status == FindingLocationStatus.Mitigated def get_mitigated_time(self, obj) -> datetime.datetime | None: return obj.audit_time if self.get_mitigated(obj) else None diff --git a/unittests/test_endpoint_init_v3.py b/unittests/test_endpoint_init_v3.py index be090b4eb8b..13cbf4d9747 100644 --- a/unittests/test_endpoint_init_v3.py +++ b/unittests/test_endpoint_init_v3.py @@ -31,6 +31,7 @@ from django.urls import reverse from django.utils import timezone from openpyxl import load_workbook +from parameterized import parameterized from rest_framework.authtoken.models import Token from rest_framework.test import APIClient @@ -38,6 +39,7 @@ from dojo.finding.helper import post_process_findings_batch from dojo.github.services import github_body from dojo.jira.helper import jira_description +from dojo.location.api.endpoint_compat import V3EndpointStatusCompatibleSerializer from dojo.location.models import LocationFindingReference, LocationProductReference from dojo.location.status import FindingLocationStatus, ProductLocationStatus from dojo.models import ( @@ -335,3 +337,54 @@ def test_post_process_findings_batch_with_legacy_endpoints_under_v3(self): ) self.assertTrue(Finding.objects.filter(id=tree.finding.id).exists()) + + # ------------------------------------------------------------------ + # endpoint_status compatibility serializer + # ------------------------------------------------------------------ + # Regression: V3EndpointStatusCompatibleSerializer.get_mitigated returned obj.created.date() + # (a copy of get_date) instead of the Mitigated status, so every row reported a truthy date. + @parameterized.expand([ + (FindingLocationStatus.Active, False), + (FindingLocationStatus.Mitigated, True), + (FindingLocationStatus.FalsePositive, False), + (FindingLocationStatus.RiskAccepted, False), + (FindingLocationStatus.OutOfScope, False), + ]) + def test_endpoint_status_mitigated_is_a_bool_tracking_the_status(self, status, expected): + """``mitigated`` must be a bool that is True only for a Mitigated location.""" + tree = self._make_tree(f"mit-{status.value}") + ref = self._add_location(tree, f"loc-{status.value.lower()}.example.com", status=status) + + data = V3EndpointStatusCompatibleSerializer(ref).data + + self.assertIsInstance( + data["mitigated"], bool, + msg=f"expected a bool for status={status.value}, got {type(data['mitigated']).__name__} {data['mitigated']!r}", + ) + self.assertEqual( + data["mitigated"], expected, + msg=f"expected mitigated={expected} for status={status.value}, got {data['mitigated']!r}", + ) + + @parameterized.expand([ + (FindingLocationStatus.Active, False), + (FindingLocationStatus.Mitigated, True), + ]) + def test_endpoint_status_mitigated_time_and_by_follow_mitigated(self, status, expected): + """``mitigated_time``/``mitigated_by`` branch on get_mitigated, so they must follow it.""" + tree = self._make_tree(f"mitby-{status.value}") + ref = self._add_location(tree, f"loc-mitby-{status.value.lower()}.example.com", status=status) + ref.auditor = self.admin + ref.audit_time = timezone.now() + ref.save() + + data = V3EndpointStatusCompatibleSerializer(ref).data + + self.assertEqual( + data["mitigated_time"] is not None, expected, + msg=f"expected mitigated_time set={expected} for status={status.value}, got {data['mitigated_time']!r}", + ) + self.assertEqual( + data["mitigated_by"], self.admin.id if expected else None, + msg=f"expected mitigated_by set={expected} for status={status.value}, got {data['mitigated_by']!r}", + )