fix(security): redact secrets at log-write time, not just at serve time (#4770) - #5053
Conversation
There was a problem hiding this comment.
🟡 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_stringsandredact_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: 1234567890123therefore 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_labelledis accepted as any dict byAPPS_SCHEMA, so an unquoted numeric MPAN such as{landlord_mpan: 1234567890123}passes validation. YAML loads that value as an integer, but thisisinstance(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 thisisinstance(value, str)check, even thoughlog()serializes the value withstr(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.
…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>
There was a problem hiding this comment.
🟡 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:705assigns a provider block containing nestedapi_key, andchat_tools.py:773writes directly tobase.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 topredbat.login 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_NAMESmakesredact_stringsget 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 inargs), 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
…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>
|
(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):
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 |
There was a problem hiding this comment.
🟡 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.argsobject (component_base.py:63-70). This test updates that old object withredact_strings_labelled, so subsequent tests can observe different configuration through component aliases even thoughmy_predbat.argslooks 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_stringslist 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 reachespredbat.login 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),
valueis rebuilt as the union of several secret strings and is not a key inlabels. 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
…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>
|
(Posted by Rik; written by Claude.) Addressed the 5 suppressed comments from the latest review:
All five verified with mutation testing (reverting each fix in turn reproduces exactly the predicted failure, caught by a new/extended test). Full |
There was a problem hiding this comment.
🟡 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.logafter closing only its temporary Hass handle; the sharedmy_predbat.logfileremains 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.logwhile the singlemy_predbatused 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 thefinallyblock restores only the cache and module path. The unit suite reuses one PredBat instance; restoring the pre-save cache while leavingsk-or-new-secretinargsmakes 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 concurrentlog()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
keyis 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
registryswitch,is_secret_key(key, registry=False)no longer has the documented “substring-only” meaning. In particular, check_apps_yaml_secrets() now flags the wholeredact_stringslist/mapping as an inline credential even when its entries use the documented!secretform, 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
…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>
…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>
…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>
754ced7 to
94b4cf1
Compare
…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>
|
(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 (
Worth stating what this already closes, since the review has been running hole-to-hole: every nested credential on the Three things to push back on deliberately, rather than leave to be found:
The one remaining leak is filed as #5106 with a Tests: 3 new |
…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>
…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>
315576b to
055262f
Compare
…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>
…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>
055262f to
2d690f7
Compare
…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>
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 copypredbat.logdirectly 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'sscrub_secrets()was an independent implementation with its own hardcoded 4-substring credential list, disconnected from the growable per-component"secret": Trueregistry the sharedutils.pymachinery 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 topredbat.log, closing the Samba-share gap. Implementation notes:str.replace()loop), cached and rebuilt only whenargs/secretsactually change - the hot logging path pays for one scan per line regardless of how many secrets are configured.<octopus_api_key>, not one opaquexxxindistinguishable 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_mpancan be as informative as the value it names.annual.py'sscrub_secrets()now delegates to the sharedutils.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_stringsandredact_strings_labelledtest to fail with the exact expected message; restoring it passes../run_all --quickgreen (310 tests)./run_pre_commitgreen🤖 Generated with Claude Code