Skip to content

Commit ac4418c

Browse files
committed
Add redaction of data during exceptions, further testing and fixtures
1 parent 7f9a2c2 commit ac4418c

17 files changed

+3427
-23
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.2.6] - 2025-08-06
6+
7+
### Added
8+
9+
- Added redaction of data in exceptions when requesting `status()`
10+
- Additional settings in dataclass (HA Core Issue 150118)
11+
- Added 'likely' mocked fixture for above issue
12+
- Added additional devices (see [Contributing](CONTRIBUTE.md) for more information)
13+
14+
### Changed
15+
16+
- Changed name and kwargs for discovery function
17+
518
## [0.2.5] - 2025-08-05
619

720
### Added

CONTRIBUTE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ It would be very helpful if you would share your configuration data to this proj
77
We currently have data on
88

99
- Nanostation 5AC (LOCO5AC) - PTP - both AP and Station output of `/status.cgi` present (by @CoMPaTech)
10+
- Nanobeam 5AC - PTMP - Station (by @PlayFaster)
11+
- LiteAP GPS - PTMP - AP (by @PlayFaster)
1012

1113
## Secure your data
1214

airos/airos8.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import aiohttp
1111
from mashumaro.exceptions import InvalidFieldValue, MissingField
1212

13-
from .data import AirOS8Data as AirOSData
13+
from .data import AirOS8Data as AirOSData, redact_data_smart
1414
from .exceptions import (
1515
AirOSConnectionAuthenticationError,
1616
AirOSConnectionSetupError,
@@ -268,28 +268,44 @@ async def status(self) -> AirOSData:
268268
if response.status == 200:
269269
try:
270270
response_json = json.loads(response_text)
271-
try:
272-
adjusted_json = self.derived_data(response_json)
273-
airos_data = AirOSData.from_dict(adjusted_json)
274-
except (MissingField, InvalidFieldValue) as err:
275-
_LOGGER.exception("Failed to deserialize AirOS data")
276-
raise AirOSKeyDataMissingError from err
277-
278-
return airos_data
271+
adjusted_json = self.derived_data(response_json)
272+
airos_data = AirOSData.from_dict(adjusted_json)
273+
except InvalidFieldValue as err:
274+
# Log with .error() as this is a specific, known type of issue
275+
redacted_data = redact_data_smart(response_json)
276+
_LOGGER.error(
277+
"Failed to deserialize AirOS data due to an invalid field value: %s",
278+
redacted_data,
279+
)
280+
raise AirOSKeyDataMissingError from err
281+
except MissingField as err:
282+
# Log with .exception() for a full stack trace
283+
redacted_data = redact_data_smart(response_json)
284+
_LOGGER.exception(
285+
"Failed to deserialize AirOS data due to a missing field: %s",
286+
redacted_data,
287+
)
288+
raise AirOSKeyDataMissingError from err
289+
279290
except json.JSONDecodeError:
280291
_LOGGER.exception(
281292
"JSON Decode Error in authenticated status response"
282293
)
283294
raise AirOSDataMissingError from None
295+
296+
return airos_data
284297
else:
285-
log = f"Authenticated status.cgi failed: {response.status}. Response: {response_text}"
286-
_LOGGER.error(log)
287-
raise AirOSDeviceConnectionError from None
298+
_LOGGER.error(
299+
"Status API call failed with status %d: %s",
300+
response.status,
301+
response_text,
302+
)
303+
raise AirOSDeviceConnectionError
288304
except (
289305
aiohttp.ClientError,
290306
aiohttp.client_exceptions.ConnectionTimeoutError,
291307
) as err:
292-
_LOGGER.exception("Error during authenticated status.cgi call")
308+
_LOGGER.error("Status API call failed: %s", err)
293309
raise AirOSDeviceConnectionError from err
294310

295311
async def stakick(self, mac_address: str = None) -> bool:

airos/data.py

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,41 @@
22

33
from dataclasses import dataclass
44
from enum import Enum
5+
import ipaddress
56
import logging
7+
import re
68
from typing import Any
79

810
from mashumaro import DataClassDictMixin
911

1012
logger = logging.getLogger(__name__)
1113

14+
# Regex for a standard MAC address format (e.g., 01:23:45:67:89:AB)
15+
# This handles both colon and hyphen separators.
16+
MAC_ADDRESS_REGEX = re.compile(r"^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$")
17+
18+
# Regex for a MAC address mask (e.g., the redacted format 00:00:00:00:89:AB)
19+
MAC_ADDRESS_MASK_REGEX = re.compile(r"^(00:){4}[0-9a-fA-F]{2}[:-][0-9a-fA-F]{2}$")
20+
21+
22+
def is_mac_address(value: str) -> bool:
23+
"""Check if a string is a valid MAC address."""
24+
return bool(MAC_ADDRESS_REGEX.match(value))
25+
26+
27+
def is_mac_address_mask(value: str) -> bool:
28+
"""Check if a string is a valid MAC address mask (e.g., the redacted format)."""
29+
return bool(MAC_ADDRESS_MASK_REGEX.match(value))
30+
31+
32+
def is_ip_address(value: str) -> bool:
33+
"""Check if a string is a valid IPv4 or IPv6 address."""
34+
try:
35+
ipaddress.ip_address(value)
36+
return True
37+
except ValueError:
38+
return False
39+
1240

1341
def _check_and_log_unknown_enum_value(
1442
data_dict: dict[str, Any],
@@ -31,6 +59,98 @@ def _check_and_log_unknown_enum_value(
3159
del data_dict[key]
3260

3361

62+
def redact_data_smart(data: dict) -> dict:
63+
"""Recursively redacts sensitive keys in a dictionary."""
64+
sensitive_keys = {
65+
"hostname",
66+
"essid",
67+
"mac",
68+
"apmac",
69+
"hwaddr",
70+
"lastip",
71+
"ipaddr",
72+
"ip6addr",
73+
"device_id",
74+
"sys_id",
75+
"station_id",
76+
"platform",
77+
}
78+
79+
def _redact(d: dict):
80+
if not isinstance(d, dict):
81+
return d
82+
83+
redacted_d = {}
84+
for k, v in d.items():
85+
if k in sensitive_keys:
86+
if isinstance(v, str) and (is_mac_address(v) or is_mac_address_mask(v)):
87+
# Redact only the first 6 hex characters of a MAC address
88+
redacted_d[k] = "00:00:00:00:" + v.replace("-", ":").upper()[-5:]
89+
elif isinstance(v, str) and is_ip_address(v):
90+
# Redact to a dummy local IP address
91+
redacted_d[k] = "127.0.0.3"
92+
elif isinstance(v, list) and all(
93+
isinstance(i, str) and is_ip_address(i) for i in v
94+
):
95+
# Redact list of IPs to a dummy list
96+
redacted_d[k] = ["127.0.0.3"]
97+
else:
98+
redacted_d[k] = "REDACTED"
99+
elif isinstance(v, dict):
100+
redacted_d[k] = _redact(v)
101+
elif isinstance(v, list):
102+
redacted_d[k] = [
103+
_redact(item) if isinstance(item, dict) else item for item in v
104+
]
105+
else:
106+
redacted_d[k] = v
107+
return redacted_d
108+
109+
return _redact(data)
110+
111+
112+
def _redact_ip_addresses(addresses: str | list[str]) -> str | list[str]:
113+
"""Redacts the first three octets of an IPv4 address."""
114+
if isinstance(addresses, str):
115+
addresses = [addresses]
116+
117+
redacted_list = []
118+
for ip in addresses:
119+
try:
120+
parts = ip.split(".")
121+
if len(parts) == 4:
122+
# Keep the last octet, but replace the rest with a placeholder.
123+
redacted_list.append(f"127.0.0.{parts[3]}")
124+
else:
125+
# Handle non-standard IPs or IPv6 if it shows up here
126+
redacted_list.append("REDACTED")
127+
except (IndexError, ValueError):
128+
# In case the IP string is malformed
129+
redacted_list.append("REDACTED")
130+
131+
return redacted_list if isinstance(addresses, list) else redacted_list[0]
132+
133+
134+
def _redact_mac_addresses(macs: str | list[str]) -> str | list[str]:
135+
"""Redacts the first four octets of a MAC address."""
136+
if isinstance(macs, str):
137+
macs = [macs]
138+
139+
redacted_list = []
140+
for mac in macs:
141+
try:
142+
parts = mac.split(":")
143+
if len(parts) == 6:
144+
# Keep the last two octets, replace the rest with a placeholder
145+
redacted_list.append(f"00:11:22:33:{parts[4]}:{parts[5]}")
146+
else:
147+
redacted_list.append("REDACTED")
148+
except (IndexError, ValueError):
149+
redacted_list.append("REDACTED")
150+
151+
return redacted_list if isinstance(macs, list) else redacted_list[0]
152+
153+
34154
class IeeeMode(Enum):
35155
"""Enum definition."""
36156

@@ -259,16 +379,16 @@ class Remote:
259379
rx_bytes: int
260380
antenna_gain: int
261381
cable_loss: int
262-
height: int
263382
ethlist: list[EthList]
264383
ipaddr: list[str]
265-
ip6addr: list[str]
266384
gps: GPSData
267385
oob: bool
268386
unms: UnmsStatus
269387
airview: int
270388
service: ServiceTime
271389
mode: WirelessMode | None = None # Investigate why remotes can have no mode set
390+
ip6addr: list[str] | None = None # For v4 only devices
391+
height: int | None = None
272392

273393
@classmethod
274394
def __pre_deserialize__(cls, d: dict[str, Any]) -> dict[str, Any]:

0 commit comments

Comments
 (0)