Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions apps/predbat/sigenergy.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@
SIGENERGY_MQTT_TOPIC_COMMAND = "openapi/instruction/command" # battery command publish
SIGENERGY_MQTT_TOPIC_MODE = "openapi/instruction/mode" # V1 operating mode switch (MQTT)

# Payload keys masked before a payload is written to the log. The MQTT command payloads
# carry the live accessToken (it doubles as the MQTT broker password), and Predbat logs are
# routinely pasted into GitHub issues, so anything credential-bearing has to be masked first.
SIGENERGY_LOG_REDACT_KEYS = ("accessToken", "refreshToken", "appKey", "appSecret", "password", "token", "key")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same bug class on the inbound path — the topic embeds app_key and the listener logs it raw.

The listener logs the full topic_str on every received message (sigenergy.py:1521) and in the non-JSON warning (sigenergy.py:1507), and those topics embed app_key (SIGENERGY_MQTT_TOPIC_CHANGE/PERIOD/ALARM, sigenergy.py:148-150) — a key this very list classifies as credential-bearing (it is the MQTT broker username and half of the base64 login key). So a user pasting a debug log still leaks it on every inbound line; the fix covers only the outbound publish line.

Worth either redact()-ing the topic (or the format args) in those two listener lines as part of this PR, or an explicit follow-up. The CLI tools (test_sigenergy_api, test_mqtt_connection) deliberately print only a 10-char app_key prefix, but the listener lines print the full topic.

Separately: the key/token catch-alls here will mask benign diagnostic fields in what is often the only log line for debugging a broker-side rejection (an alarm/instruction entry carrying a literal key id renders as <redacted>), while the match is case-exact, so a snake_case access_token variant would slip through. Sibling lists cover both directions (Deye adds tokenHash; Sunsynk covers snake_case plus Authorization/sign). Worth a conscious decision on both edges.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated reply from the triage bot (pr-cleanup).

Fixed in 0b92ad0: both listener lines now log a safe_topic with app_key masked (topic_str.replace(self.app_key, "<redacted>"), computed once after topic parsing and used for the non-JSON warning and the message line). I redacted only the topic, not the value_dict format arg, on purpose: inbound value payloads are device telemetry, and running them through redact() would amplify the over-masking problem flagged below onto every inbound state log line for no credential risk.

On the two edges of the key list — conscious decision, unchanged: the catch-alls stay, because the failure direction is asymmetric (a benign field rendering as <redacted> costs one diagnostic detail; a missed credential leaks it permanently), and case-exact matching stays to mirror the sibling integrations (deye/sunsynk are also exact-match). Verified the Sigenergy API is camelCase throughout (accessToken/refreshToken in the login/refresh responses, camelCase in the value dicts), so the snake_case variant has no live path here. Worth revisiting only if the shared-helper refactor in the third thread ever lands.


# Operating mode enums (REST mode switch endpoint — MSC and FFG only; NBI is not used)
SIGENERGY_MODE_MSC = 0 # Maximum Self-Consumption (eco)
SIGENERGY_MODE_FFG = 5 # Fully Feed-in to Grid
Expand Down Expand Up @@ -466,7 +471,7 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE
"Content-Type": "application/json",
}

self.log("Requesting {} {} with params={} json={}".format(method, path, params, json_data))
self.log("Requesting {} {} with params={} json={}".format(method, path, self.redact(params), self.redact(json_data)))
Comment thread
springfall2008 marked this conversation as resolved.

for attempt in range(retries):
await self._enforce_rate_limit()
Expand Down Expand Up @@ -508,7 +513,7 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE
self.log("Warn: SigenergyAPI: Failed to decode response from {}: {}".format(path, e))
return None

self.log("SigenergyAPI: Response from {} {}: {}".format(method, path, body))
self.log("SigenergyAPI: Response from {} {}: {}".format(method, path, self.redact(body)))

code = body.get("code", -1)
if code != 0:
Expand Down Expand Up @@ -1020,6 +1025,23 @@ def _build_tls_context(self):
self._tls_context = tls_context
return tls_context

@staticmethod
def redact(payload):
"""Return payload with credential-bearing keys masked, for safe logging.

Recursive over dicts and common sequences. DeyeAPI/SunsynkAPI recurse over dicts + lists;
Sigenergy MQTT command payloads nest per-system commands one level down inside a list,
so a top-level-only rewrite would still leak anything a future payload carries there.
Tuples are included because json.dumps() serialises tuples as JSON arrays.
Sets/frozen sets are handled for log safety, even though json.dumps() does not
serialise them by default.
"""
if isinstance(payload, dict):
return {key: ("<redacted>" if key in SIGENERGY_LOG_REDACT_KEYS else SigenergyAPI.redact(value)) for key, value in payload.items()}
if isinstance(payload, (list, tuple, set, frozenset)):
return [SigenergyAPI.redact(value) for value in payload]
return payload
Comment thread
springfall2008 marked this conversation as resolved.

async def _publish_mqtt(self, topic, payload_dict):
"""Publish a JSON payload to the Sigenergy MQTT broker.

Expand Down Expand Up @@ -1047,7 +1069,7 @@ async def _publish_mqtt(self, topic, payload_dict):
keepalive=30,
) as client:
await client.publish(topic, payload=json.dumps(payload_dict), qos=1)
self.log("SigenergyAPI: MQTT published to {} - {}".format(topic, payload_dict))
self.log("SigenergyAPI: MQTT published to {} - {}".format(topic, self.redact(payload_dict)))
Comment thread
springfall2008 marked this conversation as resolved.
return True
except Exception as e:
self.log("Warn: SigenergyAPI: MQTT publish to {} failed: {}".format(topic, e))
Expand Down Expand Up @@ -1474,6 +1496,9 @@ async def _mqtt_listener_loop(self):
# Parse topic: openapi/{type}/{app_key}/{system_id}
topic_str = str(message.topic)
parts = topic_str.split("/")
# The topic embeds app_key (the MQTT broker username), so mask it before
# any log line prints the topic — same leak class as the publish payload.
safe_topic = topic_str.replace(self.app_key, "<redacted>") if self.app_key else topic_str
# Expected: ['openapi', type, app_key, system_id]
if len(parts) < 4:
continue
Expand All @@ -1485,7 +1510,7 @@ async def _mqtt_listener_loop(self):
try:
payload = json.loads(raw.decode("utf-8", errors="replace"))
except (json.JSONDecodeError, ValueError):
self.log("Warn: SigenergyAPI: MQTT non-JSON payload on {}: {}".format(topic_str, raw[:120]))
self.log("Warn: SigenergyAPI: MQTT non-JSON payload on {}: {}".format(safe_topic, raw[:120]))
continue

# Each message is a list of device-level entries; process each
Expand All @@ -1499,7 +1524,7 @@ async def _mqtt_listener_loop(self):
continue
self.last_mqtt_update[entry_sid] = time.time()
value_dict = entry.get("value", {})
self.log("SigenergyAPI: MQTT message on {} for system {}: type={} value={}".format(topic_str, entry_sid, msg_type, value_dict))
self.log("SigenergyAPI: MQTT message on {} for system {}: type={} value={}".format(safe_topic, entry_sid, msg_type, value_dict))
if msg_type == "period":
self._handle_mqtt_period(entry_sid, value_dict)
if self.api_started:
Expand Down
111 changes: 111 additions & 0 deletions apps/predbat/tests/test_sigenergy.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
SIGENERGY_ACTIVE_MODE_SELF,
SIGENERGY_CODE_IN_OTHER_VPP,
SIGENERGY_CODE_SYSTEM_PENDING_REVIEW,
SIGENERGY_LOG_REDACT_KEYS,
SIGENERGY_MODE_MSC,
SIGENERGY_MODE_NBI,
SIGENERGY_MODE_VPP,
Expand Down Expand Up @@ -1343,6 +1344,113 @@ def test_sigenergy_publish_mqtt_success(my_predbat):
return failed


def test_sigenergy_redact(my_predbat):
"""Test redact masks credential keys at any depth and leaves the rest alone."""
failed = False

redacted = SigenergyAPI.redact({"accessToken": "live-token", "systemId": "SIG1"})
assert redacted["accessToken"] == "<redacted>", "accessToken masked"
assert redacted["systemId"] == "SIG1", "Non-credential key untouched"

# Nested inside a list, as the battery command payload nests its commands
nested = SigenergyAPI.redact({"commands": [{"systemId": "SIG1", "password": "hunter2"}]})
assert nested["commands"][0]["password"] == "<redacted>", "Credential masked inside a nested list"
assert nested["commands"][0]["systemId"] == "SIG1", "Nested non-credential key untouched"

# Nested inside a dict, exercising the dict-value recursion branch
nested_dict = SigenergyAPI.redact({"outer": {"accessToken": "live-token", "systemId": "SIG1"}})
assert nested_dict["outer"]["accessToken"] == "<redacted>", "Credential masked inside a nested dict"
assert nested_dict["outer"]["systemId"] == "SIG1", "Nested-dict non-credential key untouched"

# Every documented credential key is covered — iterate the constant so keys added
# to SIGENERGY_LOG_REDACT_KEYS later are automatically tested too
for key in SIGENERGY_LOG_REDACT_KEYS:
assert SigenergyAPI.redact({key: "secret"})[key] == "<redacted>", "Key {} masked".format(key)

# json.dumps() serialises tuples as arrays, so redaction must cover them or a
# tuple-shaped payload would be published as JSON yet logged unmasked
nested_tuple = SigenergyAPI.redact({"commands": ({"password": "hunter2"},)})
assert nested_tuple["commands"][0]["password"] == "<redacted>", "Credential masked inside a nested tuple"

# Scalars and lists of scalars pass straight through
assert SigenergyAPI.redact("plain") == "plain", "String passthrough"
assert SigenergyAPI.redact([1, 2]) == [1, 2], "List passthrough"
assert SigenergyAPI.redact(None) is None, "None passthrough"

return failed


def test_sigenergy_publish_mqtt_redacts_token(my_predbat):
"""Test _publish_mqtt keeps the live token on the wire but masks it in the log (#4920)."""
failed = False
api = MockSigenergyAPI()
api.access_token = "tok123"
api.mqtt_host = "openapi-eu.sigencloud.com" # cspell:disable-line
api.mqtt_port = 8883

mock_client = _make_mock_aiomqtt_client()
# The nested command carries a credential too, mirroring the shape of the #4920 leak:
# a top-level-only redaction would mask accessToken but still leak the nested password
payload = {"accessToken": "live-secret-token", "commands": [{"systemId": "SIG1", "activeMode": "charge", "password": "nested-secret"}]}

with patch("sigenergy.ssl.create_default_context", return_value=MagicMock()):
with patch("sigenergy.aiomqtt.Client", return_value=mock_client):
ok = run_async(SigenergyAPI._publish_mqtt(api, "openapi/instruction/command", payload))

assert ok is True, "_publish_mqtt should return True on success"

# The broker still receives the real token — redaction is log-only
topic, wire_payload = mock_client.publishes[0]
import json

assert topic == "openapi/instruction/command", "Published to the command topic"
assert json.loads(wire_payload)["accessToken"] == "live-secret-token", "Real token still published to the broker"
assert json.loads(wire_payload)["commands"][0]["password"] == "nested-secret", "Nested credential still published to the broker"

published_logs = [m for m in api.log_messages if "MQTT published" in m]
assert len(published_logs) == 1, "Exactly one publish log line expected"
assert "live-secret-token" not in published_logs[0], "Token must not appear in the log"
assert "nested-secret" not in published_logs[0], "Nested credential must not appear in the log"
assert "<redacted>" in published_logs[0], "Token replaced with the redaction marker"
assert "SIG1" in published_logs[0], "Non-credential payload content still logged"

# The caller's payload dict is not mutated by redaction
assert payload["accessToken"] == "live-secret-token", "Caller payload left unmodified"

return failed


def test_sigenergy_request_log_redacts_credentials(my_predbat):
"""Test _request masks credential-bearing keys in its request and response log lines."""
failed = False
api = MockSigenergyAPI()
api.get_access_token = AsyncMock(return_value="tok123")

fake_response = {"code": 0, "msg": "ok", "data": {"accessToken": "resp-token", "systemId": "SIG1"}}

mock_response = _make_mock_response(status=200, json_data=fake_response)
mock_session = _make_mock_session(mock_response)

with patch("sigenergy.SIGENERGY_MIN_REQUEST_INTERVAL", 0):
with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session):
result = run_async(SigenergyAPI._request(api, "POST", "/openapi/test", params={"token": "query-secret"}, json_data={"password": "hunter2", "systemId": "SIG1"}))

assert result == {"accessToken": "resp-token", "systemId": "SIG1"}, "Response data returned unchanged"

request_logs = [m for m in api.log_messages if "Requesting" in m]
assert len(request_logs) == 1, "Exactly one request log line expected"
assert "query-secret" not in request_logs[0], "params credential must not appear in the request log"
assert "hunter2" not in request_logs[0], "json_data credential must not appear in the request log"
assert "SIG1" in request_logs[0], "Non-credential request content still logged"

response_logs = [m for m in api.log_messages if "Response from" in m]
assert len(response_logs) == 1, "Exactly one response log line expected"
assert "resp-token" not in response_logs[0], "Response credential must not appear in the response log"
assert "SIG1" in response_logs[0], "Non-credential response content still logged"

return failed


def test_sigenergy_publish_mqtt_failure(my_predbat):
"""Test _publish_mqtt returns False when the broker connection raises."""
failed = False
Expand Down Expand Up @@ -3047,6 +3155,9 @@ def run_sigenergy_tests(my_predbat):
("apply_controls_deduplication", test_sigenergy_apply_controls_deduplication),
("apply_controls_export_mode", test_sigenergy_apply_controls_export_mode),
("publish_mqtt_success", test_sigenergy_publish_mqtt_success),
("redact", test_sigenergy_redact),
("publish_mqtt_redacts_token", test_sigenergy_publish_mqtt_redacts_token),
("request_log_redacts_credentials", test_sigenergy_request_log_redacts_credentials),
("publish_mqtt_failure", test_sigenergy_publish_mqtt_failure),
("send_battery_command_mqtt", test_sigenergy_send_battery_command_mqtt),
("send_battery_command_no_token", test_sigenergy_send_battery_command_no_token),
Expand Down
Loading