فهرست منبع

Add ability to redact device data from diagnostics output

- use this on camera images and passwords

Issue #1984 (appropriately)
Jason Rumney 1 سال پیش
والد
کامیت
07f15a6d12

+ 8 - 0
custom_components/tuya_local/devices/README.md

@@ -152,6 +152,14 @@ attribute will be returned as a readonly custom attribute on the entity.
 If you need non-standard attributes to be able to be set, you will need
 to use a secondary entity for that.
 
+### `sensitive`
+
+*Optional, default false.*
+
+A boolean setting yo mark attributes as containing potentially sensitive
+data.  Setting this to true will result in the data being redacted in
+device diagnostics output.
+
 ### `readonly`
 
 *Optional, default false.*

+ 2 - 0
custom_components/tuya_local/devices/bcom_intercom_camera.yaml

@@ -14,6 +14,7 @@ primary_entity:
       type: base64
       persist: false
       optional: true
+      sensitive: true
       mapping:
         - dps_val: ""
           value_redirect: motion_detected
@@ -22,6 +23,7 @@ primary_entity:
     - id: 115
       name: motion_detected
       type: base64
+      sensitive: true
 secondary_entities:
   - entity: lock
     name: Door lock

+ 1 - 0
custom_components/tuya_local/devices/door_peephole_camera.yaml

@@ -17,6 +17,7 @@ primary_entity:
       type: base64
       optional: true
       persist: false
+      sensitive: true
       name: snapshot
 secondary_entities:
   - entity: switch

+ 1 - 0
custom_components/tuya_local/devices/kerui_200w_camera.yaml

@@ -9,6 +9,7 @@ primary_entity:
       name: snapshot
       type: base64
       optional: true
+      sensitive: true
     - id: 134
       name: motion_enable
       type: boolean

+ 1 - 0
custom_components/tuya_local/devices/kerui_300w_camera.yaml

@@ -9,6 +9,7 @@ primary_entity:
       name: snapshot
       type: base64
       optional: true
+      sensitive: true
     - id: 134
       name: motion_enable
       type: boolean

+ 1 - 0
custom_components/tuya_local/devices/lsc_ptz_camera.yaml

@@ -17,6 +17,7 @@ primary_entity:
       type: base64
       optional: true
       persist: false
+      sensitive: true
       name: snapshot
 secondary_entities:
   - entity: switch

+ 1 - 0
custom_components/tuya_local/devices/moebot_s_mower.yaml

@@ -66,6 +66,7 @@ primary_entity:
     - id: 106
       type: integer
       name: password
+      sensitive: true
     - id: 110
       type: string
       name: schedule

+ 5 - 0
custom_components/tuya_local/devices/nedis_outdoor_camera.yaml

@@ -9,6 +9,7 @@ primary_entity:
       name: snapshot
       type: base64
       optional: true
+      sensitive: true
     - id: 134
       name: motion_enable
       type: boolean
@@ -236,6 +237,10 @@ secondary_entities:
       - id: 254
         type: string
         name: ip_address
+        sensitive: true
+        optional: true
       - id: 253
         type: string
         name: password_change
+        sensitive: true
+        optional: true

+ 1 - 0
custom_components/tuya_local/devices/petlibro_camera_feeder.yaml

@@ -94,6 +94,7 @@ secondary_entities:
         type: base64
         name: snapshot
         optional: true
+        sensitive: true
   - entity: switch
     name: Motion notification
     icon: "mdi:motion-sensor"

+ 1 - 0
custom_components/tuya_local/devices/rl_video_lock.yaml

@@ -13,6 +13,7 @@ primary_entity:
       name: snapshot
       type: base64
       optional: true
+      sensitive: true
     - id: 191
       name: smart_action
       type: string

+ 36 - 4
custom_components/tuya_local/diagnostics.py

@@ -6,6 +6,7 @@ from typing import Any
 
 from homeassistant.components.diagnostics import REDACTED
 from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST
 from homeassistant.core import HomeAssistant, callback
 from homeassistant.helpers import device_registry as dr
 from homeassistant.helpers import entity_registry as er
@@ -45,6 +46,7 @@ def _async_get_diagnostics(
 ) -> dict[str, Any]:
     """Return diagnostics for a tuya-local config entry."""
     hass_data = hass.data[DOMAIN][get_device_id(entry.data)]
+    hostname = entry.data.get(CONF_HOST, "")
 
     data = {
         "name": entry.title,
@@ -52,7 +54,9 @@ def _async_get_diagnostics(
         "device_id": REDACTED,
         "device_cid": REDACTED if entry.data.get(CONF_DEVICE_CID, "") != "" else "",
         "local_key": REDACTED,
-        "host": REDACTED,
+        "host": REDACTED
+        if hostname != "" and hostname.casefold() != "auto"
+        else hostname,
         "protocol_version": entry.data[CONF_PROTOCOL_VERSION],
         "tinytuya_version": tinytuya_version,
     }
@@ -66,6 +70,30 @@ def _async_get_diagnostics(
     return data
 
 
+def redact_dps(device: TuyaLocalDevice, dps: dict[str, Any]) -> dict[str, Any]:
+    """Redact any sensitive data from a list of dps"""
+    sensitive = []
+    for entity in device._children:
+        for dp in entity._config.dps():
+            if dp.sensitive:
+                sensitive += dp.id
+    return {k: (REDACTED if k in sensitive else v) for (k, v) in dps.items()}
+
+
+def redact_entity(
+    device: TuyaLocalDevice,
+    entity_id: str,
+    state_dict: dict[str, Any],
+) -> dict[str, Any]:
+    sensitive = []
+    for entity in device._children:
+        if entity._config.config_id == entity_id:
+            for dp in entity._config.dps():
+                if dp.sensitive:
+                    sensitive += dp.name
+    return {k: (REDACTED if k in sensitive else v) for (k, v) in state_dict.items()}
+
+
 @callback
 def _async_device_as_dict(
     hass: HomeAssistant, device: TuyaLocalDevice
@@ -83,8 +111,8 @@ def _async_device_as_dict(
         ),
         "api_working": device._api_protocol_working,
         "status": device._api.dps_cache,
-        "cached_state": device._cached_state,
-        "pending_state": device._pending_updates,
+        "cached_state": redact_dps(device, device._cached_state),
+        "pending_state": redact_dps(device, device._pending_updates),
         "connected": device._running,
         "force_dps": device._force_dps,
     }
@@ -112,7 +140,11 @@ def _async_device_as_dict(
             state = hass.states.get(entity_entry.entity_id)
             state_dict = None
             if state:
-                state_dict = dict(state.as_dict())
+                state_dict = redact_entity(
+                    device,
+                    entity_entry.entity_id,
+                    state.as_dict(),
+                )
 
                 # Redact entity_picture in case it is sensitive
                 if "entity_picture" in state_dict["attributes"]:

+ 4 - 0
custom_components/tuya_local/helpers/device_config.py

@@ -360,6 +360,10 @@ class TuyaDpsConfig:
     def force(self):
         return self._config.get("force", False)
 
+    @property
+    def sensitive(self):
+        return self._config.get("sensitive", False)
+
     @property
     def format(self):
         fmt = self._config.get("format")

+ 1 - 0
tests/test_device_config.py

@@ -103,6 +103,7 @@ DP_SCHEMA = vol.Schema(
         vol.Optional("persist"): False,
         vol.Optional("hidden"): True,
         vol.Optional("readonly"): True,
+        vol.Optional("sensitive"): True,
         vol.Optional("force"): True,
         vol.Optional("icon_priority"): int,
         vol.Optional("mapping"): [MAPPING_SCHEMA],

+ 60 - 3
tests/test_diagnostics.py

@@ -1,6 +1,8 @@
 """Tests for diagnostics platform"""
 
-from unittest.mock import AsyncMock
+from homeassistant.components.diagnostics import REDACTED
+from homeassistant.const import CONF_HOST
+from unittest.mock import Mock
 
 import pytest
 from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -16,6 +18,7 @@ from custom_components.tuya_local.diagnostics import (
     async_get_config_entry_diagnostics,
     async_get_device_diagnostics,
 )
+from custom_components.tuya_local.helpers.device_config import TuyaEntityConfig
 
 
 @pytest.mark.asyncio
@@ -29,7 +32,11 @@ async def test_config_entry_diagnostics(hass):
             CONF_TYPE: "simple_switch",
         },
     )
-    m_device = AsyncMock()
+    m_device = Mock()
+    m_device._api_protocol_version_index = 0
+    m_device._children = []
+    m_device._cached_state = {"1": "Test"}
+    m_device._pending_updates = {}
     hass.data[DOMAIN] = {"test_device": {"device": m_device}}
     diag = await async_get_config_entry_diagnostics(hass, entry)
     assert diag
@@ -46,8 +53,58 @@ async def test_device_diagnostics(hass):
             CONF_TYPE: "simple_switch",
         },
     )
-    m_device = AsyncMock()
+    m_device = Mock()
+    m_device._api_protocol_version_index = 0
+    m_device._children = []
+    m_device._cached_state = {"1": "Test"}
+    m_device._pending_updates = {}
     hass.data[DOMAIN] = {"test_device": {"device": m_device}}
     diag = await async_get_device_diagnostics(hass, entry, m_device)
 
     assert diag
+
+
+@pytest.mark.asyncio
+async def test_diagnostic_redaction(hass):
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_DEVICE_ID: "test_device",
+            CONF_LOCAL_KEY: "test_key",
+            CONF_PROTOCOL_VERSION: "auto",
+            CONF_HOST: "auto",
+            CONF_TYPE: "",
+        },
+    )
+    m_device = Mock()
+    m_entity = Mock()
+    config = TuyaEntityConfig(
+        Mock(),
+        {
+            "entity": "sensor",
+            "dps": [
+                {
+                    "id": "1",
+                    "type": "string",
+                    "name": "sensor",
+                },
+                {
+                    "id": "2",
+                    "type": "string",
+                    "name": "secrets",
+                    "sensitive": True,
+                },
+            ],
+        },
+    )
+    m_entity._config = config
+    m_device._api_protocol_version_index = 0
+    m_device._children = [m_entity]
+    m_device._cached_state = {"1": "Test", "2": "secret"}
+    m_device._pending_updates = {}
+    hass.data[DOMAIN] = {"test_device": {"device": m_device}}
+    diag = await async_get_device_diagnostics(hass, entry, m_device)
+
+    assert diag["device_id"] is REDACTED
+    assert diag["local_key"] is REDACTED
+    assert diag["cached_state"]["2"] is REDACTED