Răsfoiți Sursa

Merge commit from fork

redact_entity compared TuyaEntityConfig.config_id, a config local slug
such as "lock", against a Home Assistant entity_id such as
"lock.front_door". Those can never be equal, so the list of sensitive
dps was always empty and the state was returned unchanged.

Sensitive dps are documented as being redacted from diagnostics output,
and 50 configs rely on that, mostly locks and intercoms whose dps carry
unlock codes and passwords. Diagnostics downloads are routinely attached
to public issue reports.

Join on unique_id, which the entity registry also carries, and redact dp
names from the attributes rather than the top level keys. A sensitive dp
consumed by the platform is reported as the state, so redact that too,
but only when the state actually carries that dp's value: blanking it
unconditionally would discard useful states such as a camera's
"recording", whose sensitive snapshot dp is never the state.
Leesh 10 ore în urmă
părinte
comite
053eaef1be
2 a modificat fișierele cu 124 adăugiri și 8 ștergeri
  1. 35 8
      custom_components/tuya_local/diagnostics.py
  2. 89 0
      tests/test_diagnostics.py

+ 35 - 8
custom_components/tuya_local/diagnostics.py

@@ -82,16 +82,43 @@ def redact_dps(device: TuyaLocalDevice, dps: dict[str, Any]) -> dict[str, Any]:
 
 def redact_entity(
     device: TuyaLocalDevice,
-    entity_id: str,
+    entity_unique_id: str | None,
     state_dict: dict[str, Any],
 ) -> dict[str, Any]:
-    sensitive = []
+    """Redact any sensitive dps from an entity's state.
+
+    Sensitive dps the entity publishes as extra attributes are redacted by
+    name. A sensitive dp consumed by the platform itself, such as a text
+    entity's `value`, is reported as the state instead, so the state is
+    redacted only when it actually carries that dp's value - blanking it
+    unconditionally would discard useful states such as a camera's
+    `recording`, whose sensitive `snapshot` dp is never the state.
+    """
+    names = []
+    values = []
     for entity in device._children:
-        if entity._config.config_id == entity_id:
-            for dp in entity._config.dps():
-                if dp.sensitive:
-                    sensitive.append(dp.name)
-    return {k: (REDACTED if k in sensitive else v) for (k, v) in state_dict.items()}
+        if entity._config.unique_id(device.unique_id) != entity_unique_id:
+            continue
+        for dp in entity._config.dps():
+            if not dp.sensitive:
+                continue
+            names.append(dp.name)
+            value = dp.get_value(device)
+            if value is not None:
+                values.append(str(value))
+
+    if not names:
+        return state_dict
+
+    redacted = dict(state_dict)
+    if isinstance(redacted.get("attributes"), dict):
+        redacted["attributes"] = {
+            k: (REDACTED if k in names else v)
+            for (k, v) in redacted["attributes"].items()
+        }
+    if "state" in redacted and str(redacted["state"]) in values:
+        redacted["state"] = REDACTED
+    return redacted
 
 
 @callback
@@ -142,7 +169,7 @@ def _async_device_as_dict(
             if state:
                 state_dict = redact_entity(
                     device,
-                    entity_entry.entity_id,
+                    entity_entry.unique_id,
                     state.as_dict(),
                 )
 

+ 89 - 0
tests/test_diagnostics.py

@@ -17,6 +17,7 @@ from custom_components.tuya_local.const import (
 from custom_components.tuya_local.diagnostics import (
     async_get_config_entry_diagnostics,
     async_get_device_diagnostics,
+    redact_entity,
 )
 from custom_components.tuya_local.helpers.device_config import TuyaEntityConfig
 
@@ -108,3 +109,91 @@ async def test_diagnostic_redaction(hass):
     assert diag["device_id"] is REDACTED
     assert diag["local_key"] is REDACTED
     assert diag["cached_state"]["2"] is REDACTED
+
+
+# Deliberately self describing. Realistic looking values get picked up by
+# secret scanners, and names containing "secret" or "password" trip ruff's S105.
+EXAMPLE_SENSITIVE_VALUE = "example-not-a-real-value"
+EXAMPLE_SNAPSHOT = "example-not-real-snapshot-data"
+
+
+def _device_with_sensitive_dp(dp_name: str, dp_value: str):
+    """Build a device with one entity whose dp `dp_name` is sensitive."""
+    config = TuyaEntityConfig(
+        Mock(),
+        {
+            "entity": "lock",
+            "dps": [
+                {"id": "33", "type": "boolean", "name": "lock"},
+                {"id": "1", "type": "string", "name": dp_name, "sensitive": True},
+            ],
+        },
+    )
+    m_entity = Mock()
+    m_entity._config = config
+    m_device = Mock()
+    m_device.unique_id = "test_device"
+    m_device._children = [m_entity]
+    m_device.get_property = lambda dp_id: dp_value if dp_id == "1" else None
+    return m_device, config.unique_id("test_device")
+
+
+def test_sensitive_attribute_is_redacted():
+    """A sensitive dp published as an extra attribute must be redacted."""
+    m_device, unique_id = _device_with_sensitive_dp(
+        "unlock_password", EXAMPLE_SENSITIVE_VALUE
+    )
+    state = {
+        "entity_id": "lock.front_door",
+        "state": "locked",
+        "attributes": {
+            "friendly_name": "Front door",
+            "unlock_password": EXAMPLE_SENSITIVE_VALUE,
+        },
+    }
+
+    result = redact_entity(m_device, unique_id, state)
+
+    assert result["attributes"]["unlock_password"] is REDACTED
+    assert result["attributes"]["friendly_name"] == "Front door"
+    assert EXAMPLE_SENSITIVE_VALUE not in str(result)
+    # the entity's own state is not sensitive, so it stays useful
+    assert result["state"] == "locked"
+
+
+def test_sensitive_primary_value_is_redacted():
+    """A sensitive dp consumed by the platform surfaces as state, so redact it."""
+    m_device, unique_id = _device_with_sensitive_dp("value", EXAMPLE_SENSITIVE_VALUE)
+    state = {
+        "entity_id": "text.door_code",
+        "state": EXAMPLE_SENSITIVE_VALUE,
+        "attributes": {"friendly_name": "Door code"},
+    }
+
+    result = redact_entity(m_device, unique_id, state)
+
+    assert result["state"] is REDACTED
+    assert EXAMPLE_SENSITIVE_VALUE not in str(result)
+
+
+def test_state_kept_when_sensitive_dp_is_not_the_state():
+    """A camera's snapshot is sensitive, but its state is not - keep the state."""
+    m_device, unique_id = _device_with_sensitive_dp("snapshot", EXAMPLE_SNAPSHOT)
+    state = {
+        "entity_id": "camera.doorbell",
+        "state": "recording",
+        "attributes": {"friendly_name": "Doorbell"},
+    }
+
+    result = redact_entity(m_device, unique_id, state)
+
+    assert result["state"] == "recording"
+    assert EXAMPLE_SNAPSHOT not in str(result)
+
+
+def test_unrelated_entity_state_is_untouched():
+    """An entity with no sensitive dps is returned unchanged."""
+    m_device, _ = _device_with_sensitive_dp("unlock_password", EXAMPLE_SENSITIVE_VALUE)
+    state = {"entity_id": "lock.other", "state": "locked", "attributes": {}}
+
+    assert redact_entity(m_device, "some-other-unique-id", state) == state