You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit d3585d6
Browse filesBrowse the repository at this point in the historyBrowse files
fix(security): address the follow-up Copilot review on #5053 - numeric secrets, overlap redaction, a load_secrets() crash, a cache race, and a label-priority bug
Six findings from a second round of Copilot review on the #4770 write-time log
redaction PR, all confirmed and fixed.
Numeric secrets.yaml/redact_strings_labelled values silently dropped (utils.py)
-----------------------------------------------------------------------------
collect_log_secret_values() checked isinstance(value, str), so an unquoted
numeric MPAN/account ID (landlord_mpan: 1234567890123) loaded from YAML as an
int and was silently skipped - never redacted even though log() serializes
every message with str(msg). Scalar secrets.yaml and redact_strings_labelled
values are now coerced to string before matching (bools excluded, to avoid
"True"/"False" matching ordinary log text).
redact_strings_labelled schema gave no warning for a non-string value (config.py, predbat.py)
-----------------------------------------------------------------------------------------------
The dict-type validator only checked the mapping itself, never its values, so
the numeric-MPAN case above passed validation with no warning at all. Added a
scalar_value_dict schema flag, checked in validate_config()'s dict branch,
that warns (not errors - the value is still usable once coerced) when a
mapping value isn't already a string.
Two overlapping secrets at different start positions left part of the longer one exposed (utils.py)
-------------------------------------------------------------------------------------------------------
pattern.sub() finds the first alternative that matches at the earliest
position and resumes scanning after it, so "sec1" and "c123x" both present in
"sec123x" only ever matched "sec1" - "c123x" starting one character in was
never considered, leaking "23x". redact_log_line() now extends each match to
the longest secret starting anywhere inside its span before emitting the
mask, chasing chained overlaps until the span stabilises.
A malformed secrets.yaml (valid YAML, wrong shape) crashed startup entirely (hass.py)
-----------------------------------------------------------------------------------------
load_secrets() only guarded yaml.safe_load()'s exceptions, not a load that
succeeds with the wrong shape - a bare scalar/list secrets.yaml reassigns the
local `secrets` before the next line's secrets.get("logger") call throws
AttributeError. The except caught that, but the reassignment had already
happened, so load_secrets() still returned the malformed value - and the very
next log() call crashed unhandled reaching collect_log_secret_values()'s
secrets.items(). Now checked explicitly and degraded to {} with a warning.
The cached redaction pattern build was racy against a concurrent invalidation (hass.py)
---------------------------------------------------------------------------------------------
log() runs from component threads as well as the main thread, so two threads
could both observe the UNSET sentinel and race to rebuild: a thread that
started building from stale args right before another thread invalidated the
cache could finish second and overwrite the fresh invalidation with its
stale pattern, silently keeping a just-added credential unredacted. Added a
lock making "read sentinel, build, store" one atomic step, and two missed
invalidation call sites (web_chat.py's provider save, chat_tools.py's
set_apps_config) that could leave a saved credential under the stale pattern.
redact_strings raced its own more-specific label (utils.py)
-------------------------------------------------------------
redact_strings/redact_strings_labelled are secret-flagged (via
SECRET_KEY_EXPLICIT_NAMES) so mask_secret_args()'s debug-dump masking hides
them wholesale - but that same flag made the generic args-traversal pass in
_collect_secret_values() pick up redact_strings as an ordinary secret-valued
key too, labelling its values generically BEFORE the dedicated
redact_strings_labelled pass ran with its documented "specific label wins"
priority. A value present in both denylist forms kept the generic label
instead. The two true top-level keys are now skipped in that traversal (an
unrelated nested key sharing the name still collects normally).
All six verified with mutation testing against the actual fix: reverting each
one in turn reproduces exactly the failure the corresponding new/extended
test describes, and only that test fails.
./run_all --quick and ./run_pre_commit both green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: apps/predbat/predbat.py
+12Lines changed: 12 additions & 0 deletions
Original file line number
Diff line number
Diff line change
@@ -1641,6 +1641,18 @@ def validate_config(self):
1641
1641
self.arg_errors[name] ="Invalid type, element {} expected dict".format(item)
1642
1642
errors+=1
1643
1643
break
1644
+
1645
+
# scalar_value_dict (e.g. redact_strings_labelled): a mapping value
1646
+
# that isn't already a string passes this branch silently since it
1647
+
# only checks item is a dict, but the consumer collecting these
1648
+
# values only matches strings - an unquoted numeric MPAN/account ID
1649
+
# loads from YAML as an int and would otherwise go unredacted with
1650
+
# no warning at all (#5053 review). Warn, don't error: the value is
1651
+
# still usable once coerced to a string by the collector.
1652
+
ifspec.get("scalar_value_dict", False):
1653
+
forkey, sub_valueinitem.items():
1654
+
ifnotisinstance(sub_value, str):
1655
+
self.log("Warn: Validation of apps.yaml found configuration item '{}' entry '{}' value {} is not a string - quote it to ensure it is matched".format(name, key, sub_value))
0 commit comments