diff --git a/datadog_sync/model/sensitive_data_scanner_rules.py b/datadog_sync/model/sensitive_data_scanner_rules.py index 18c15126..209f2e9b 100644 --- a/datadog_sync/model/sensitive_data_scanner_rules.py +++ b/datadog_sync/model/sensitive_data_scanner_rules.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional, List, Dict, Tuple +from datadog_sync.constants import Command, Metrics from datadog_sync.utils.base_resource import BaseResource, ResourceConfig from datadog_sync.utils.resource_utils import SkipResource @@ -67,6 +68,58 @@ async def pre_resource_action_hook(self, _id, resource: Dict) -> None: ) resource["relationships"]["standard_pattern"]["data"]["id"] = dest_id + async def _align_name_with_standard_pattern(self, _id: str, resource: Dict) -> None: + # Destination API rejects a standard-pattern-linked rule whose + # attributes.name does not match the linked pattern's canonical name. + # Overwrite the name on write and emit a metric so operators can + # audit drift. Applied only on create/update (not diffs/import) so + # source state is not silently mutated. By the time this runs, + # pre_resource_action_hook has already replaced data.id with the + # destination pattern uuid, so resolve the canonical name via the + # destination mapping (name -> id) rather than trusting the id + # field to still hold a name string. + pattern_id = ((resource.get("relationships", {}).get("standard_pattern", {}).get("data") or {}).get("id")) + if not pattern_id: + return + pattern_name = next( + (n for n, i in self.destination_standard_pattern_mapping.items() if i == pattern_id), + None, + ) + if not pattern_name: + return + attrs = resource.setdefault("attributes", {}) + source_name = attrs.get("name") + if not source_name or source_name == pattern_name: + return + attrs["name"] = pattern_name + self.config.logger.debug( + "%s %s: aligned attributes.name '%s' -> '%s' to match linked standard pattern", + self.resource_type, + _id, + source_name, + pattern_name, + ) + try: + await self.config.destination_client.send_metric( + Metrics.ACTION.value, + [ + f"id:{_id}", + f"resource_type:{self.resource_type}", + f"action_type:{Command.SYNC.value}", + "action_sub_type:standard_pattern_name_rewrite", + "status:success", + "client_type:destination", + f"pattern:{pattern_name}", + ], + ) + except Exception as e: + self.config.logger.debug( + "Failed to send standard_pattern_name_rewrite metric for %s %s: %s", + self.resource_type, + _id, + e, + ) + async def pre_apply_hook(self) -> None: destination_client = self.config.destination_client if not self.destination_standard_pattern_mapping: @@ -85,6 +138,7 @@ async def pre_apply_hook(self) -> None: async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client + await self._align_name_with_standard_pattern(_id, resource) payload = {"data": resource, "meta": {}} resp = await destination_client.post(self.resource_config.base_path + "/rules", payload) @@ -93,6 +147,7 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client resource["id"] = self.config.state.destination[self.resource_type][_id]["id"] + await self._align_name_with_standard_pattern(_id, resource) payload = {"data": resource, "meta": {}} await destination_client.patch( self.resource_config.base_path + f"/rules/{self.config.state.destination[self.resource_type][_id]['id']}", diff --git a/tests/unit/test_sensitive_data_scanner_rules.py b/tests/unit/test_sensitive_data_scanner_rules.py index 48f02da5..22a69b3e 100644 --- a/tests/unit/test_sensitive_data_scanner_rules.py +++ b/tests/unit/test_sensitive_data_scanner_rules.py @@ -13,8 +13,9 @@ import asyncio import pytest -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock +from datadog_sync.constants import Metrics from datadog_sync.model.sensitive_data_scanner_rules import SensitiveDataScannerRules from datadog_sync.utils.resource_utils import SkipResource, prep_resource @@ -158,3 +159,136 @@ def test_prep_resource_handles_missing_attributes_key(self): } # Should not raise prep_resource(SensitiveDataScannerRules.resource_config, resource) + + +class TestSensitiveDataScannerRulesCanonicalNameRewrite: + """Rewrites attributes.name to the linked standard pattern's canonical + name on write, since the destination CREATE validator rejects a + non-matching name. Emits a metric per rewrite for audit. Applied only + on create/update (not diffs/import) so source state stays untouched.""" + + # By the time _align_name_with_standard_pattern runs (from create/update), + # pre_resource_action_hook has already replaced data.id with the destination + # pattern uuid. So test inputs use the destination uuid and rely on + # destination_standard_pattern_mapping (name -> id) for reverse lookup. + VISA_NAME = "Visa Card Scanner (4x4 digits)" + VISA_DEST_ID = "dest-visa-uuid" + MC_NAME = "MasterCard Scanner (4x4 digits)" + MC_DEST_ID = "dest-mc-uuid" + EMAIL_NAME = "Email Address Scanner" + EMAIL_DEST_ID = "dest-email-uuid" + + def _make_rules(self, mapping=None): + mock_config = MagicMock() + mock_config.state = MagicMock() + mock_config.destination_client = MagicMock() + mock_config.destination_client.send_metric = AsyncMock() + rules = SensitiveDataScannerRules(mock_config) + rules.destination_standard_pattern_mapping = mapping if mapping is not None else { + self.VISA_NAME: self.VISA_DEST_ID, + self.MC_NAME: self.MC_DEST_ID, + self.EMAIL_NAME: self.EMAIL_DEST_ID, + } + return rules + + def _resource(self, _id, name, pattern_dest_id): + return { + "id": _id, + "type": "sensitive_data_scanner_rule", + "attributes": {"name": name}, + "relationships": { + "standard_pattern": { + "data": {"id": pattern_dest_id, "type": "sensitive_data_scanner_standard_pattern"} + } + }, + } + + def test_align_rewrites_name_when_mismatched(self): + rules = self._make_rules() + resource = self._resource("rule-1", "Custom Visa Scanner", self.VISA_DEST_ID) + asyncio.run(rules._align_name_with_standard_pattern("rule-1", resource)) + assert resource["attributes"]["name"] == self.VISA_NAME + + def test_align_noop_when_name_matches(self): + rules = self._make_rules() + resource = self._resource("rule-2", self.EMAIL_NAME, self.EMAIL_DEST_ID) + asyncio.run(rules._align_name_with_standard_pattern("rule-2", resource)) + assert resource["attributes"]["name"] == self.EMAIL_NAME + rules.config.destination_client.send_metric.assert_not_called() + + def test_align_noop_when_source_name_is_empty(self): + rules = self._make_rules() + resource = self._resource("rule-3", "", self.VISA_DEST_ID) + asyncio.run(rules._align_name_with_standard_pattern("rule-3", resource)) + assert resource["attributes"]["name"] == "" + rules.config.destination_client.send_metric.assert_not_called() + + def test_align_noop_when_no_standard_pattern(self): + rules = self._make_rules() + resource = { + "id": "custom", + "type": "sensitive_data_scanner_rule", + "attributes": {"name": "Custom SSN"}, + "relationships": {"group": {"data": {"id": "grp"}}}, + } + asyncio.run(rules._align_name_with_standard_pattern("custom", resource)) + assert resource["attributes"]["name"] == "Custom SSN" + rules.config.destination_client.send_metric.assert_not_called() + + def test_align_noop_when_pattern_id_not_in_destination_mapping(self): + # If we cannot resolve the canonical name, do not touch the name — + # do not rewrite it to the raw destination uuid. + rules = self._make_rules(mapping={}) + resource = self._resource("rule-x", "Custom Visa", self.VISA_DEST_ID) + asyncio.run(rules._align_name_with_standard_pattern("rule-x", resource)) + assert resource["attributes"]["name"] == "Custom Visa" + rules.config.destination_client.send_metric.assert_not_called() + + def test_align_emits_metric_with_expected_tags(self): + rules = self._make_rules() + resource = self._resource("rule-4", "Custom MC", self.MC_DEST_ID) + asyncio.run(rules._align_name_with_standard_pattern("rule-4", resource)) + rules.config.destination_client.send_metric.assert_awaited_once() + metric_name, tags = rules.config.destination_client.send_metric.await_args.args + assert metric_name == Metrics.ACTION.value + assert "id:rule-4" in tags + assert "resource_type:sensitive_data_scanner_rules" in tags + assert "action_type:sync" in tags + assert "action_sub_type:standard_pattern_name_rewrite" in tags + assert "status:success" in tags + assert "client_type:destination" in tags + assert f"pattern:{self.MC_NAME}" in tags + + def test_align_tolerates_metric_failure(self): + rules = self._make_rules() + rules.config.destination_client.send_metric = AsyncMock(side_effect=Exception("metric down")) + resource = self._resource("rule-5", "Custom Visa", self.VISA_DEST_ID) + asyncio.run(rules._align_name_with_standard_pattern("rule-5", resource)) + assert resource["attributes"]["name"] == self.VISA_NAME + + def test_pre_resource_action_hook_does_not_rewrite_name(self): + # The hook runs on the diffs path against a live reference into + # state.source (resources_handler.py:571 — no deepcopy). It must + # not mutate attributes.name; the rewrite happens in create/update. + rules = self._make_rules() + # In the diffs path the pattern data.id is still the source-side name. + resource = self._resource("rule-6", "Custom Visa", self.VISA_NAME) + asyncio.run(rules.pre_resource_action_hook("rule-6", resource)) + assert resource["attributes"]["name"] == "Custom Visa" + assert resource["relationships"]["standard_pattern"]["data"]["id"] == self.VISA_DEST_ID + rules.config.destination_client.send_metric.assert_not_called() + + def test_create_path_yields_canonical_pattern_name_not_uuid(self): + # End-to-end orchestration: pre_resource_action_hook translates + # data.id source-name -> destination-uuid, then create_resource + # calls _align_name_with_standard_pattern which must resolve + # canonical NAME (not the uuid) as the target attributes.name. + rules = self._make_rules() + rules.config.destination_client.post = AsyncMock(return_value={"data": {"id": "created"}}) + # Post-import shape: data.id holds the source pattern's canonical name. + resource = self._resource("rule-e2e", "Custom Visa Scanner", self.VISA_NAME) + asyncio.run(rules.pre_resource_action_hook("rule-e2e", resource)) + asyncio.run(rules.create_resource("rule-e2e", resource)) + # attributes.name must be the canonical pattern NAME, not the destination uuid. + assert resource["attributes"]["name"] == self.VISA_NAME + assert resource["attributes"]["name"] != self.VISA_DEST_ID