Redact user codes from zwave_js diagnostics (#68515)

* Redact user codes from zwave_js diagnostics

* simplify test

* Remove unused logic

* revert change and make all inputs to ZwaveValueID optional

* revert change and make all inputs to ZwaveValueID optional

* Remove unused diagnostics data from fixture and test location redaction

* Add empty ZwaveValueID check

* Improve coverage

* Simplify post_init check

* Use dataclasses.astuple for checks instead
This commit is contained in:
Raman Gupta 2022-03-23 16:13:27 -04:00 committed by GitHub
parent ccd8c7d5f8
commit 8293430e25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 2064 additions and 44 deletions

View file

@ -1,12 +1,16 @@
"""Provides diagnostics for Z-Wave JS.""" """Provides diagnostics for Z-Wave JS."""
from __future__ import annotations from __future__ import annotations
from dataclasses import astuple
from typing import Any from typing import Any
from zwave_js_server.client import Client from zwave_js_server.client import Client
from zwave_js_server.const import CommandClass
from zwave_js_server.dump import dump_msgs from zwave_js_server.dump import dump_msgs
from zwave_js_server.model.node import Node, NodeDataType from zwave_js_server.model.node import Node, NodeDataType
from zwave_js_server.model.value import ValueDataType
from homeassistant.components.diagnostics.const import REDACTED
from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.components.diagnostics.util import async_redact_data
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL from homeassistant.const import CONF_URL
@ -17,9 +21,43 @@ from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_registry import async_entries_for_device, async_get from homeassistant.helpers.entity_registry import async_entries_for_device, async_get
from .const import DATA_CLIENT, DOMAIN from .const import DATA_CLIENT, DOMAIN
from .helpers import get_home_and_node_id_from_device_entry from .helpers import ZwaveValueID, get_home_and_node_id_from_device_entry
TO_REDACT = {"homeId", "location"} KEYS_TO_REDACT = {"homeId", "location"}
VALUES_TO_REDACT = (
ZwaveValueID(property_="userCode", command_class=CommandClass.USER_CODE),
)
def redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType:
"""Redact value of a Z-Wave value."""
for value_to_redact in VALUES_TO_REDACT:
zwave_value_id = ZwaveValueID(
property_=zwave_value["property"],
command_class=CommandClass(zwave_value["commandClass"]),
endpoint=zwave_value["endpoint"],
property_key=zwave_value.get("propertyKey"),
)
if all(
redacted_field_val is None or redacted_field_val == zwave_value_field_val
for redacted_field_val, zwave_value_field_val in zip(
astuple(value_to_redact), astuple(zwave_value_id)
)
):
return {**zwave_value, "value": REDACTED}
return zwave_value
def redact_node_state(node_state: NodeDataType) -> NodeDataType:
"""Redact node state."""
return {
**node_state,
"values": [
redact_value_of_zwave_value(zwave_value)
for zwave_value in node_state["values"]
],
}
def get_device_entities( def get_device_entities(
@ -79,10 +117,16 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry hass: HomeAssistant, config_entry: ConfigEntry
) -> list[dict]: ) -> list[dict]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
msgs: list[dict] = await dump_msgs( msgs: list[dict] = async_redact_data(
config_entry.data[CONF_URL], async_get_clientsession(hass) await dump_msgs(config_entry.data[CONF_URL], async_get_clientsession(hass)),
KEYS_TO_REDACT,
) )
return async_redact_data(msgs, TO_REDACT) handshake_msgs = msgs[:-1]
network_state = msgs[-1]
network_state["result"]["state"]["nodes"] = [
redact_node_state(node) for node in network_state["result"]["state"]["nodes"]
]
return [*handshake_msgs, network_state]
async def async_get_device_diagnostics( async def async_get_device_diagnostics(
@ -104,5 +148,5 @@ async def async_get_device_diagnostics(
"maxSchemaVersion": client.version.max_schema_version, "maxSchemaVersion": client.version.max_schema_version,
}, },
"entities": entities, "entities": entities,
"state": async_redact_data(node.data, TO_REDACT), "state": redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)),
} }

View file

@ -55,8 +55,8 @@ from .discovery_data_template import (
FanValueMapping, FanValueMapping,
FixedFanValueMappingDataTemplate, FixedFanValueMappingDataTemplate,
NumericSensorDataTemplate, NumericSensorDataTemplate,
ZwaveValueID,
) )
from .helpers import ZwaveValueID
class DataclassMustHaveAtLeastOne: class DataclassMustHaveAtLeastOne:
@ -307,7 +307,7 @@ DISCOVERY_SCHEMAS = [
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
data_template=ConfigurableFanValueMappingDataTemplate( data_template=ConfigurableFanValueMappingDataTemplate(
configuration_option=ZwaveValueID( configuration_option=ZwaveValueID(
5, CommandClass.CONFIGURATION, endpoint=0 property_=5, command_class=CommandClass.CONFIGURATION, endpoint=0
), ),
configuration_value_to_fan_value_mapping={ configuration_value_to_fan_value_mapping={
0: FanValueMapping(speeds=[(1, 33), (34, 66), (67, 99)]), 0: FanValueMapping(speeds=[(1, 33), (34, 66), (67, 99)]),
@ -325,8 +325,8 @@ DISCOVERY_SCHEMAS = [
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
data_template=CoverTiltDataTemplate( data_template=CoverTiltDataTemplate(
tilt_value_id=ZwaveValueID( tilt_value_id=ZwaveValueID(
"fibaro", property_="fibaro",
CommandClass.MANUFACTURER_PROPRIETARY, command_class=CommandClass.MANUFACTURER_PROPRIETARY,
endpoint=0, endpoint=0,
property_key="venetianBlindsTilt", property_key="venetianBlindsTilt",
) )
@ -391,34 +391,36 @@ DISCOVERY_SCHEMAS = [
lookup_table={ lookup_table={
# Internal Sensor # Internal Sensor
"A": ZwaveValueID( "A": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY, property_=THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL, command_class=CommandClass.SENSOR_MULTILEVEL,
endpoint=2, endpoint=2,
), ),
"AF": ZwaveValueID( "AF": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY, property_=THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL, command_class=CommandClass.SENSOR_MULTILEVEL,
endpoint=2, endpoint=2,
), ),
# External Sensor # External Sensor
"A2": ZwaveValueID( "A2": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY, property_=THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL, command_class=CommandClass.SENSOR_MULTILEVEL,
endpoint=3, endpoint=3,
), ),
"A2F": ZwaveValueID( "A2F": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY, property_=THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL, command_class=CommandClass.SENSOR_MULTILEVEL,
endpoint=3, endpoint=3,
), ),
# Floor sensor # Floor sensor
"F": ZwaveValueID( "F": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY, property_=THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL, command_class=CommandClass.SENSOR_MULTILEVEL,
endpoint=4, endpoint=4,
), ),
}, },
dependent_value=ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), dependent_value=ZwaveValueID(
property_=2, command_class=CommandClass.CONFIGURATION, endpoint=0
),
), ),
), ),
# Heatit Z-TRM2fx # Heatit Z-TRM2fx
@ -438,23 +440,25 @@ DISCOVERY_SCHEMAS = [
lookup_table={ lookup_table={
# External Sensor # External Sensor
"A2": ZwaveValueID( "A2": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY, property_=THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL, command_class=CommandClass.SENSOR_MULTILEVEL,
endpoint=2, endpoint=2,
), ),
"A2F": ZwaveValueID( "A2F": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY, property_=THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL, command_class=CommandClass.SENSOR_MULTILEVEL,
endpoint=2, endpoint=2,
), ),
# Floor sensor # Floor sensor
"F": ZwaveValueID( "F": ZwaveValueID(
THERMOSTAT_CURRENT_TEMP_PROPERTY, property_=THERMOSTAT_CURRENT_TEMP_PROPERTY,
CommandClass.SENSOR_MULTILEVEL, command_class=CommandClass.SENSOR_MULTILEVEL,
endpoint=3, endpoint=3,
), ),
}, },
dependent_value=ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), dependent_value=ZwaveValueID(
property_=2, command_class=CommandClass.CONFIGURATION, endpoint=0
),
), ),
), ),
# FortrezZ SSA1/SSA2/SSA3 # FortrezZ SSA1/SSA2/SSA3

View file

@ -148,6 +148,7 @@ from .const import (
ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_TOTAL_INCREASING,
ENTITY_DESC_KEY_VOLTAGE, ENTITY_DESC_KEY_VOLTAGE,
) )
from .helpers import ZwaveValueID
METER_DEVICE_CLASS_MAP: dict[str, set[MeterScaleType]] = { METER_DEVICE_CLASS_MAP: dict[str, set[MeterScaleType]] = {
ENTITY_DESC_KEY_CURRENT: CURRENT_METER_TYPES, ENTITY_DESC_KEY_CURRENT: CURRENT_METER_TYPES,
@ -226,16 +227,6 @@ MULTILEVEL_SENSOR_UNIT_MAP: dict[str, set[MultilevelSensorScaleType]] = {
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@dataclass
class ZwaveValueID:
"""Class to represent a value ID."""
property_: str | int
command_class: int
endpoint: int | None = None
property_key: str | int | None = None
@dataclass @dataclass
class BaseDiscoverySchemaDataTemplate: class BaseDiscoverySchemaDataTemplate:
"""Base class for discovery schema data templates.""" """Base class for discovery schema data templates."""
@ -486,7 +477,7 @@ class ConfigurableFanValueMappingDataTemplate(
... ...
data_template=ConfigurableFanValueMappingDataTemplate( data_template=ConfigurableFanValueMappingDataTemplate(
configuration_option=ZwaveValueID( configuration_option=ZwaveValueID(
5, CommandClass.CONFIGURATION, endpoint=0 property_=5, command_class=CommandClass.CONFIGURATION, endpoint=0
), ),
configuration_value_to_fan_value_mapping={ configuration_value_to_fan_value_mapping={
0: FanValueMapping(speeds=[(1,33), (34,66), (67,99)]), 0: FanValueMapping(speeds=[(1,33), (34,66), (67,99)]),

View file

@ -223,7 +223,7 @@ class ZWaveBaseEntity(Entity):
value_property: str | int, value_property: str | int,
command_class: int | None = None, command_class: int | None = None,
endpoint: int | None = None, endpoint: int | None = None,
value_property_key: int | None = None, value_property_key: int | str | None = None,
add_to_watched_value_ids: bool = True, add_to_watched_value_ids: bool = True,
check_all_endpoints: bool = False, check_all_endpoints: bool = False,
) -> ZwaveValue | None: ) -> ZwaveValue | None:

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import astuple, dataclass
from typing import Any, cast from typing import Any, cast
import voluptuous as vol import voluptuous as vol
@ -41,6 +42,21 @@ from .const import (
) )
@dataclass
class ZwaveValueID:
"""Class to represent a value ID."""
property_: str | int | None = None
command_class: int | None = None
endpoint: int | None = None
property_key: str | int | None = None
def __post_init__(self) -> None:
"""Post initialization check."""
if all(val is None for val in astuple(self)):
raise ValueError("At least one of the fields must be set.")
@callback @callback
def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None:
"""Return the value of a ZwaveValue.""" """Return the value of a ZwaveValue."""

View file

@ -210,6 +210,12 @@ def log_config_state_fixture():
} }
@pytest.fixture(name="config_entry_diagnostics", scope="session")
def config_entry_diagnostics_fixture():
"""Load the config entry diagnostics fixture data."""
return json.loads(load_fixture("zwave_js/config_entry_diagnostics.json"))
@pytest.fixture(name="multisensor_6_state", scope="session") @pytest.fixture(name="multisensor_6_state", scope="session")
def multisensor_6_state_fixture(): def multisensor_6_state_fixture():
"""Load the multisensor 6 node state fixture data.""" """Load the multisensor 6 node state fixture data."""

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ from unittest.mock import patch
import pytest import pytest
from zwave_js_server.event import Event from zwave_js_server.event import Event
from homeassistant.components.diagnostics.const import REDACTED
from homeassistant.components.zwave_js.diagnostics import async_get_device_diagnostics from homeassistant.components.zwave_js.diagnostics import async_get_device_diagnostics
from homeassistant.components.zwave_js.discovery import async_discover_node_values from homeassistant.components.zwave_js.discovery import async_discover_node_values
from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.components.zwave_js.helpers import get_device_id
@ -17,15 +18,27 @@ from tests.components.diagnostics import (
) )
async def test_config_entry_diagnostics(hass, hass_client, integration): async def test_config_entry_diagnostics(
hass, hass_client, integration, config_entry_diagnostics
):
"""Test the config entry level diagnostics data dump.""" """Test the config entry level diagnostics data dump."""
with patch( with patch(
"homeassistant.components.zwave_js.diagnostics.dump_msgs", "homeassistant.components.zwave_js.diagnostics.dump_msgs",
return_value=[{"hello": "world"}, {"second": "msg"}], return_value=config_entry_diagnostics,
): ):
assert await get_diagnostics_for_config_entry( diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, integration hass, hass_client, integration
) == [{"hello": "world"}, {"second": "msg"}] )
assert len(diagnostics) == 3
assert diagnostics[0]["homeId"] == REDACTED
nodes = diagnostics[2]["result"]["state"]["nodes"]
for node in nodes:
assert "location" not in node or node["location"] == REDACTED
for value in node["values"]:
if value["commandClass"] == 99 and value["property"] == "userCode":
assert value["value"] == REDACTED
else:
assert value.get("value") != REDACTED
async def test_device_diagnostics( async def test_device_diagnostics(

View file

@ -0,0 +1,10 @@
"""Test Z-Wave JS helpers module."""
import pytest
from homeassistant.components.zwave_js.helpers import ZwaveValueID
async def test_empty_zwave_value_id():
"""Test empty ZwaveValueID is invalid."""
with pytest.raises(ValueError):
ZwaveValueID()