Skip to content

Commit c6a79f0

Browse files
committed
feat: improve diagnostics anonymization with per-endpoint redact fields and bump to 0.7.0
Add per-endpoint redact field sets so profile-specific PII (name, birthDate, address, city, postalCode, phone, maintainerCode) is redacted without affecting device/room name fields. Also truncate device_id, masterMac, and mac fields in diagnostics output.
1 parent 745bfe3 commit c6a79f0

4 files changed

Lines changed: 51 additions & 22 deletions

File tree

custom_components/mylight_systems/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
NAME = "MyLight Systems"
1111
DOMAIN = "mylight_systems"
1212
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
13-
VERSION = "0.6.0"
13+
VERSION = "0.7.0"
1414
ATTRIBUTION = "Data provided by https://www.mylight-systems.com/"
1515
DEFAULT_SCAN_INTERVAL_IN_MINUTES = 15
1616
MIN_SCAN_INTERVAL_IN_MINUTES = 5

custom_components/mylight_systems/diagnostics.py

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,43 @@
3535

3636
TO_REDACT = {CONF_EMAIL, CONF_PASSWORD, CONF_SUBSCRIPTION_ID, CONF_MASTER_ID, CONF_MASTER_RELAY_ID}
3737

38+
# --- Global anonymization rules (applied to all endpoints) ---
39+
3840
# Fields to fully replace with "***"
3941
_REDACT_FIELDS = {"email", "firstName", "lastName", "tenant"}
4042

4143
# Fields to truncate to first 4 chars + "***"
42-
_TRUNCATE_FIELDS = {"id", "deviceId", "sensorId", "serialNumber"}
44+
_TRUNCATE_FIELDS = {"id", "deviceId", "device_id", "sensorId", "serialNumber", "masterMac", "mac"}
4345

4446
# Fields to zero out
4547
_ZERO_FIELDS = {"latitude", "longitude"}
4648

4749
# Fields to remove entirely
4850
_REMOVE_FIELDS = {"authToken"}
4951

52+
# --- Per-endpoint extra redact fields ---
53+
54+
_PROFILE_REDACT_FIELDS = {
55+
"name",
56+
"birthDate",
57+
"postalCode",
58+
"city",
59+
"address",
60+
"additionalAddress",
61+
"phoneNumber",
62+
"mobileNumber",
63+
"maintainerCode",
64+
}
65+
66+
_DEVICES_REDACT_FIELDS: set[str] = set()
67+
_MEASURES_REDACT_FIELDS: set[str] = set()
68+
_STATES_REDACT_FIELDS: set[str] = set()
69+
_ROOMS_REDACT_FIELDS: set[str] = set()
70+
5071

51-
def _anonymize_value(key: str, value: Any) -> Any:
72+
def _anonymize_value(key: str, value: Any, extra_redact: set[str]) -> Any:
5273
"""Anonymize a single value based on its key."""
53-
if key in _REDACT_FIELDS:
74+
if key in _REDACT_FIELDS or key in extra_redact:
5475
return "***"
5576
if key in _ZERO_FIELDS:
5677
return 0.0
@@ -59,34 +80,36 @@ def _anonymize_value(key: str, value: Any) -> Any:
5980
return value
6081

6182

62-
def _anonymize_response(data: Any) -> Any:
83+
def _anonymize_response(data: Any, extra_redact: set[str] | None = None) -> Any:
6384
"""Recursively anonymize sensitive fields in an API response."""
85+
redact = extra_redact or set()
6486
if isinstance(data, dict):
6587
result = {}
6688
for key, value in data.items():
6789
if key in _REMOVE_FIELDS:
6890
continue
69-
result[key] = _anonymize_value(key, _anonymize_response(value))
91+
result[key] = _anonymize_value(key, _anonymize_response(value, redact), redact)
7092
return result
7193
if isinstance(data, list):
72-
return [_anonymize_response(item) for item in data]
94+
return [_anonymize_response(item, redact) for item in data]
7395
return data
7496

7597

76-
# Each entry: (endpoint_path, param_builder)
98+
# Each entry: (endpoint_path, param_builder, extra_redact_fields)
7799
# The callable receives (auth_token, entry_data, today, tomorrow) and returns params.
78-
DiagnosticEndpoint = tuple[str, Callable[[str, dict, str, str], dict]]
100+
DiagnosticEndpoint = tuple[str, Callable[[str, dict, str, str], dict], set[str]]
79101

80102
DIAGNOSTIC_ENDPOINTS: list[DiagnosticEndpoint] = [
81-
(PROFILE_URL, lambda tok, data, t, tm: {"authToken": tok}),
82-
(DEVICES_URL, lambda tok, data, t, tm: {"authToken": tok}),
103+
(PROFILE_URL, lambda tok, data, t, tm: {"authToken": tok}, _PROFILE_REDACT_FIELDS),
104+
(DEVICES_URL, lambda tok, data, t, tm: {"authToken": tok}, _DEVICES_REDACT_FIELDS),
83105
(
84106
MEASURES_TOTAL_URL,
85107
lambda tok, data, t, tm: {
86108
"authToken": tok,
87109
"measureType": data[CONF_GRID_TYPE],
88110
"deviceId": data[CONF_VIRTUAL_DEVICE_ID],
89111
},
112+
_MEASURES_REDACT_FIELDS,
90113
),
91114
(
92115
MEASURES_GROUPING_URL,
@@ -98,9 +121,10 @@ def _anonymize_response(data: Any) -> Any:
98121
"measureType": data[CONF_GRID_TYPE],
99122
"deviceId": data[CONF_VIRTUAL_DEVICE_ID],
100123
},
124+
_MEASURES_REDACT_FIELDS,
101125
),
102-
(STATES_URL, lambda tok, data, t, tm: {"authToken": tok}),
103-
(ROOMS_URL, lambda tok, data, t, tm: {"authToken": tok}),
126+
(STATES_URL, lambda tok, data, t, tm: {"authToken": tok}, _STATES_REDACT_FIELDS),
127+
(ROOMS_URL, lambda tok, data, t, tm: {"authToken": tok}, _ROOMS_REDACT_FIELDS),
104128
]
105129

106130

@@ -128,18 +152,23 @@ async def async_get_config_entry_diagnostics(
128152
entry_data = dict(entry.data)
129153

130154
tasks = {
131-
path: coordinator.client.async_raw_request(
132-
"get", path, params=param_builder(auth_token, entry_data, today, tomorrow)
155+
path: (
156+
coordinator.client.async_raw_request(
157+
"get", path, params=param_builder(auth_token, entry_data, today, tomorrow)
158+
),
159+
extra_redact,
133160
)
134-
for path, param_builder in DIAGNOSTIC_ENDPOINTS
161+
for path, param_builder, extra_redact in DIAGNOSTIC_ENDPOINTS
135162
}
136-
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
163+
results = await asyncio.gather(
164+
*[coro for coro, _ in tasks.values()], return_exceptions=True
165+
)
137166

138-
for path, result in zip(tasks.keys(), results):
167+
for (path, (_, extra_redact)), result in zip(tasks.items(), results):
139168
if isinstance(result, Exception):
140169
payload = {"error": type(result).__name__, "message": str(result)}
141170
else:
142-
payload = _anonymize_response(result)
171+
payload = _anonymize_response(result, extra_redact)
143172
raw_api_responses[path] = base64.b64encode(
144173
json.dumps(payload, default=str).encode()
145174
).decode()

custom_components/mylight_systems/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"integration_type": "hub",
1010
"iot_class": "cloud_polling",
1111
"issue_tracker": "https://github.com/acesyde/hassio_mylight_integration/issues",
12-
"version": "0.6.0"
12+
"version": "0.7.0"
1313
}

tests/test_diagnostics.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ async def test_diagnostics_returns_all_endpoint_keys():
167167
):
168168
result = await async_get_config_entry_diagnostics(hass, entry)
169169

170-
expected_keys = {path for path, _ in DIAGNOSTIC_ENDPOINTS}
170+
expected_keys = {path for path, *_ in DIAGNOSTIC_ENDPOINTS}
171171
assert set(result["raw_api_responses"].keys()) == expected_keys
172172

173173

@@ -260,7 +260,7 @@ async def _mock_raw_request(method, path, params=None):
260260
result = await async_get_config_entry_diagnostics(hass, entry)
261261

262262
# All endpoints should still be present
263-
expected_keys = {path for path, _ in DIAGNOSTIC_ENDPOINTS}
263+
expected_keys = {path for path, *_ in DIAGNOSTIC_ENDPOINTS}
264264
assert set(result["raw_api_responses"].keys()) == expected_keys
265265

266266
# The failed endpoint should have an error payload

0 commit comments

Comments
 (0)