diff --git a/custom_components/tuya_local/devices/README.md b/custom_components/tuya_local/devices/README.md index fa9fc43788..7efd8cd722 100644 --- a/custom_components/tuya_local/devices/README.md +++ b/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.* diff --git a/custom_components/tuya_local/devices/bcom_intercom_camera.yaml b/custom_components/tuya_local/devices/bcom_intercom_camera.yaml index 07edcadd28..c857aaefd9 100644 --- a/custom_components/tuya_local/devices/bcom_intercom_camera.yaml +++ b/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 diff --git a/custom_components/tuya_local/devices/door_peephole_camera.yaml b/custom_components/tuya_local/devices/door_peephole_camera.yaml index 9e3aebbf31..ad5c95c3a6 100644 --- a/custom_components/tuya_local/devices/door_peephole_camera.yaml +++ b/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 diff --git a/custom_components/tuya_local/devices/kerui_200w_camera.yaml b/custom_components/tuya_local/devices/kerui_200w_camera.yaml index 8056669dcc..b993620cb2 100644 --- a/custom_components/tuya_local/devices/kerui_200w_camera.yaml +++ b/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 diff --git a/custom_components/tuya_local/devices/kerui_300w_camera.yaml b/custom_components/tuya_local/devices/kerui_300w_camera.yaml index 120d5f3075..efd9d0654d 100644 --- a/custom_components/tuya_local/devices/kerui_300w_camera.yaml +++ b/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 diff --git a/custom_components/tuya_local/devices/lsc_ptz_camera.yaml b/custom_components/tuya_local/devices/lsc_ptz_camera.yaml index d4c4f865ab..2efbb8611b 100644 --- a/custom_components/tuya_local/devices/lsc_ptz_camera.yaml +++ b/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 diff --git a/custom_components/tuya_local/devices/moebot_s_mower.yaml b/custom_components/tuya_local/devices/moebot_s_mower.yaml index 925182adf2..5f63e47835 100644 --- a/custom_components/tuya_local/devices/moebot_s_mower.yaml +++ b/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 diff --git a/custom_components/tuya_local/devices/nedis_outdoor_camera.yaml b/custom_components/tuya_local/devices/nedis_outdoor_camera.yaml index c20d55c0e8..d8a41c2a10 100644 --- a/custom_components/tuya_local/devices/nedis_outdoor_camera.yaml +++ b/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 diff --git a/custom_components/tuya_local/devices/petlibro_camera_feeder.yaml b/custom_components/tuya_local/devices/petlibro_camera_feeder.yaml index 16d54aacc2..b4b3a96fd3 100644 --- a/custom_components/tuya_local/devices/petlibro_camera_feeder.yaml +++ b/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" diff --git a/custom_components/tuya_local/devices/rl_video_lock.yaml b/custom_components/tuya_local/devices/rl_video_lock.yaml index f6246a246b..2e74139f8b 100644 --- a/custom_components/tuya_local/devices/rl_video_lock.yaml +++ b/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 diff --git a/custom_components/tuya_local/diagnostics.py b/custom_components/tuya_local/diagnostics.py index cb945a4456..b5980ea3f1 100644 --- a/custom_components/tuya_local/diagnostics.py +++ b/custom_components/tuya_local/diagnostics.py @@ -6,6 +6,7 @@ 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"]: diff --git a/custom_components/tuya_local/helpers/device_config.py b/custom_components/tuya_local/helpers/device_config.py index eb10776ddb..a2c2ac2545 100644 --- a/custom_components/tuya_local/helpers/device_config.py +++ b/custom_components/tuya_local/helpers/device_config.py @@ -360,6 +360,10 @@ def persist(self): 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") diff --git a/tests/test_device_config.py b/tests/test_device_config.py index 3e0e035a09..fb875a120c 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -103,6 +103,7 @@ 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], diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 5976a00421..037cad0ff8 100644 --- a/tests/test_diagnostics.py +++ b/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 @@ 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