Skip to content

fix(security): redact secrets at log-write time, not just at serve time (#4770) - #5053

Merged
springfall2008 merged 11 commits into
mainfrom
fix/redact-secrets-log-write-time-4770
Sep 19, 2026
Merged

springfall2008 merged 11 commits into
mainfrom
fix/redact-secrets-log-write-time-4770

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Posted by Claude on behalf of @chalfontchubby.

Addresses #4770.

Problem

Every existing masking path (create_debug_yaml(), mask_secret_yaml_text(), web.py's apps.yaml route) only redacts at serve/download time. Some users copy predbat.log directly off a Samba share exposing the addon's config directory, bypassing every HTTP/MCP endpoint entirely - a scrub applied only at those endpoints leaves the on-disk file itself holding the plaintext credential.

Separately, annual.py's scrub_secrets() was an independent implementation with its own hardcoded 4-substring credential list, disconnected from the growable per-component "secret": True registry the shared utils.py machinery has since grown (account numbers, serial numbers, etc). Growing that registry did not benefit this path.

Predbat also has no way to know an MPAN, account number, or similar identifier surfaced by a third-party HA integration's entity state/attributes is sensitive - there is no schema for arbitrary third-party data, so nothing could redact it even in principle.

Fix

Write-time log redaction. Hass.log() now redacts every known secret value before the line is written to predbat.log, closing the Samba-share gap. Implementation notes:

  • A single compiled regex alternation (not a per-value str.replace() loop), cached and rebuilt only when args/secrets actually change - the hot logging path pays for one scan per line regardless of how many secrets are configured.
  • Values are matched longest-first, so a secret sharing a prefix with a longer one can't pre-empt the more specific match and leave part of it exposed.
  • A masked occurrence carries a label identifying which credential was found - <octopus_api_key>, not one opaque xxx indistinguishable from every other secret - so a log stays diagnosable without ever writing out the value itself.

User-maintained redaction denylist, for anything Predbat cannot recognise as a credential on its own:

  • redact_strings - a plain list of values, masked generically as <redact_strings>.
  • redact_strings_labelled - a {label: value} mapping, so a user's own entry gets a real identifying label back too, the same way a built-in credential is labelled by its config key name.

Both are masked wholesale in a debug dump - for the labelled form, the user's own label names are hidden too, since a key like landlord_mpan can be as informative as the value it names.

annual.py's scrub_secrets() now delegates to the shared utils.mask_secret_args() instead of keeping its own list, so it inherits every future registry addition automatically.

Deliberately out of scope: user-authored regex patterns for redaction (e.g. a template for "looks like an MPAN"). That opens real questions of its own - pattern validation, ReDoS risk from a pattern compiled into the hot logging path, whether a regex hit is safe to label - better left for its own discussion than folded into this fix.

Verification

Mutation-tested: reverting the write-time call in log() causes every credential, redact_strings and redact_strings_labelled test to fail with the exact expected message; restoring it passes.

  • ./run_all --quick green (310 tests)
  • ./run_pre_commit green

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved redaction issues could expose secrets or prevent startup.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds write-time secret redaction for logs, configurable redaction denylists, and shared masking for annual reports.

Changes:

  • Adds cached, labelled regex-based log redaction.
  • Adds redact_strings and redact_strings_labelled.
  • Extends tests and reuses shared masking in annual.py.
File summaries
File Summary and final findings
apps/predbat/utils.py Secret collection and masking. Critical (2 votes): secret-valued lists are not collected. Moderate (3 votes): short explicit secrets are not redacted. Moderate (2 votes): invalid labelled values can break startup. Moderate (1 vote): numeric labelled values are discarded. Moderate (1 vote): numeric YAML secrets are skipped.
apps/predbat/tests/test_validate_config.py Configuration validation tests; no final comments.
apps/predbat/tests/test_secrets.py Redaction tests. Moderate (2 votes): PREDBAT_APPS_FILE is deleted instead of restoring its prior value.
apps/predbat/hass.py Write-time log redaction. Critical (2 votes): the cache is not invalidated after in-place argument or secret changes.
apps/predbat/config.py Denylist schema. Moderate (1 vote): non-string labelled values pass validation but are ignored by collection.
apps/predbat/annual.py Uses shared secret masking; no final comments.
Review details

Suppressed comments (3)

apps/predbat/config.py:2809

  • The new labelled denylist accepts any dict value, but the collector silently ignores non-string values. A natural my_mpan: 1234567890123 therefore passes this schema and is never redacted. Validate mapping values as strings or collect scalar values consistently.
    "redact_strings_labelled": {"type": "dict"},

apps/predbat/utils.py:305

  • redact_strings_labelled is accepted as any dict by APPS_SCHEMA, so an unquoted numeric MPAN such as {landlord_mpan: 1234567890123} passes validation. YAML loads that value as an integer, but this isinstance(value, str) check discards it, so the denylist fails for a common form of the identifier and the log can expose it. Normalize scalar mapping values to strings or validate the mapping as string-valued before collecting it.
    if redact_strings_labelled:
        for label, value in redact_strings_labelled.items():
            if isinstance(value, str) and len(value) >= 6 and value not in found:
                found[value] = str(label)

apps/predbat/utils.py:295

  • Secrets loaded from YAML are not guaranteed to be strings. An unquoted numeric MPAN/account ID (or numeric !secret) is returned as an int and is skipped by this isinstance(value, str) check, even though log() serializes the value with str(msg). That leaves a known identifier unredacted; normalize supported scalar values or fail closed.
    if secrets:
        for key, value in secrets.items():
            if isinstance(value, str) and len(value) >= 6 and value not in found:
                found[value] = key
  • Files reviewed: 6/6 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/hass.py Outdated
Comment thread apps/predbat/utils.py Outdated
Comment thread apps/predbat/tests/test_secrets.py Outdated
Comment thread apps/predbat/utils.py Outdated
Comment thread apps/predbat/utils.py Outdated
chalfontchubby added a commit that referenced this pull request Sep 11, 2026
…t secrets, crash guard

Five findings from Copilot's review of the #4770 write-time log redaction PR, all confirmed
and fixed.

Cache invalidated only at startup (hass.py, userinterface.py, web.py)
------------------------------------------------------------------------
The compiled redaction pattern was rebuilt only in Hass.__init__, but self.args is mutated
after startup too: set_arg() (userinterface.py) and the apps.yaml web editor's batch
clear()/update() (web.py). A credential added or changed through either path kept leaking into
the log under the stale pre-change pattern until Predbat restarted - the exact class of leak
this PR exists to close. Invalidated the cache at both mutation points.

Secret-flagged list values were dropped, not collected (utils.py)
----------------------------------------------------------------------
Several registry secrets are declared "string|string_list" (teslemetry_site_id,
sigenergy_system_id). _collect_secret_values() checked isinstance(item, str) with no list
branch, so a list value under a secret-flagged key was silently skipped entirely - not masked
in a debug dump (mask_secret_args() already handled that correctly, wholesale), but each real
value never entered the log redaction set either. Now collects each list element individually.

Malformed redact_strings_labelled crashed Predbat at startup (utils.py)
-----------------------------------------------------------------------------
log() runs on the very first startup log line, before APPS_SCHEMA validation has had any chance
to run. redact_strings_labelled.items() had no type guard, so the exact malformed value the new
validator test exercises as an expected rejection (a plain string instead of a dict) raised
AttributeError and prevented Predbat from starting at all, rather than reaching the validation
warning. Both redact_strings and redact_strings_labelled are now type-checked before iteration
and degrade to "nothing from this source" on a bad shape.

Length floor over-applied to explicit denylist entries (utils.py)
------------------------------------------------------------------
The 6-character floor (dropped to avoid false-positive-redacting ordinary short log text from
the automatic key-name heuristic) was also applied to redact_strings/redact_strings_labelled -
entries the user added deliberately, not inferred. A 4-5 character denylisted value would still
be written in the clear, contradicting the point of listing it. Floor now applies only to the
secrets.yaml/args sources; an explicit denylist entry of any length is honoured.

Test teardown deleted an env var unconditionally (test_secrets.py)
------------------------------------------------------------------------
The new write-time test set PREDBAT_APPS_FILE and unconditionally deleted it in finally,
dropping any pre-existing value rather than restoring it - the suite runs every registered test
in one shared process. Now saves and restores the prior value (or absence of one).

All five verified with mutation testing against the actual fix, not just written and trusted:
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>
@springfall2008
springfall2008 requested a lite review from Copilot September 11, 2026 19:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved redaction correctness, cache synchronization, and configuration-handling issues remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

apps/predbat/hass.py:203

  • This cache is still bypassed by in-place configuration writes outside the two invalidation sites: web_chat.py:705 assigns a provider block containing nested api_key, and chat_tools.py:773 writes directly to base.args. After the first log builds the pattern, either path can add or change a credential while leaving this old pattern active, so the new value can be written to predbat.log in plaintext until restart. Route all config mutations through one invalidating helper or refresh from a configuration generation/fingerprint.
            args = getattr(self, "args", None)
            redact_strings = args.get("redact_strings") if args else None
            redact_strings_labelled = args.get("redact_strings_labelled") if args else None
            values = collect_log_secret_values(args, getattr(self, "secrets", None), redact_strings, redact_strings_labelled)
            self._log_secret_pattern_cache = compile_log_secret_pattern(values)

apps/predbat/utils.py:249

  • SECRET_KEY_EXPLICIT_NAMES makes redact_strings get collected during this generic args traversal, before the dedicated labelled-denylist pass at line 326. If the same value is present in both denylist forms (or under a credential key later in args), the generic <redact_strings> label wins based on YAML insertion order, contrary to the documented rule that a specific label wins. Skip the explicit denylist keys here and collect them only in the ordered blocks below.
        for key, item in value.items():
            if is_secret_key(key):
  • Files reviewed: 11/11 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment thread apps/predbat/config.py Outdated
Comment thread apps/predbat/hass.py Outdated
Comment thread apps/predbat/utils.py
Comment thread apps/predbat/utils.py Outdated
Comment thread apps/predbat/utils.py
chalfontchubby added a commit that referenced this pull request Sep 11, 2026
…c 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>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Posted by Rik; written by Claude.)

Addressed the two suppressed comments from the latest review, plus the 5 inline-thread findings from the earlier one (replied/resolved individually on their threads):

web_chat.py:705 / chat_tools.py:773 - missed cache invalidation sites: both wrote straight to base.args (a provider block that can carry a nested api_key, and an arbitrary set_apps_config value respectively) without invalidating the cached log redaction pattern, so a credential saved through either path would keep leaking into the log under the stale pattern until restart. Both now call the shared _invalidate_log_secret_pattern() helper.

hass.py:249 - SECRET_KEY_EXPLICIT_NAMES label-priority ordering: while fixing the cache invalidation gaps I also found and fixed the ordering issue this comment flagged. redact_strings/redact_strings_labelled are secret-flagged (via SECRET_KEY_EXPLICIT_NAMES in is_secret_key()) so the debug-dump masking hides them wholesale - but that same flag made the generic args traversal 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 "a more specific label wins" priority. A value present in both denylist forms kept the generic <redact_strings> label instead of the more specific one. Fixed by skipping the two true top-level keys in that traversal (an unrelated nested key merely sharing the name still collects normally, labelled by its own key path).

All fixes verified with mutation testing (reverting each one in turn reproduces exactly the predicted failure, caught by a new/extended test) and the full ./run_all --quick + ./run_pre_commit are green. Pushed as d3585d65.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical redaction gaps and concurrency issues could still expose sensitive values.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (5)

apps/predbat/predbat.py:1655

  • The collector now explicitly coerces numeric labelled values to strings, so an unquoted numeric denylist value is still matched. This warning says quoting is required “to ensure it is matched,” which contradicts the implemented behavior and the new numeric-value handling; revise it to explain coercion (or the separate risk of losing formatting such as leading zeros).
                                            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))

apps/predbat/tests/test_validate_config.py:216

  • Assigning a new dict here leaves initialized components holding the old base.args object (component_base.py:63-70). This test updates that old object with redact_strings_labelled, so subsequent tests can observe different configuration through component aliases even though my_predbat.args looks restored. Restore the shared mapping in place instead of replacing it.
        my_predbat.args = saved_args

apps/predbat/utils.py:262

  • This traversal assumes every YAML mapping key is a string. A valid nested mapping with an integer or other non-string key reaches key.lower() during the first startup log and raises before configuration validation can report the input, potentially aborting startup. Normalize the key before both the explicit-name check and label construction.
            if not label_prefix and key.lower() in SECRET_KEY_EXPLICIT_NAMES:
                continue
            if is_secret_key(key):
                key_label = (label_prefix + "." + key) if label_prefix else key

apps/predbat/utils.py:354

  • A numeric entry in the explicitly user-maintained redact_strings list is ignored here. MPANs and account identifiers are commonly written as unquoted YAML numbers, so the denylist can appear configured while the corresponding value still reaches predbat.log in plaintext (and may be printed by validation). Coerce non-boolean numeric entries just as the labelled denylist does.
    if isinstance(redact_strings, list):
        for value in redact_strings:
            if isinstance(value, str) and value and value not in found:
                found[value] = "redact_strings"

apps/predbat/utils.py:429

  • For overlaps that start at different offsets (and for the chained-overlap case covered by the new tests), value is rebuilt as the union of several secret strings and is not a key in labels. The fallback therefore emits <xxx>, losing the labelled-redaction guarantee described by this change and making the affected log line indistinguishable from an unlabelled mask. Preserve the label(s) of the extending matches, or explicitly define a labelled representation for a multi-secret overlap.
        out.append("<{}>".format(labels.get(value, SECRET_MASK)))
  • Files reviewed: 16/16 changed files
  • Comments generated: 12
  • Review effort level: Lite

Comment thread apps/predbat/annual.py
Comment thread apps/predbat/chat_tools.py
Comment thread apps/predbat/config.py
Comment thread apps/predbat/hass.py
Comment thread apps/predbat/hass.py
Comment thread apps/predbat/utils.py Outdated
Comment thread apps/predbat/web.py
Comment thread apps/predbat/web_chat.py Outdated
Comment thread apps/predbat/tests/test_web_chat.py
Comment thread apps/predbat/tests/test_secrets.py
chalfontchubby added a commit that referenced this pull request Sep 12, 2026
…rning text, args aliasing, a key.lower() crash, numeric redact_strings, and overlap labels

Five suppressed findings from the latest Copilot review of the #4770 write-time
log redaction PR, all confirmed and fixed.

Warning text contradicted the actual (already-fixed) behaviour
------------------------------------------------------------------
The scalar_value_dict warning told the user to quote a numeric value "to
ensure it is matched", but collect_log_secret_values() already coerces it to
a string regardless. Reworded to explain the real reason to quote it: keeping
exact formatting (a leading zero, say) rather than losing it to numeric
parsing.

A test restored my_predbat.args by reassignment, not in place
------------------------------------------------------------------
ComponentBase.__init__ aliases self.args = base.args at construction, so a
test that restores my_predbat.args = saved_args (rather than mutating the
same dict object back to its original contents) leaves any component already
holding that reference pointing at the old, still-mutated dict - the test
itself looks clean but a component alias is not. Fixed both _run()'s shared
restore and the redact_strings_labelled numeric-value test's own restore to
clear()+update() in place. Confirmed the leak directly: with the old
reassignment restore, a stand-in "component alias" dict still carried the
test's added key after _run() returned; with the fix, it does not.

_collect_secret_values crashed on a non-string top-level dict key
------------------------------------------------------------------
The redact_strings/redact_strings_labelled exemption added for the
label-priority fix called key.lower() unconditionally. is_secret_key()
already tolerates a non-string key (str(key).lower()) for exactly this
reason - log() runs on the very first startup line, before validation has
had any chance to report a malformed apps.yaml. Matched the same guard.

Numeric redact_strings (bare list) values were still silently dropped
------------------------------------------------------------------------
The earlier fix coerced scalar secrets.yaml and redact_strings_labelled
values to string but missed the parallel redact_strings list form - an
unquoted numeric MPAN there (redact_strings: [1234567890123]) still failed
the isinstance(value, str) check and never reached the log redaction set.

An overlapping-secret mask lost its label
------------------------------------------------------------------------
redact_log_line()'s overlap-extension fix (previous commit) covered the full
extended span but looked its label up as labels.get(merged_span, ...) - the
merged span spanning more than one secret is not itself a key in labels, so
it silently fell back to the generic <xxx> mask, losing the "which
credential" guarantee a labelled mask exists to provide even though nothing
leaked. Now tracks the label of every secret that contributed to the
(possibly chained) extended span and joins them, e.g. <api_key+redact_strings>.

All five verified with mutation testing: reverting each fix in turn
reproduces exactly the failure its regression 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>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Posted by Rik; written by Claude.)

Addressed the 5 suppressed comments from the latest review:

predbat.py:1655 - stale warning text: the scalar_value_dict warning said quoting a numeric value was needed "to ensure it is matched" - but the collector already coerces it to a string regardless, so that's no longer true. Reworded to explain the real reason to quote it (keeping exact formatting, e.g. a leading zero, rather than losing it to numeric parsing).

test_validate_config.py:216 - args restored by reassignment, not in place: ComponentBase.__init__ aliases self.args = base.args at construction, so restoring via my_predbat.args = saved_args leaves any component already holding that reference pointing at the old, still-mutated dict - the test itself looks clean but a component alias is not. Confirmed the leak directly with a stand-in alias, then fixed both _run()'s shared restore (used by every test in the file) and this test's own restore to clear()+update() in place.

utils.py:262 - key.lower() crash on a non-string key: the label-priority exemption added last round called .lower() unconditionally; is_secret_key() already guards this exact case (str(key).lower()) since log() runs on the very first startup line, before validation gets a chance to report a malformed apps.yaml. Matched the same guard.

utils.py:354 - numeric redact_strings (bare list) still dropped: the earlier numeric-coercion fix covered secrets.yaml and redact_strings_labelled but missed the parallel bare-list form - an unquoted numeric MPAN there still failed isinstance(value, str) and never reached the log redaction set.

utils.py:429 - an overlapping-secret mask lost its label: the overlap-extension fix covered the value correctly (nothing leaks) but looked its label up as labels.get(merged_span, ...) - a span covering more than one overlapping secret isn't itself a key in labels, so it silently fell back to the generic <xxx> mask, losing the "which credential" guarantee. Now tracks every secret's label that contributed to the (possibly chained) extended span and joins them, e.g. <api_key+redact_strings>.

All five verified with mutation testing (reverting each fix in turn reproduces exactly the predicted failure, caught by a new/extended test). Full ./run_all --quick + ./run_pre_commit green. Pushed as 2dd5a066.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Critical redaction gaps and race conditions remain, along with moderate input-handling and test-isolation issues.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (8)

apps/predbat/tests/test_secrets.py:528

  • This test also unconditionally deletes the shared predbat.log after closing only its temporary Hass handle; the shared my_predbat.logfile remains open for all subsequent registry tests. That makes the full suite order-dependent and can remove a caller's existing log. Use a temporary working directory or restore the original file rather than removing this path.
        for name in ("test_apps.yaml", "secrets.yaml", "predbat.log"):
            if os.path.exists(name):
                os.remove(name)

apps/predbat/tests/test_secrets.py:131

  • This cleanup unlinks the shared predbat.log while the single my_predbat used by the test registry still has its logfile open. Later tests keep writing to the unlinked handle, so log readers see a missing/stale file and any pre-existing log is destroyed. Run the temporary Hass instance in an isolated directory or preserve and restore the original log instead of deleting it.
        if os.path.exists("predbat.log"):
            os.remove("predbat.log")

apps/predbat/tests/test_web_chat.py:2947

  • This new test changes the shared my_predbat.args["chat"] through the route, but the finally block restores only the cache and module path. The unit suite reuses one PredBat instance; restoring the pre-save cache while leaving sk-or-new-secret in args makes the next tests run with an inconsistent redaction state and can leak this fixture value. Save and restore the prior chat block as well, then restore/invalidate the cache consistently.
    finally:
        web_chat.APPS_YAML_PATH = original
        my_predbat._log_secret_pattern_cache = saved_cache

apps/predbat/userinterface.py:212

  • This invalidates only after mutating self.args. A concurrent log() can enter between the assignment and _invalidate_log_secret_pattern(), reuse the old compiled pattern, and write the newly added credential before the invalidation acquires the lock; the lock only ensures the stale cache is removed afterward. Make each mutation plus cache-generation update atomic (the same sequence exists in the web/chat writers), and cover this interleaving in the race test.
        # A credential value or the redact_strings/redact_strings_labelled denylists themselves
        # can change here, so log()'s cached redaction pattern (hass.py) must be rebuilt on next
        # use - otherwise a newly added/changed secret keeps leaking into the log under the stale
        # pattern until Predbat restarts (GH#4770 review).
        self._invalidate_log_secret_pattern()

apps/predbat/utils.py:267

  • Registry-marked credentials can still be numeric when an apps.yaml value is unquoted (for example an MPAN/account ID). This branch only collects strings, so collect_log_secret_values({"kraken_mpan": 1234567890123}, {}) produces no pattern and a subsequent log can write the identifier verbatim. Coerce scalar int/float values to strings here, as the explicit denylist sources already do.
                if isinstance(item, str) and item and item not in found:

apps/predbat/utils.py:248

  • The comments say malformed non-string keys are tolerated, but raw key is later concatenated with strings for nested prefixes and can also be stored as a non-string label. A YAML mapping with a numeric nested key can therefore raise during the first log, and a non-string secret label can make '+'.join(span_labels) fail. Normalize the key once at the loop boundary.
        for key, item in value.items():

apps/predbat/utils.py:354

  • The labelled denylist key is copied verbatim into the replacement written to the log. A valid configuration such as redact_strings_labelled: {secret: secret} therefore produces <secret> and leaks the denylisted value; labels containing newlines can also inject log lines. Validate/sanitize labels or fall back to a generic mask whenever a label contains secret/control content.
                    found[value] = str(label)

apps/predbat/utils.py:202

  • Because this check runs before the registry switch, is_secret_key(key, registry=False) no longer has the documented “substring-only” meaning. In particular, check_apps_yaml_secrets() now flags the whole redact_strings list/mapping as an inline credential even when its entries use the documented !secret form, since the raw-file walker does not recurse once the container key is treated as secret. Keep denylist masking separate from the credential heuristic or make the raw checker recurse these containers.
    if key_lower in SECRET_KEY_EXPLICIT_NAMES:
        return True
  • Files reviewed: 16/16 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment thread apps/predbat/hass.py
Comment thread apps/predbat/predbat.py Outdated
Comment thread apps/predbat/utils.py Outdated
Comment thread apps/predbat/utils.py Outdated
Comment thread apps/predbat/utils.py
chalfontchubby added a commit that referenced this pull request Sep 12, 2026
…idation (#5053)

Holistic pass over #5053 looking for the next Copilot-review gap before another
round-trip: auto_config() (userinterface.py) is a third, independent place that
writes into self.args - alongside set_arg() and web.py's apps.yaml batch editor,
both already fixed - and had no invalidation at all. It resolves `re:` patterns
against live HA entity ids on every startup/reconfigure pass, so a resolved or
newly-unmatched-and-removed value could keep leaking under (or being missed by)
a stale redaction pattern until Predbat next restarts - the same leak class this
PR exists to close, just at a call site the first four review rounds didn't
reach. Invalidates once at the end of the pass, only when something changed,
rather than per key, since auto_config() iterates every configured arg on each
call.

Also documents, at the top of hass.py, why the module's "outside AppDaemon"
naming does not mean the write-time redaction only partially covers the real
addon population: genuine AppDaemon-hosted installs are retired, there is no
appdaemon dependency in this repo, and predbat.PredBat unconditionally inherits
from this file's Hass class in every currently supported install path (Predbat
app/addon and Docker) - so there is no separate, unprotected logging pipeline
left for the Samba-share scenario this PR targets.

Added a regression test mirroring test_set_arg_invalidates_log_secret_cache:
confirmed it fails with exactly the leak described (the resolved value reaches
the log unredacted) when the fix is reverted, and passes with it restored.

./run_all --quick and ./run_pre_commit both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Sep 12, 2026
…leaks

Five review rounds each found another args-mutation site that could log a
credential under a stale redaction pattern, because every writer mutates
self.args first and calls _invalidate_log_secret_pattern() second: a log()
landing between those steps takes the cache lock, sees a cache not yet
marked stale, and redacts with the pre-mutation pattern. Fixing the named
call site each round left the next one to be found.

Cache the fingerprint (identity and size of args/secrets) the pattern was
built from and rebuild when it no longer matches, so a credential is
redacted from the first line after it becomes visible in args whether or
not its writer invalidated. The explicit invalidations stay load-bearing
for in-place edits that keep the size the same; this is a safety net under
them, downgrading a missed site from "leaks until restart" to "redacted
from the next line". GH#5063 still tracks removing the contract itself.

Also, from the same review rounds:

- utils.py: a scalar redact_strings ("redact_strings: !secret my_mpan",
  which validate_config() reads without get_arg()'s list wrapping) was
  dropped by the list-only guard, leaving the value in the clear. Numeric
  entries in secret-flagged scalars and lists (sigenergy_system_id,
  teslemetry_site_id) were skipped for the same reason ints are not str.
- predbat.py: the warning about a non-string redact_strings_labelled value
  interpolated that value - printing the MPAN it exists to hide, before the
  pattern that would redact it has been built. Names the type instead.
- web.py: the /apps editor used its own four-substring key check, rendering
  registry-flagged credentials (account numbers, MPANs) in the clear and
  putting them in data-original-value and a title tooltip. Uses the shared
  is_secret_key() predicate.
- tests: test_secrets.py deleted PREDBAT_APPS_FILE unconditionally and
  test_web_chat.py left a saved provider api_key in the shared args, both
  leaking into later tests in the same process.

The malformed-input test now distinguishes a bare string redact_strings
(honoured - safe degradation for a string is to redact it) from a shape
naming no value at all (still collects nothing).

New test publishes a credential with no invalidation at all and asserts it
is still redacted; it fails with the fingerprint check removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Sep 12, 2026
scrub_secrets() delegates to the shared mask_secret_args(), deliberately
rather than keeping a second credential list - but the annual tool's raw
schema spells the credential annual.load.octopus.account_id, which matches
no substring and is not a component-registered apps.yaml name. The
registry flags the apps.yaml spellings of the same thing
(octopus_api_account, kraken_account_id), so this one value stayed in the
clear in the annual result and web surface after being masked everywhere
else.

Added via a new SECRET_KEY_EXTRA_NAMES rather than SECRET_KEY_EXPLICIT_NAMES:
that list carries a second meaning, since _collect_secret_values() skips
its names at the top level so the redact_strings denylists take their
labels from a later, more specific pass. account_id has no such pass and
must keep collecting normally, so a top-level one is still redacted in the
log as well as masked in a dump.

The annual test used account_id as its "non-secret value survives
scrubbing" sample, which is exactly the assumption this corrects; it now
asserts account_id is masked and uses region for the survival check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Sep 14, 2026
…t secrets, crash guard

Five findings from Copilot's review of the #4770 write-time log redaction PR, all confirmed
and fixed.

Cache invalidated only at startup (hass.py, userinterface.py, web.py)
------------------------------------------------------------------------
The compiled redaction pattern was rebuilt only in Hass.__init__, but self.args is mutated
after startup too: set_arg() (userinterface.py) and the apps.yaml web editor's batch
clear()/update() (web.py). A credential added or changed through either path kept leaking into
the log under the stale pre-change pattern until Predbat restarted - the exact class of leak
this PR exists to close. Invalidated the cache at both mutation points.

Secret-flagged list values were dropped, not collected (utils.py)
----------------------------------------------------------------------
Several registry secrets are declared "string|string_list" (teslemetry_site_id,
sigenergy_system_id). _collect_secret_values() checked isinstance(item, str) with no list
branch, so a list value under a secret-flagged key was silently skipped entirely - not masked
in a debug dump (mask_secret_args() already handled that correctly, wholesale), but each real
value never entered the log redaction set either. Now collects each list element individually.

Malformed redact_strings_labelled crashed Predbat at startup (utils.py)
-----------------------------------------------------------------------------
log() runs on the very first startup log line, before APPS_SCHEMA validation has had any chance
to run. redact_strings_labelled.items() had no type guard, so the exact malformed value the new
validator test exercises as an expected rejection (a plain string instead of a dict) raised
AttributeError and prevented Predbat from starting at all, rather than reaching the validation
warning. Both redact_strings and redact_strings_labelled are now type-checked before iteration
and degrade to "nothing from this source" on a bad shape.

Length floor over-applied to explicit denylist entries (utils.py)
------------------------------------------------------------------
The 6-character floor (dropped to avoid false-positive-redacting ordinary short log text from
the automatic key-name heuristic) was also applied to redact_strings/redact_strings_labelled -
entries the user added deliberately, not inferred. A 4-5 character denylisted value would still
be written in the clear, contradicting the point of listing it. Floor now applies only to the
secrets.yaml/args sources; an explicit denylist entry of any length is honoured.

Test teardown deleted an env var unconditionally (test_secrets.py)
------------------------------------------------------------------------
The new write-time test set PREDBAT_APPS_FILE and unconditionally deleted it in finally,
dropping any pre-existing value rather than restoring it - the suite runs every registered test
in one shared process. Now saves and restores the prior value (or absence of one).

All five verified with mutation testing against the actual fix, not just written and trusted:
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>
chalfontchubby added a commit that referenced this pull request Sep 14, 2026
…c 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>
chalfontchubby added a commit that referenced this pull request Sep 14, 2026
…rning text, args aliasing, a key.lower() crash, numeric redact_strings, and overlap labels

Five suppressed findings from the latest Copilot review of the #4770 write-time
log redaction PR, all confirmed and fixed.

Warning text contradicted the actual (already-fixed) behaviour
------------------------------------------------------------------
The scalar_value_dict warning told the user to quote a numeric value "to
ensure it is matched", but collect_log_secret_values() already coerces it to
a string regardless. Reworded to explain the real reason to quote it: keeping
exact formatting (a leading zero, say) rather than losing it to numeric
parsing.

A test restored my_predbat.args by reassignment, not in place
------------------------------------------------------------------
ComponentBase.__init__ aliases self.args = base.args at construction, so a
test that restores my_predbat.args = saved_args (rather than mutating the
same dict object back to its original contents) leaves any component already
holding that reference pointing at the old, still-mutated dict - the test
itself looks clean but a component alias is not. Fixed both _run()'s shared
restore and the redact_strings_labelled numeric-value test's own restore to
clear()+update() in place. Confirmed the leak directly: with the old
reassignment restore, a stand-in "component alias" dict still carried the
test's added key after _run() returned; with the fix, it does not.

_collect_secret_values crashed on a non-string top-level dict key
------------------------------------------------------------------
The redact_strings/redact_strings_labelled exemption added for the
label-priority fix called key.lower() unconditionally. is_secret_key()
already tolerates a non-string key (str(key).lower()) for exactly this
reason - log() runs on the very first startup line, before validation has
had any chance to report a malformed apps.yaml. Matched the same guard.

Numeric redact_strings (bare list) values were still silently dropped
------------------------------------------------------------------------
The earlier fix coerced scalar secrets.yaml and redact_strings_labelled
values to string but missed the parallel redact_strings list form - an
unquoted numeric MPAN there (redact_strings: [1234567890123]) still failed
the isinstance(value, str) check and never reached the log redaction set.

An overlapping-secret mask lost its label
------------------------------------------------------------------------
redact_log_line()'s overlap-extension fix (previous commit) covered the full
extended span but looked its label up as labels.get(merged_span, ...) - the
merged span spanning more than one secret is not itself a key in labels, so
it silently fell back to the generic <xxx> mask, losing the "which
credential" guarantee a labelled mask exists to provide even though nothing
leaked. Now tracks the label of every secret that contributed to the
(possibly chained) extended span and joins them, e.g. <api_key+redact_strings>.

All five verified with mutation testing: reverting each fix in turn
reproduces exactly the failure its regression 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>
@chalfontchubby
chalfontchubby force-pushed the fix/redact-secrets-log-write-time-4770 branch from 754ced7 to 94b4cf1 Compare September 14, 2026 15:14
chalfontchubby added a commit that referenced this pull request Sep 14, 2026
…idation (#5053)

Holistic pass over #5053 looking for the next Copilot-review gap before another
round-trip: auto_config() (userinterface.py) is a third, independent place that
writes into self.args - alongside set_arg() and web.py's apps.yaml batch editor,
both already fixed - and had no invalidation at all. It resolves `re:` patterns
against live HA entity ids on every startup/reconfigure pass, so a resolved or
newly-unmatched-and-removed value could keep leaking under (or being missed by)
a stale redaction pattern until Predbat next restarts - the same leak class this
PR exists to close, just at a call site the first four review rounds didn't
reach. Invalidates once at the end of the pass, only when something changed,
rather than per key, since auto_config() iterates every configured arg on each
call.

Also documents, at the top of hass.py, why the module's "outside AppDaemon"
naming does not mean the write-time redaction only partially covers the real
addon population: genuine AppDaemon-hosted installs are retired, there is no
appdaemon dependency in this repo, and predbat.PredBat unconditionally inherits
from this file's Hass class in every currently supported install path (Predbat
app/addon and Docker) - so there is no separate, unprotected logging pipeline
left for the Samba-share scenario this PR targets.

Added a regression test mirroring test_set_arg_invalidates_log_secret_cache:
confirmed it fails with exactly the leak described (the resolved value reaches
the log unredacted) when the fix is reverted, and passes with it restored.

./run_all --quick and ./run_pre_commit both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Sep 14, 2026
…leaks

Five review rounds each found another args-mutation site that could log a
credential under a stale redaction pattern, because every writer mutates
self.args first and calls _invalidate_log_secret_pattern() second: a log()
landing between those steps takes the cache lock, sees a cache not yet
marked stale, and redacts with the pre-mutation pattern. Fixing the named
call site each round left the next one to be found.

Cache the fingerprint (identity and size of args/secrets) the pattern was
built from and rebuild when it no longer matches, so a credential is
redacted from the first line after it becomes visible in args whether or
not its writer invalidated. The explicit invalidations stay load-bearing
for in-place edits that keep the size the same; this is a safety net under
them, downgrading a missed site from "leaks until restart" to "redacted
from the next line". GH#5063 still tracks removing the contract itself.

Also, from the same review rounds:

- utils.py: a scalar redact_strings ("redact_strings: !secret my_mpan",
  which validate_config() reads without get_arg()'s list wrapping) was
  dropped by the list-only guard, leaving the value in the clear. Numeric
  entries in secret-flagged scalars and lists (sigenergy_system_id,
  teslemetry_site_id) were skipped for the same reason ints are not str.
- predbat.py: the warning about a non-string redact_strings_labelled value
  interpolated that value - printing the MPAN it exists to hide, before the
  pattern that would redact it has been built. Names the type instead.
- web.py: the /apps editor used its own four-substring key check, rendering
  registry-flagged credentials (account numbers, MPANs) in the clear and
  putting them in data-original-value and a title tooltip. Uses the shared
  is_secret_key() predicate.
- tests: test_secrets.py deleted PREDBAT_APPS_FILE unconditionally and
  test_web_chat.py left a saved provider api_key in the shared args, both
  leaking into later tests in the same process.

The malformed-input test now distinguishes a bare string redact_strings
(honoured - safe degradation for a string is to redact it) from a shape
naming no value at all (still collects nothing).

New test publishes a credential with no invalidation at all and asserts it
is still redacted; it fails with the fingerprint check removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Sep 14, 2026
scrub_secrets() delegates to the shared mask_secret_args(), deliberately
rather than keeping a second credential list - but the annual tool's raw
schema spells the credential annual.load.octopus.account_id, which matches
no substring and is not a component-registered apps.yaml name. The
registry flags the apps.yaml spellings of the same thing
(octopus_api_account, kraken_account_id), so this one value stayed in the
clear in the annual result and web surface after being masked everywhere
else.

Added via a new SECRET_KEY_EXTRA_NAMES rather than SECRET_KEY_EXPLICIT_NAMES:
that list carries a second meaning, since _collect_secret_values() skips
its names at the top level so the redact_strings denylists take their
labels from a later, more specific pass. account_id has no such pass and
must keep collecting normally, so a top-level one is still redacted in the
log as well as masked in a dump.

The annual test used account_id as its "non-secret value survives
scrubbing" sample, which is exactly the assumption this corrects; it now
asserts account_id is masked and uses region for the survival check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chalfontchubby
chalfontchubby requested a lite review from Copilot September 15, 2026 09:13
chalfontchubby added a commit that referenced this pull request Sep 15, 2026
…red redaction

Addresses the sixth review round on #5053 structurally rather than per hole.
All six findings were two call sites that bypassed helpers the PR already
has, so the bypasses are removed instead of their edge cases patched.

/apps rendering: html_apps() masked only credentials whose own top-level
apps.yaml key named them, then handed the raw structure to render_type(),
which recurses. A nested credential (chat.providers.openrouter.api_key,
forecast_solar[0].api_key) therefore reached the browser in the clear, in
both the rendered value and data-nested-original. It now masks once at the
route through mask_secret_args() - the same recursive traversal every other
surface uses - so nested credentials are covered by construction.

/apps writing: because the page now serves the mask, saving a secret row
back would write "xxx" over a live credential. That is the trap
find_redacted_secret_overwrite() already refuses on the model's write path,
so html_apps_post() refuses it too, per path segment the way chat_tools
does. Rotating a credential to a real new value still works.

Denylist collection: the redact_strings/redact_strings_labelled tails
hand-rolled scalar-only loops, so any nested shape was silently dropped
while validation merely warned - "redact_strings: [[1234567890123]]" never
entered the pattern. Both now go through _flatten_denylist_value(), which
yields every scalar inside any shape, str()d the way log() serialises. This
deliberately over-collects: redacting a harmless word beats leaving a
credential in the clear, and one test that asserted the opposite for a dict
is updated with the reasoning.

Traversal: build nested label prefixes with str(key), at both sites rather
than only the reported one - a YAML mapping may have a non-string key, and
this runs from log() before validation can report anything.

The remaining leak, resolve_arg_re() logging a matched entity id before
redaction covers it, is filed as #5106 with a TODO at the site: the value
there is an entity id rather than a resolved credential, and the fix needs
a decision about where the diagnostic belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Written by Claude, working with @chalfontchubby.)

Sixth round addressed structurally rather than hole-by-hole, because reading the findings together they were all one shape: two call sites bypassing helpers this PR already has.

The core is one predicate (is_secret_key), one recursive traversal (mask_secret_args), one write-back guard (find_redacted_secret_overwrite). Nine of ten consumers use them and are correct by construction. The two that hand-rolled flat versions produced all six comments — so the bypasses are gone, not their edge cases patched:

  • /apps rendering masked only top-level-named credentials, then handed the raw structure to render_type(), which recurses. Now masked once at the route through mask_secret_args(), so nested credentials are covered as new ones appear.
  • /apps writing gets the guard that already protects the model's write path, so a masked row saved back is refused rather than destroying the key.
  • The denylist tails dropped any non-scalar shape silently while validation only warned. Both now flatten through one normaliser.
  • str(key) in the traversal, at both sites rather than only the reported one.

Worth stating what this already closes, since the review has been running hole-to-hole: every nested credential on the /apps page, and every non-scalar denylist entry — two whole classes, not six instances. forecast_solar[0].api_key was the documented case mask_secret_args was written for, and the page was the one surface not asking it.

Three things to push back on deliberately, rather than leave to be found:

  1. Over-collection is now the posture. _flatten_denylist_value() str()s even unknown types. Asymmetric failure modes: over-collecting redacts a harmless word, under-collecting leaks a credential.
  2. One test changed sides. It asserted redact_strings={"not": "a list"} collects nothing; it now collects the value inside. The test's own comment already argued that for bare strings and merely carved out dicts — and redact_strings: {mpan: 123…} is exactly the plausible mix-up with redact_strings_labelled that was being dropped. Reasoning is recorded in the test.
  3. A real behaviour change: editing a masked credential row on /apps is now refused. That is what makes masking safe, but it is new surface — say if it should move.

The one remaining leak is filed as #5106 with a TODO at the site: resolve_arg_re() logs a matched entity id before redaction covers it. Deferred because the value is an entity id rather than a resolved credential, and the fix needs a decision about where that diagnostic belongs rather than a reflex patch.

Tests: 3 new /apps cases (nested masking deep-copies, refusal on both a dict and a list path, rotation still works), 3 new collector cases (malformed shape, double-nested, None/bool/empty dropped), 1 new traversal case (credential under an int key, asserting the outer.123.password label). Pre-commit clean, quick suite green.

chalfontchubby added a commit that referenced this pull request Sep 15, 2026
…t secrets, crash guard

Five findings from Copilot's review of the #4770 write-time log redaction PR, all confirmed
and fixed.

Cache invalidated only at startup (hass.py, userinterface.py, web.py)
------------------------------------------------------------------------
The compiled redaction pattern was rebuilt only in Hass.__init__, but self.args is mutated
after startup too: set_arg() (userinterface.py) and the apps.yaml web editor's batch
clear()/update() (web.py). A credential added or changed through either path kept leaking into
the log under the stale pre-change pattern until Predbat restarted - the exact class of leak
this PR exists to close. Invalidated the cache at both mutation points.

Secret-flagged list values were dropped, not collected (utils.py)
----------------------------------------------------------------------
Several registry secrets are declared "string|string_list" (teslemetry_site_id,
sigenergy_system_id). _collect_secret_values() checked isinstance(item, str) with no list
branch, so a list value under a secret-flagged key was silently skipped entirely - not masked
in a debug dump (mask_secret_args() already handled that correctly, wholesale), but each real
value never entered the log redaction set either. Now collects each list element individually.

Malformed redact_strings_labelled crashed Predbat at startup (utils.py)
-----------------------------------------------------------------------------
log() runs on the very first startup log line, before APPS_SCHEMA validation has had any chance
to run. redact_strings_labelled.items() had no type guard, so the exact malformed value the new
validator test exercises as an expected rejection (a plain string instead of a dict) raised
AttributeError and prevented Predbat from starting at all, rather than reaching the validation
warning. Both redact_strings and redact_strings_labelled are now type-checked before iteration
and degrade to "nothing from this source" on a bad shape.

Length floor over-applied to explicit denylist entries (utils.py)
------------------------------------------------------------------
The 6-character floor (dropped to avoid false-positive-redacting ordinary short log text from
the automatic key-name heuristic) was also applied to redact_strings/redact_strings_labelled -
entries the user added deliberately, not inferred. A 4-5 character denylisted value would still
be written in the clear, contradicting the point of listing it. Floor now applies only to the
secrets.yaml/args sources; an explicit denylist entry of any length is honoured.

Test teardown deleted an env var unconditionally (test_secrets.py)
------------------------------------------------------------------------
The new write-time test set PREDBAT_APPS_FILE and unconditionally deleted it in finally,
dropping any pre-existing value rather than restoring it - the suite runs every registered test
in one shared process. Now saves and restores the prior value (or absence of one).

All five verified with mutation testing against the actual fix, not just written and trusted:
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>
chalfontchubby added a commit that referenced this pull request Sep 15, 2026
…c 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>
chalfontchubby added a commit that referenced this pull request Sep 15, 2026
…rning text, args aliasing, a key.lower() crash, numeric redact_strings, and overlap labels

Five suppressed findings from the latest Copilot review of the #4770 write-time
log redaction PR, all confirmed and fixed.

Warning text contradicted the actual (already-fixed) behaviour
------------------------------------------------------------------
The scalar_value_dict warning told the user to quote a numeric value "to
ensure it is matched", but collect_log_secret_values() already coerces it to
a string regardless. Reworded to explain the real reason to quote it: keeping
exact formatting (a leading zero, say) rather than losing it to numeric
parsing.

A test restored my_predbat.args by reassignment, not in place
------------------------------------------------------------------
ComponentBase.__init__ aliases self.args = base.args at construction, so a
test that restores my_predbat.args = saved_args (rather than mutating the
same dict object back to its original contents) leaves any component already
holding that reference pointing at the old, still-mutated dict - the test
itself looks clean but a component alias is not. Fixed both _run()'s shared
restore and the redact_strings_labelled numeric-value test's own restore to
clear()+update() in place. Confirmed the leak directly: with the old
reassignment restore, a stand-in "component alias" dict still carried the
test's added key after _run() returned; with the fix, it does not.

_collect_secret_values crashed on a non-string top-level dict key
------------------------------------------------------------------
The redact_strings/redact_strings_labelled exemption added for the
label-priority fix called key.lower() unconditionally. is_secret_key()
already tolerates a non-string key (str(key).lower()) for exactly this
reason - log() runs on the very first startup line, before validation has
had any chance to report a malformed apps.yaml. Matched the same guard.

Numeric redact_strings (bare list) values were still silently dropped
------------------------------------------------------------------------
The earlier fix coerced scalar secrets.yaml and redact_strings_labelled
values to string but missed the parallel redact_strings list form - an
unquoted numeric MPAN there (redact_strings: [1234567890123]) still failed
the isinstance(value, str) check and never reached the log redaction set.

An overlapping-secret mask lost its label
------------------------------------------------------------------------
redact_log_line()'s overlap-extension fix (previous commit) covered the full
extended span but looked its label up as labels.get(merged_span, ...) - the
merged span spanning more than one secret is not itself a key in labels, so
it silently fell back to the generic <xxx> mask, losing the "which
credential" guarantee a labelled mask exists to provide even though nothing
leaked. Now tracks the label of every secret that contributed to the
(possibly chained) extended span and joins them, e.g. <api_key+redact_strings>.

All five verified with mutation testing: reverting each fix in turn
reproduces exactly the failure its regression 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>
chalfontchubby added a commit that referenced this pull request Sep 15, 2026
…idation (#5053)

Holistic pass over #5053 looking for the next Copilot-review gap before another
round-trip: auto_config() (userinterface.py) is a third, independent place that
writes into self.args - alongside set_arg() and web.py's apps.yaml batch editor,
both already fixed - and had no invalidation at all. It resolves `re:` patterns
against live HA entity ids on every startup/reconfigure pass, so a resolved or
newly-unmatched-and-removed value could keep leaking under (or being missed by)
a stale redaction pattern until Predbat next restarts - the same leak class this
PR exists to close, just at a call site the first four review rounds didn't
reach. Invalidates once at the end of the pass, only when something changed,
rather than per key, since auto_config() iterates every configured arg on each
call.

Also documents, at the top of hass.py, why the module's "outside AppDaemon"
naming does not mean the write-time redaction only partially covers the real
addon population: genuine AppDaemon-hosted installs are retired, there is no
appdaemon dependency in this repo, and predbat.PredBat unconditionally inherits
from this file's Hass class in every currently supported install path (Predbat
app/addon and Docker) - so there is no separate, unprotected logging pipeline
left for the Samba-share scenario this PR targets.

Added a regression test mirroring test_set_arg_invalidates_log_secret_cache:
confirmed it fails with exactly the leak described (the resolved value reaches
the log unredacted) when the fix is reverted, and passes with it restored.

./run_all --quick and ./run_pre_commit both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@chalfontchubby
chalfontchubby force-pushed the fix/redact-secrets-log-write-time-4770 branch from 315576b to 055262f Compare September 15, 2026 21:18
chalfontchubby added a commit that referenced this pull request Sep 15, 2026
…leaks

Five review rounds each found another args-mutation site that could log a
credential under a stale redaction pattern, because every writer mutates
self.args first and calls _invalidate_log_secret_pattern() second: a log()
landing between those steps takes the cache lock, sees a cache not yet
marked stale, and redacts with the pre-mutation pattern. Fixing the named
call site each round left the next one to be found.

Cache the fingerprint (identity and size of args/secrets) the pattern was
built from and rebuild when it no longer matches, so a credential is
redacted from the first line after it becomes visible in args whether or
not its writer invalidated. The explicit invalidations stay load-bearing
for in-place edits that keep the size the same; this is a safety net under
them, downgrading a missed site from "leaks until restart" to "redacted
from the next line". GH#5063 still tracks removing the contract itself.

Also, from the same review rounds:

- utils.py: a scalar redact_strings ("redact_strings: !secret my_mpan",
  which validate_config() reads without get_arg()'s list wrapping) was
  dropped by the list-only guard, leaving the value in the clear. Numeric
  entries in secret-flagged scalars and lists (sigenergy_system_id,
  teslemetry_site_id) were skipped for the same reason ints are not str.
- predbat.py: the warning about a non-string redact_strings_labelled value
  interpolated that value - printing the MPAN it exists to hide, before the
  pattern that would redact it has been built. Names the type instead.
- web.py: the /apps editor used its own four-substring key check, rendering
  registry-flagged credentials (account numbers, MPANs) in the clear and
  putting them in data-original-value and a title tooltip. Uses the shared
  is_secret_key() predicate.
- tests: test_secrets.py deleted PREDBAT_APPS_FILE unconditionally and
  test_web_chat.py left a saved provider api_key in the shared args, both
  leaking into later tests in the same process.

The malformed-input test now distinguishes a bare string redact_strings
(honoured - safe degradation for a string is to redact it) from a shape
naming no value at all (still collects nothing).

New test publishes a credential with no invalidation at all and asserts it
is still redacted; it fails with the fingerprint check removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Sep 15, 2026
scrub_secrets() delegates to the shared mask_secret_args(), deliberately
rather than keeping a second credential list - but the annual tool's raw
schema spells the credential annual.load.octopus.account_id, which matches
no substring and is not a component-registered apps.yaml name. The
registry flags the apps.yaml spellings of the same thing
(octopus_api_account, kraken_account_id), so this one value stayed in the
clear in the annual result and web surface after being masked everywhere
else.

Added via a new SECRET_KEY_EXTRA_NAMES rather than SECRET_KEY_EXPLICIT_NAMES:
that list carries a second meaning, since _collect_secret_values() skips
its names at the top level so the redact_strings denylists take their
labels from a later, more specific pass. account_id has no such pass and
must keep collecting normally, so a top-level one is still redacted in the
log as well as masked in a dump.

The annual test used account_id as its "non-secret value survives
scrubbing" sample, which is exactly the assumption this corrects; it now
asserts account_id is masked and uses region for the survival check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chalfontchubby added a commit that referenced this pull request Sep 15, 2026
…red redaction

Addresses the sixth review round on #5053 structurally rather than per hole.
All six findings were two call sites that bypassed helpers the PR already
has, so the bypasses are removed instead of their edge cases patched.

/apps rendering: html_apps() masked only credentials whose own top-level
apps.yaml key named them, then handed the raw structure to render_type(),
which recurses. A nested credential (chat.providers.openrouter.api_key,
forecast_solar[0].api_key) therefore reached the browser in the clear, in
both the rendered value and data-nested-original. It now masks once at the
route through mask_secret_args() - the same recursive traversal every other
surface uses - so nested credentials are covered by construction.

/apps writing: because the page now serves the mask, saving a secret row
back would write "xxx" over a live credential. That is the trap
find_redacted_secret_overwrite() already refuses on the model's write path,
so html_apps_post() refuses it too, per path segment the way chat_tools
does. Rotating a credential to a real new value still works.

Denylist collection: the redact_strings/redact_strings_labelled tails
hand-rolled scalar-only loops, so any nested shape was silently dropped
while validation merely warned - "redact_strings: [[1234567890123]]" never
entered the pattern. Both now go through _flatten_denylist_value(), which
yields every scalar inside any shape, str()d the way log() serialises. This
deliberately over-collects: redacting a harmless word beats leaving a
credential in the clear, and one test that asserted the opposite for a dict
is updated with the reasoning.

Traversal: build nested label prefixes with str(key), at both sites rather
than only the reported one - a YAML mapping may have a non-string key, and
this runs from log() before validation can report anything.

The remaining leak, resolve_arg_re() logging a matched entity id before
redaction covers it, is filed as #5106 with a TODO at the site: the value
there is an entity id rather than a resolved credential, and the fix needs
a decision about where the diagnostic belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread apps/predbat/web_chat.py Outdated
chalfontchubby and others added 11 commits September 16, 2026 09:14
…me (#4770)

Two real gaps beyond what was already fixed for #4770: predbat.log and annual.py's config
scrubbing had no protection against a credential ever reaching them.

Write-time log redaction (hass.py, utils.py)
---------------------------------------------
Every existing masking path (create_debug_yaml, mask_secret_yaml_text, web.py's apps.yaml
route) only redacts at serve/download time. Some users copy predbat.log directly off a Samba
share exposing the addon's config directory, bypassing every HTTP/MCP endpoint entirely - a
scrub applied only at those endpoints leaves the on-disk file itself holding the plaintext
value. Hass.log() now redacts every known secret value before the line is written, using a
compiled-regex pattern (not a per-value str.replace() loop) cached and rebuilt only when
args/secrets actually change, so the hot logging path pays for a single scan per line rather
than compiling or re-scanning per secret.

Masked occurrences carry a label identifying which credential was found - <octopus_api_key>,
not a single opaque "xxx" indistinguishable from every other secret - so a log still tells you
which integration to check without ever writing out the value itself.

User-maintained redaction denylist (config.py)
------------------------------------------------
Predbat can only redact by config key name or its component registry; it has no schema for what
a third-party HA integration's entity state/attributes might contain, so an MPAN or account
number surfaced that way was invisible to every masking path. Two new apps.yaml keys let a user
list values Predbat cannot infer are sensitive on its own:
  - redact_strings: a bare string list, masked generically as <redact_strings>
  - redact_strings_labelled: a {label: value} mapping, masked with the user's own chosen label
Both are themselves masked wholesale in a debug dump, or they would defeat their own purpose -
including redact_strings_labelled's own label names, which could be as informative as the values.

annual.py's independent scrub_secrets() (annual.py)
------------------------------------------------------
A second, independent implementation with its own hardcoded four-substring list, no access to
the growable per-component "secret": True registry account numbers/serial numbers have since
been added to. Growing that registry silently did not benefit this path. Now delegates to the
shared utils.mask_secret_args() so it inherits every future addition automatically.

Verified with mutation testing: reverting the write-time call in log() causes every credential,
redact_strings and redact_strings_labelled test to fail exactly as expected; restoring it passes.
./run_all --quick and ./run_pre_commit both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t secrets, crash guard

Five findings from Copilot's review of the #4770 write-time log redaction PR, all confirmed
and fixed.

Cache invalidated only at startup (hass.py, userinterface.py, web.py)
------------------------------------------------------------------------
The compiled redaction pattern was rebuilt only in Hass.__init__, but self.args is mutated
after startup too: set_arg() (userinterface.py) and the apps.yaml web editor's batch
clear()/update() (web.py). A credential added or changed through either path kept leaking into
the log under the stale pre-change pattern until Predbat restarted - the exact class of leak
this PR exists to close. Invalidated the cache at both mutation points.

Secret-flagged list values were dropped, not collected (utils.py)
----------------------------------------------------------------------
Several registry secrets are declared "string|string_list" (teslemetry_site_id,
sigenergy_system_id). _collect_secret_values() checked isinstance(item, str) with no list
branch, so a list value under a secret-flagged key was silently skipped entirely - not masked
in a debug dump (mask_secret_args() already handled that correctly, wholesale), but each real
value never entered the log redaction set either. Now collects each list element individually.

Malformed redact_strings_labelled crashed Predbat at startup (utils.py)
-----------------------------------------------------------------------------
log() runs on the very first startup log line, before APPS_SCHEMA validation has had any chance
to run. redact_strings_labelled.items() had no type guard, so the exact malformed value the new
validator test exercises as an expected rejection (a plain string instead of a dict) raised
AttributeError and prevented Predbat from starting at all, rather than reaching the validation
warning. Both redact_strings and redact_strings_labelled are now type-checked before iteration
and degrade to "nothing from this source" on a bad shape.

Length floor over-applied to explicit denylist entries (utils.py)
------------------------------------------------------------------
The 6-character floor (dropped to avoid false-positive-redacting ordinary short log text from
the automatic key-name heuristic) was also applied to redact_strings/redact_strings_labelled -
entries the user added deliberately, not inferred. A 4-5 character denylisted value would still
be written in the clear, contradicting the point of listing it. Floor now applies only to the
secrets.yaml/args sources; an explicit denylist entry of any length is honoured.

Test teardown deleted an env var unconditionally (test_secrets.py)
------------------------------------------------------------------------
The new write-time test set PREDBAT_APPS_FILE and unconditionally deleted it in finally,
dropping any pre-existing value rather than restoring it - the suite runs every registered test
in one shared process. Now saves and restores the prior value (or absence of one).

All five verified with mutation testing against the actual fix, not just written and trusted:
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>
…trings (#4770)

The #4770 write-time redaction and the redact_strings/redact_strings_labelled denylist keys had
no documentation anywhere - the shipped apps.yaml template didn't mention them and
docs/apps-yaml.md's "Storing secrets" section, the natural home for this, stopped at the
existing !secret/key-name-substring coverage.

Added a "Redaction in logs and debug files" subsection covering where redaction applies and
that it happens at write time (predbat.log included, not just downloads), plus a
redact_strings/redact_strings_labelled subsection with examples - leading with the labelled
form since it's the more generally useful shape, per feedback while reviewing this.

Added both settings, commented out, to the shipped apps.yaml template, in the same style as the
file's other end-of-file cross-cutting settings (watch_list) - so a user encountering the need
for this finds it in the file they already have open, not only in the docs site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…c 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>
…rning text, args aliasing, a key.lower() crash, numeric redact_strings, and overlap labels

Five suppressed findings from the latest Copilot review of the #4770 write-time
log redaction PR, all confirmed and fixed.

Warning text contradicted the actual (already-fixed) behaviour
------------------------------------------------------------------
The scalar_value_dict warning told the user to quote a numeric value "to
ensure it is matched", but collect_log_secret_values() already coerces it to
a string regardless. Reworded to explain the real reason to quote it: keeping
exact formatting (a leading zero, say) rather than losing it to numeric
parsing.

A test restored my_predbat.args by reassignment, not in place
------------------------------------------------------------------
ComponentBase.__init__ aliases self.args = base.args at construction, so a
test that restores my_predbat.args = saved_args (rather than mutating the
same dict object back to its original contents) leaves any component already
holding that reference pointing at the old, still-mutated dict - the test
itself looks clean but a component alias is not. Fixed both _run()'s shared
restore and the redact_strings_labelled numeric-value test's own restore to
clear()+update() in place. Confirmed the leak directly: with the old
reassignment restore, a stand-in "component alias" dict still carried the
test's added key after _run() returned; with the fix, it does not.

_collect_secret_values crashed on a non-string top-level dict key
------------------------------------------------------------------
The redact_strings/redact_strings_labelled exemption added for the
label-priority fix called key.lower() unconditionally. is_secret_key()
already tolerates a non-string key (str(key).lower()) for exactly this
reason - log() runs on the very first startup line, before validation has
had any chance to report a malformed apps.yaml. Matched the same guard.

Numeric redact_strings (bare list) values were still silently dropped
------------------------------------------------------------------------
The earlier fix coerced scalar secrets.yaml and redact_strings_labelled
values to string but missed the parallel redact_strings list form - an
unquoted numeric MPAN there (redact_strings: [1234567890123]) still failed
the isinstance(value, str) check and never reached the log redaction set.

An overlapping-secret mask lost its label
------------------------------------------------------------------------
redact_log_line()'s overlap-extension fix (previous commit) covered the full
extended span but looked its label up as labels.get(merged_span, ...) - the
merged span spanning more than one secret is not itself a key in labels, so
it silently fell back to the generic <xxx> mask, losing the "which
credential" guarantee a labelled mask exists to provide even though nothing
leaked. Now tracks the label of every secret that contributed to the
(possibly chained) extended span and joins them, e.g. <api_key+redact_strings>.

All five verified with mutation testing: reverting each fix in turn
reproduces exactly the failure its regression 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>
…idation (#5053)

Holistic pass over #5053 looking for the next Copilot-review gap before another
round-trip: auto_config() (userinterface.py) is a third, independent place that
writes into self.args - alongside set_arg() and web.py's apps.yaml batch editor,
both already fixed - and had no invalidation at all. It resolves `re:` patterns
against live HA entity ids on every startup/reconfigure pass, so a resolved or
newly-unmatched-and-removed value could keep leaking under (or being missed by)
a stale redaction pattern until Predbat next restarts - the same leak class this
PR exists to close, just at a call site the first four review rounds didn't
reach. Invalidates once at the end of the pass, only when something changed,
rather than per key, since auto_config() iterates every configured arg on each
call.

Also documents, at the top of hass.py, why the module's "outside AppDaemon"
naming does not mean the write-time redaction only partially covers the real
addon population: genuine AppDaemon-hosted installs are retired, there is no
appdaemon dependency in this repo, and predbat.PredBat unconditionally inherits
from this file's Hass class in every currently supported install path (Predbat
app/addon and Docker) - so there is no separate, unprotected logging pipeline
left for the Samba-share scenario this PR targets.

Added a regression test mirroring test_set_arg_invalidates_log_secret_cache:
confirmed it fails with exactly the leak described (the resolved value reaches
the log unredacted) when the fix is reverted, and passes with it restored.

./run_all --quick and ./run_pre_commit both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…leaks

Five review rounds each found another args-mutation site that could log a
credential under a stale redaction pattern, because every writer mutates
self.args first and calls _invalidate_log_secret_pattern() second: a log()
landing between those steps takes the cache lock, sees a cache not yet
marked stale, and redacts with the pre-mutation pattern. Fixing the named
call site each round left the next one to be found.

Cache the fingerprint (identity and size of args/secrets) the pattern was
built from and rebuild when it no longer matches, so a credential is
redacted from the first line after it becomes visible in args whether or
not its writer invalidated. The explicit invalidations stay load-bearing
for in-place edits that keep the size the same; this is a safety net under
them, downgrading a missed site from "leaks until restart" to "redacted
from the next line". GH#5063 still tracks removing the contract itself.

Also, from the same review rounds:

- utils.py: a scalar redact_strings ("redact_strings: !secret my_mpan",
  which validate_config() reads without get_arg()'s list wrapping) was
  dropped by the list-only guard, leaving the value in the clear. Numeric
  entries in secret-flagged scalars and lists (sigenergy_system_id,
  teslemetry_site_id) were skipped for the same reason ints are not str.
- predbat.py: the warning about a non-string redact_strings_labelled value
  interpolated that value - printing the MPAN it exists to hide, before the
  pattern that would redact it has been built. Names the type instead.
- web.py: the /apps editor used its own four-substring key check, rendering
  registry-flagged credentials (account numbers, MPANs) in the clear and
  putting them in data-original-value and a title tooltip. Uses the shared
  is_secret_key() predicate.
- tests: test_secrets.py deleted PREDBAT_APPS_FILE unconditionally and
  test_web_chat.py left a saved provider api_key in the shared args, both
  leaking into later tests in the same process.

The malformed-input test now distinguishes a bare string redact_strings
(honoured - safe degradation for a string is to redact it) from a shape
naming no value at all (still collects nothing).

New test publishes a credential with no invalidation at all and asserts it
is still redacted; it fails with the fingerprint check removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scrub_secrets() delegates to the shared mask_secret_args(), deliberately
rather than keeping a second credential list - but the annual tool's raw
schema spells the credential annual.load.octopus.account_id, which matches
no substring and is not a component-registered apps.yaml name. The
registry flags the apps.yaml spellings of the same thing
(octopus_api_account, kraken_account_id), so this one value stayed in the
clear in the annual result and web surface after being masked everywhere
else.

Added via a new SECRET_KEY_EXTRA_NAMES rather than SECRET_KEY_EXPLICIT_NAMES:
that list carries a second meaning, since _collect_secret_values() skips
its names at the top level so the redact_strings denylists take their
labels from a later, more specific pass. account_id has no such pass and
must keep collecting normally, so a top-level one is still redacted in the
log as well as masked in a dump.

The annual test used account_id as its "non-secret value survives
scrubbing" sample, which is exactly the assumption this corrects; it now
asserts account_id is masked and uses region for the survival check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…red redaction

Addresses the sixth review round on #5053 structurally rather than per hole.
All six findings were two call sites that bypassed helpers the PR already
has, so the bypasses are removed instead of their edge cases patched.

/apps rendering: html_apps() masked only credentials whose own top-level
apps.yaml key named them, then handed the raw structure to render_type(),
which recurses. A nested credential (chat.providers.openrouter.api_key,
forecast_solar[0].api_key) therefore reached the browser in the clear, in
both the rendered value and data-nested-original. It now masks once at the
route through mask_secret_args() - the same recursive traversal every other
surface uses - so nested credentials are covered by construction.

/apps writing: because the page now serves the mask, saving a secret row
back would write "xxx" over a live credential. That is the trap
find_redacted_secret_overwrite() already refuses on the model's write path,
so html_apps_post() refuses it too, per path segment the way chat_tools
does. Rotating a credential to a real new value still works.

Denylist collection: the redact_strings/redact_strings_labelled tails
hand-rolled scalar-only loops, so any nested shape was silently dropped
while validation merely warned - "redact_strings: [[1234567890123]]" never
entered the pattern. Both now go through _flatten_denylist_value(), which
yields every scalar inside any shape, str()d the way log() serialises. This
deliberately over-collects: redacting a harmless word beats leaving a
credential in the clear, and one test that asserted the opposite for a dict
is updated with the reasoning.

Traversal: build nested label prefixes with str(key), at both sites rather
than only the reported one - a YAML mapping may have a non-string key, and
this runs from log() before validation can report anything.

The remaining leak, resolve_arg_re() logging a matched entity id before
redaction covers it, is filed as #5106 with a TODO at the site: the value
there is an entity id rather than a resolved credential, and the fix needs
a decision about where the diagnostic belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ine (#5106)

resolve_arg_re() logs the entity id an re: pattern matched before its
caller (auto_config()) assigns the value and invalidates the log
redaction cache, so a secret-flagged arg whose match embeds a
credential (e.g. an MPAN in an Octopus entity id) was written to the
log in plaintext. A secret-flagged arg now logs the pattern and key
name only; the resolved value returned to the caller is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
set_arg() already writes self.args[key] and invalidates the cached
log redaction pattern in one place; web_chat.py's provider-save route
was hand-rolling both steps separately. Reusing it removes the
duplicated invalidation call (review comment on #5053).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@chalfontchubby
chalfontchubby force-pushed the fix/redact-secrets-log-write-time-4770 branch from 055262f to 2d690f7 Compare September 16, 2026 08:20
@springfall2008
springfall2008 merged commit 4f20c1e into main Sep 19, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/redact-secrets-log-write-time-4770 branch September 19, 2026 18:24
CossieRob pushed a commit to CossieRob/batpred that referenced this pull request Sep 20, 2026
…for the 2026-09-19 merge batch

7 of 15 candidates folded (GH#2033, GH#2444 x2, PR#4756, PR#4972, GH#1406 residual, GH#5066 nuance, teslemetry midnight flake), 8 dropped as already covered. Corrects GH#4165/springfall2008#4641 row for PR springfall2008#5114, GH#5033 row for the springfall2008#5149 balance rework, the debug-snapshot bullet and GH#5070 row for PR springfall2008#5071, the GH#5063 example for PR springfall2008#5053, and adds the myenergi springfall2008#5150 clause.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants