diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 12d81146c03a..8c167979cf5e 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -2,17 +2,69 @@ from __future__ import annotations -from homeassistant.components.hassio import AddonManager +from typing import Any + +from homeassistant.components.hassio import AddonError, AddonManager from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.redact import async_redact_data from homeassistant.helpers.singleton import singleton -from .const import ADDON_SLUG, DOMAIN, LOGGER +from .const import ( + ADDON_SLUG, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, + CONF_ADDON_NETWORK_KEY, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, + DOMAIN, + LOGGER, +) DATA_ADDON_MANAGER = f"{DOMAIN}_addon_manager" +REDACT_ADDON_OPTION_KEYS = { + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, + CONF_ADDON_NETWORK_KEY, +} + + +def _redact_sensitive_option_values(message: str, config: dict[str, Any]) -> str: + """Redact sensitive add-on option values in an error string.""" + redacted_config = async_redact_data(config, REDACT_ADDON_OPTION_KEYS) + + for key in REDACT_ADDON_OPTION_KEYS: + option_value = config.get(key) + if not isinstance(option_value, str) or not option_value: + continue + redacted_value = redacted_config.get(key) + if not isinstance(redacted_value, str): + continue + message = message.replace(option_value, redacted_value) + + return message + + +class ZwaveAddonManager(AddonManager): + """Addon manager for Z-Wave JS with redacted option errors.""" + + async def async_set_addon_options(self, config: dict[str, Any]) -> None: + """Set add-on options.""" + try: + await super().async_set_addon_options(config) + except AddonError as err: + raise AddonError( + _redact_sensitive_option_values(str(err), config) + ) from None @singleton(DATA_ADDON_MANAGER) @callback def get_addon_manager(hass: HomeAssistant) -> AddonManager: """Get the add-on manager.""" - return AddonManager(hass, LOGGER, "Z-Wave JS", ADDON_SLUG) + return ZwaveAddonManager(hass, LOGGER, "Z-Wave JS", ADDON_SLUG) diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 6f08e89830b1..21da6671f915 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -46,6 +46,7 @@ HUMIDIFIER_ADC_T3000_ENTITY = "humidifier.adc_t3000_humidifier" DEHUMIDIFIER_ADC_T3000_ENTITY = "humidifier.adc_t3000_dehumidifier" PROPERTY_ULTRAVIOLET = "Ultraviolet" +TEST_SENSITIVE_NETWORK_KEY = "00112233445566778899AABBCCDDEEFF" def replace_value_of_zwave_value( diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 4517d22d661c..5a26b3509ab9 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -38,11 +38,14 @@ from homeassistant.components.zwave_js.helpers import SERVER_VERSION_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.redact import REDACTED from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .common import TEST_SENSITIVE_NETWORK_KEY + from tests.common import MockConfigEntry, async_capture_events ADDON_DISCOVERY_INFO = { @@ -2542,13 +2545,23 @@ async def test_addon_installed_failures( @pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") -@pytest.mark.parametrize("set_addon_options_side_effect", [SupervisorError()]) +@pytest.mark.parametrize( + "set_addon_options_side_effect", + [ + SupervisorError( + "not a valid value for dictionary value @ data['options']. " + f"Got {{'s0_legacy_key': '{TEST_SENSITIVE_NETWORK_KEY}'}}" + ) + ], +) async def test_addon_installed_set_options_failure( hass: HomeAssistant, set_addon_options: AsyncMock, start_addon: AsyncMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test all failures when add-on is installed.""" + secret = TEST_SENSITIVE_NETWORK_KEY result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -2594,7 +2607,7 @@ async def test_addon_installed_set_options_failure( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "s0_legacy_key": "new123", + "s0_legacy_key": secret, "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", @@ -2608,7 +2621,7 @@ async def test_addon_installed_set_options_failure( AddonsOptions( config={ "device": "/test", - "s0_legacy_key": "new123", + "s0_legacy_key": secret, "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", @@ -2622,6 +2635,10 @@ async def test_addon_installed_set_options_failure( assert result["reason"] == "addon_set_config_failed" assert start_addon.call_count == 0 + assert "Failed to set the Z-Wave JS app options" in caplog.text + assert "not a valid value for dictionary value" in caplog.text + assert REDACTED in caplog.text + assert secret not in caplog.text @pytest.mark.usefixtures("supervisor", "addon_installed") diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 035d657bbeb3..c8966a412a41 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -34,12 +34,14 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) +from homeassistant.helpers.redact import REDACTED from homeassistant.setup import async_setup_component from .common import ( AIR_TEMPERATURE_SENSOR, BULB_6_MULTI_COLOR_LIGHT_ENTITY, EATON_RF9640_ENTITY, + TEST_SENSITIVE_NETWORK_KEY, ) from tests.common import ( @@ -933,6 +935,46 @@ async def test_start_addon( assert start_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_installed", "addon_info") +@pytest.mark.parametrize( + "set_addon_options_side_effect", + [ + SupervisorError( + "not a valid value for dictionary value @ data['options']. " + f"Got {{'s0_legacy_key': '{TEST_SENSITIVE_NETWORK_KEY}'}}" + ) + ], +) +async def test_start_addon_redacts_set_options_error( + hass: HomeAssistant, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test startup redacts add-on options backend error details.""" + device = "/test" + secret = TEST_SENSITIVE_NETWORK_KEY + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + data={"use_addon": True, "usb_path": device, "network_key": secret}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert install_addon.call_count == 0 + assert set_addon_options.call_count == 1 + assert start_addon.call_count == 0 + assert "Failed to set the Z-Wave JS app options" in caplog.text + assert "not a valid value for dictionary value" in caplog.text + assert REDACTED in caplog.text + assert secret not in caplog.text + + @pytest.mark.usefixtures("addon_info") async def test_install_addon( hass: HomeAssistant,