Skip to content

Commit

Permalink
Add ability to redact device data from diagnostics output
Browse files Browse the repository at this point in the history
- use this on camera images and passwords

Issue #1984 (appropriately)
  • Loading branch information
make-all committed Jun 11, 2024
1 parent 22d56da commit 07f15a6
Show file tree
Hide file tree
Showing 14 changed files with 123 additions and 7 deletions.
8 changes: 8 additions & 0 deletions custom_components/tuya_local/devices/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ primary_entity:
type: base64
persist: false
optional: true
sensitive: true
mapping:
- dps_val: ""
value_redirect: motion_detected
Expand All @@ -22,6 +23,7 @@ primary_entity:
- id: 115
name: motion_detected
type: base64
sensitive: true
secondary_entities:
- entity: lock
name: Door lock
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ primary_entity:
type: base64
optional: true
persist: false
sensitive: true
name: snapshot
secondary_entities:
- entity: switch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ primary_entity:
name: snapshot
type: base64
optional: true
sensitive: true
- id: 134
name: motion_enable
type: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ primary_entity:
name: snapshot
type: base64
optional: true
sensitive: true
- id: 134
name: motion_enable
type: boolean
Expand Down
1 change: 1 addition & 0 deletions custom_components/tuya_local/devices/lsc_ptz_camera.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ primary_entity:
type: base64
optional: true
persist: false
sensitive: true
name: snapshot
secondary_entities:
- entity: switch
Expand Down
1 change: 1 addition & 0 deletions custom_components/tuya_local/devices/moebot_s_mower.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ primary_entity:
- id: 106
type: integer
name: password
sensitive: true
- id: 110
type: string
name: schedule
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ primary_entity:
name: snapshot
type: base64
optional: true
sensitive: true
- id: 134
name: motion_enable
type: boolean
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ secondary_entities:
type: base64
name: snapshot
optional: true
sensitive: true
- entity: switch
name: Motion notification
icon: "mdi:motion-sensor"
Expand Down
1 change: 1 addition & 0 deletions custom_components/tuya_local/devices/rl_video_lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ primary_entity:
name: snapshot
type: base64
optional: true
sensitive: true
- id: 191
name: smart_action
type: string
Expand Down
40 changes: 36 additions & 4 deletions custom_components/tuya_local/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,14 +46,17 @@ 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,
"type": entry.data[CONF_TYPE],
"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,
}
Expand All @@ -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
Expand All @@ -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,
}
Expand Down Expand Up @@ -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"]:
Expand Down
4 changes: 4 additions & 0 deletions custom_components/tuya_local/helpers/device_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions tests/test_device_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
63 changes: 60 additions & 3 deletions tests/test_diagnostics.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

0 comments on commit 07f15a6

Please sign in to comment.