Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions datadog_sync/model/sensitive_data_scanner_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand All @@ -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']}",
Expand Down
136 changes: 135 additions & 1 deletion tests/unit/test_sensitive_data_scanner_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading