Files
Jean-Pierre MartineauandClaude Opus 5.5 1d983013d7 No log keeps a password or a secret: the log file, the run log, or the remote history
Passwords, channel secrets, pairing PINs, and private keys reached the DEBUG log and
the runs table in plain text: every chat run stored its channel secret inside the
Conversation repr, and --password, --secret, channel links, and config set device_pin
were stored as typed. meshterm.core.secrets now hides each one as <secret> by its
shape (a secret-named key, record, set operation, or dataclass field; bytes; a Secret;
a remote `password`/`set guest.password` command; name=value in text), and by value
for each secret MeshTerm has read or sent. Both log handlers and the runs table go
through it, always. The remote CLI history keeps `password ` without its value. Schema
v20 hides the secrets that older versions stored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 00:20:22 -04:00

384 lines
15 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Keep each password and each secret out of each log: the log file and the run log.
MeshTerm keeps a record of what it does in two places: the log file (``meshterm.log``) and
the ``runs`` table, which stores the parameters and the summary of each run. Neither place
may ever hold a password, a channel secret, a pairing PIN, or a private key (JP,
2026-10-18: "NEVER log passwords or secrets!"). Each one is written as :data:`SECRET_TEXT`
instead. This rule does not depend on a preference, and the ``anonymize`` preference
(refer to :mod:`meshterm.core.anonymize`) adds to it and does not replace it.
A secret gets into a record in many shapes, and this module has one rule for each shape.
No rule depends on a list of the tools or of their parameters. Thus a tool that is added
later is covered on the day that it arrives:
- **A named value.** A value is a secret when its name says so (:func:`names_secret`):
a mapping key (``{"password": …}``), a dataclass field (``Conversation.secret``), the
``key`` of a record (``{"key": "device_pin", "value": …}``), the name in an operation
(``("set", "device_pin", "123456")``), or the name before ``=`` in a text
(``…&secret=8f…``, ``secret=b'…'``).
- **A wrapped value.** A tool can wrap a value in :class:`Secret` when nothing names it.
- **Bytes.** The parameters of a run carry bytes only for keys and secrets. Thus each
``bytes`` value is a secret.
- **A remote command.** ``password <new>`` and ``set guest.password <new>`` carry the
secret as their last word.
- **A known value in free text.** Each value that a rule above hid, and each secret that
MeshTerm reads (a stored password, a channel secret, a pairing PIN), is registered
(:func:`remember`). :func:`hide` then finds it in any text: an error message that
repeats it, or a frame that a library writes in hex.
:func:`loggable` applies the rules to a structure (the parameters and the summary of a
run). :func:`hide` applies them to a text (a log record).
"""
from __future__ import annotations
import re
import threading
from collections.abc import Mapping
from dataclasses import fields, is_dataclass
from datetime import date, datetime
from enum import Enum
from pathlib import PurePath
from typing import Any
#: What a log and the run log show in the place of a secret.
SECRET_TEXT = "<secret>"
#: The words that make a name the name of a secret. A name matches when one of its words
#: is in this set. The words of a name are the parts between the characters that are not
#: letters or digits. Thus ``device_pin``, ``guest.password``, and ``bridge.secret`` match,
#: and ``pinned`` and ``spin`` do not. ``prv`` is the private key of a repeater
#: (``prv.key``). ``key`` alone is not a secret word, because most keys in MeshTerm are
#: public keys.
SECRET_WORDS = frozenset(
{
"password",
"passwd",
"passphrase",
"secret",
"pin",
"private",
"privkey",
"prv",
"psk",
"token",
"credential",
"credentials",
}
)
#: The entries of a record whose ``key`` names a secret, and which hold its value.
_RECORD_VALUES = frozenset({"value", "previous", "default", "old", "new"})
#: The entries of a record that name the thing that the record is about.
_RECORD_NAMES = ("key", "setting")
#: The shortest text that :func:`remember` registers. A shorter value matches too much
#: ordinary text in a log (``abc``, ``123``). The structural rules still hide it where it
#: has a name.
_MIN_REMEMBERED = 4
#: The shortest hex value that :func:`hide` finds inside a longer run of hex. A channel
#: secret has 32 hex digits. With 16 or more, a match by chance is not probable.
_MIN_HEX_SUBSTRING = 16
_WORD_SPLIT = re.compile(r"[^a-z0-9]+")
_HEX = re.compile(r"[0-9a-fA-F]+")
#: ``name = value`` in a text: a URL query, a ``repr`` of an object, or a ``key=value``
#: log line. A name with no quotes takes ``=``. A quoted name (a ``repr`` of a dict, or
#: JSON) also takes ``:``. Prose ("device_pin: must be six digits") is not an assignment.
#: The value is a quoted literal (also a ``bytes`` literal), or a run of characters up to
#: the end of the value.
_ASSIGNMENT = re.compile(
r"(?P<name>(?<![\w.-])[A-Za-z_][\w.-]*)"
r"(?P<sep>\s*=\s*|['\"]\s*[=:]\s*)"
r"(?P<value>b?'(?:[^'\\]|\\.)*'|b?\"(?:[^\"\\]|\\.)*\"|<secret>|[^\s'\",;)}\]&<>]+)"
)
#: A remote command that sets a password: ``password <new>``.
_PASSWORD_COMMAND = re.compile(r"^(?P<head>\s*password\s+)(?P<value>\S.*)$", re.IGNORECASE)
#: A remote command that sets a setting: ``set <name> <value>``.
_SET_COMMAND = re.compile(r"^(?P<head>\s*set\s+(?P<name>\S+)\s+)(?P<value>\S.*)$", re.IGNORECASE)
_known: set[str] = set()
_known_pattern: re.Pattern[str] | None = None
_known_lock = threading.Lock()
class Secret:
"""A value that no log may keep, for example a private key or a password.
:meth:`~meshterm.tools.base.Tool.execute` stores the parameters of each run in the
``runs`` table, and logs them at debug level. A tool that has a secret that nothing
names (a key in a tuple) puts it in this wrapper. :func:`loggable` and each text form
of this object show :data:`SECRET_TEXT`, and the tool reads :attr:`value` (refer to
:func:`reveal`).
The class does not derive from ``str``, because ``json.dumps`` writes the value of a
``str`` subclass and never calls its ``default`` for it.
Attributes:
value: The secret itself.
"""
__slots__ = ("value",)
def __init__(self, value: str) -> None:
"""Keep ``value`` out of each text form of this object, and register it."""
self.value = value
remember(value)
def __repr__(self) -> str:
""":data:`SECRET_TEXT`, never the value."""
return SECRET_TEXT
__str__ = __repr__
def reveal(value: Any) -> Any:
"""The value inside a :class:`Secret`, or ``value`` itself if it is not a secret."""
return value.value if isinstance(value, Secret) else value
def names_secret(name: str) -> bool:
"""Whether ``name`` is the name of a secret (refer to :data:`SECRET_WORDS`).
Args:
name: A parameter name, a field name, a setting key, or a word of a text.
Returns:
``True`` when one of the words of ``name`` is a secret word.
"""
return not SECRET_WORDS.isdisjoint(_WORD_SPLIT.split(name.lower()))
def remember(value: Any) -> None:
"""Register a secret, so that :func:`hide` finds it in any text after this call.
A value of bytes registers as hex, the form in which a log writes bytes. A value that
is too short to find safely, or that is a secret word (a password of ``password``), is
not registered: each mention of the word in the log would become the mark. Each other
rule still hides such a value where it has a name.
Args:
value: The secret: text, a number (a PIN), or bytes.
"""
global _known_pattern
if value is None or isinstance(value, bool):
return
if isinstance(value, (bytes, bytearray, memoryview)):
text = bytes(value).hex()
else:
text = str(value).strip()
if len(text) < _MIN_REMEMBERED or text == SECRET_TEXT or text.lower() in SECRET_WORDS:
return
with _known_lock:
if text in _known:
return
_known.add(text)
_known_pattern = _compile_known(_known)
def hide(text: str) -> str:
"""Replace each secret in ``text`` with :data:`SECRET_TEXT`.
Args:
text: A log record, an error message, a remote command, or a URL.
Returns:
The text with each secret that a rule finds replaced.
"""
match = _PASSWORD_COMMAND.match(text)
if match is not None:
_remember_text(match.group("value"))
return match.group("head") + SECRET_TEXT
match = _SET_COMMAND.match(text)
if match is not None and names_secret(match.group("name")):
_remember_text(match.group("value"))
return match.group("head") + SECRET_TEXT
text = _ASSIGNMENT.sub(_hide_assignment, text)
known = _known_pattern
if known is not None:
text = known.sub(SECRET_TEXT, text)
return text
def without_secret(command: str) -> str:
"""A remote command with its secret value removed, for a history to recall.
The history of the remote command line keeps ``password `` for ``password hunter2``.
Thus ↑ recalls the start of the command, and the user types the new value again. If
the history kept :data:`SECRET_TEXT`, a recall and Enter would set the password to
that text.
Args:
command: A command as the user typed it.
Returns:
The command up to its secret value, or the command itself when it has none.
"""
for pattern in (_PASSWORD_COMMAND, _SET_COMMAND):
match = pattern.match(command)
if match is not None and (pattern is _PASSWORD_COMMAND or names_secret(match["name"])):
return match.group("head")
return command
def loggable(value: Any) -> Any:
"""A copy of ``value`` that a log can keep: each secret in it is :data:`SECRET_TEXT`.
The copy has the same shape: mappings stay mappings, and lists and tuples keep their
types. A dataclass becomes an object whose text form is the ``repr`` of the dataclass
with its secret fields hidden, so the log line looks as it did. Each text goes through
:func:`hide`. Each value that a rule hides is registered (:func:`remember`). Thus the
same value is also hidden when an error message repeats it later in the run.
Args:
value: The parameters or the summary of a run, or any part of them.
Returns:
The copy, ready for a log line or for ``json.dumps(..., default=str)``.
"""
if isinstance(value, Secret):
remember(value.value)
return SECRET_TEXT
if isinstance(value, (bytes, bytearray, memoryview)):
return _hidden(value)
if isinstance(value, str):
return hide(value)
if value is None or isinstance(value, (bool, int, float, Enum, PurePath, date, datetime)):
return value
if isinstance(value, Mapping):
return _loggable_mapping(value)
if is_dataclass(value) and not isinstance(value, type):
items = []
for f in fields(value):
item = getattr(value, f.name)
items.append((f.name, _hidden(item) if names_secret(f.name) else loggable(item)))
return _Shown(type(value).__name__, items)
if isinstance(value, (list, tuple, set, frozenset)):
items = _loggable_sequence(list(value))
return tuple(items) if isinstance(value, tuple) else items
return _Shown(hide(repr(value)))
class _Shown:
"""An object in a log, with its secrets hidden: its ``repr`` and its ``str`` are the same.
``json.dumps(..., default=str)`` writes an object as its ``str``, and a log line writes
a value in a dict as its ``repr``. Both forms of the original object can hold the
secret, so both forms of this object are the hidden form.
"""
__slots__ = ("_text",)
def __init__(self, name: str, items: list[tuple[str, Any]] | None = None) -> None:
"""Build the form ``Name(a=1, b=<secret>)``, or keep ``name`` as the whole text."""
if items is None:
self._text = name
else:
inner = ", ".join(f"{field}={_repr(value)}" for field, value in items)
self._text = f"{name}({inner})"
def __repr__(self) -> str:
"""The hidden form."""
return self._text
__str__ = __repr__
def _repr(value: Any) -> str:
"""The ``repr`` of a value in a hidden form, with :data:`SECRET_TEXT` not in quotes."""
return SECRET_TEXT if value == SECRET_TEXT else repr(value)
def _hidden(value: Any) -> Any:
""":data:`SECRET_TEXT` for a value that a name or a type makes secret, after it is registered.
An absent value (``None``, or an empty text) stays as it is. That a password was not
given is a fact that a maintainer can use, and it hides nothing.
"""
if value is None or value == "" or value == b"":
return value
remember(reveal(value))
return SECRET_TEXT
def _loggable_mapping(value: Mapping) -> dict:
""":func:`loggable` for a mapping: hide each value that its key or its record names secret."""
record_names = [value.get(name) for name in _RECORD_NAMES]
secret_record = any(isinstance(name, str) and names_secret(name) for name in record_names)
shown: dict = {}
for key, item in value.items():
named = names_secret(str(key)) or (secret_record and key in _RECORD_VALUES)
shown[key] = _hidden(item) if named else loggable(item)
return shown
def _loggable_sequence(items: list) -> list:
""":func:`loggable` for a sequence, and for an operation that sets a secret.
An operation that sets a value names the setting before the value:
``("set", "device_pin", "123456")``. Thus, in a sequence of three items that starts
with ``set`` and whose second item names a secret, the third item is the secret. No
other sequence has a value after a name. A list of setting names
(``["name", "device_pin", "radio_freq"]``) stays as it is.
"""
shown = [loggable(item) for item in items]
verb, name = (items[0], items[1]) if len(items) == 3 else (None, None)
if isinstance(verb, str) and verb.lower() == "set" and isinstance(name, str):
if names_secret(name):
shown[2] = _hidden(items[2])
return shown
def _hide_assignment(match: re.Match[str]) -> str:
"""Hide the value of ``name = value`` when the name is the name of a secret."""
value = match.group("value")
if not names_secret(match.group("name")) or value in (SECRET_TEXT, "None", "null"):
return match.group(0)
if value in ("''", '""', "b''", 'b""'):
return match.group(0)
_remember_text(value)
return match.group("name") + match.group("sep") + SECRET_TEXT
def _remember_text(value: str) -> None:
"""Register a value that a text rule found, without the quotes of a literal."""
text = value.strip()
if len(text) >= 2 and text[-1] in "'\"" and text.lstrip("b")[:1] == text[-1]:
text = text.lstrip("b")[1:-1]
remember(text)
def _compile_known(known: set[str]) -> re.Pattern[str]:
"""The pattern that finds each registered secret.
A long hex value matches also inside a longer run of hex, in any letter case, because a
library writes a whole frame as one run of hex. Each other value matches only as a
whole word, so a PIN of ``123456`` does not hide a part of ``71234567``.
"""
parts: list[str] = []
for text in sorted(known, key=len, reverse=True):
if len(text) >= _MIN_HEX_SUBSTRING and _HEX.fullmatch(text):
parts.append(f"(?i:{re.escape(text)})")
else:
parts.append(rf"(?<![\w]){re.escape(text)}(?![\w])")
return re.compile("|".join(parts))
__all__ = [
"SECRET_TEXT",
"SECRET_WORDS",
"Secret",
"hide",
"loggable",
"names_secret",
"remember",
"reveal",
"without_secret",
]