hannah-grpc-lib 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: hannah-grpc-lib
3
+ Version: 0.4.0
4
+ Summary: Shared gRPC client code for Hannah components (log shipping to the Hannah log collector)
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: grpcio>=1.84.0
8
+ Requires-Dist: hannah-proto>=4.5.0
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest>=8; extra == "test"
11
+
12
+ # hannah-grpc-lib (Python)
13
+
14
+ Shared gRPC client code for Hannah components. Until 0.3.1 this package was called
15
+ `hannah-logging` (import `hannah_logging`); that package gets no further updates.
16
+
17
+ ## Logging (`hannah_grpc.logging`)
18
+
19
+ Ships the logs of a Hannah component to the Hannah log collector, so a user can download
20
+ one archive of all components' logs from the WebUI.
21
+
22
+ The library adds one `logging.Handler`. Your existing handlers (stdout, journald, syslog)
23
+ keep working unchanged.
24
+
25
+ ## Installation
26
+
27
+ ```sh
28
+ pip install hannah-grpc-lib
29
+ ```
30
+
31
+ The library relies on the `hannah-proto` version the component already uses, because
32
+ every call to Hannah Core has to carry the matching protocol version.
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ import logging
38
+ import hannah_grpc.logging as hannah_logging # alias it: `from hannah_grpc import logging` would shadow the stdlib
39
+
40
+ logging.basicConfig(level=logging.INFO)
41
+
42
+ # As early as possible: everything logged from here on is buffered.
43
+ shipping = hannah_logging.install(
44
+ "telegram",
45
+ version=__version__,
46
+ secrets=[config.bot_token], # masked wherever they appear
47
+ logger_categories={"hannah.stt": hannah_logging.TRANSCRIPT},
48
+ )
49
+
50
+ # Once the config is loaded:
51
+ shipping.connect(
52
+ hannah_address="localhost:50051", # discovery via Hannah Core
53
+ collector_address=None, # optional static fallback
54
+ )
55
+ ```
56
+
57
+ ### Setting the collector address directly
58
+
59
+ A component that knows the collector address itself (Hannah Core, which announces it)
60
+ passes it in instead of subscribing to discovery:
61
+
62
+ ```python
63
+ shipping.set_collector_address("192.168.1.10:50061") # None withdraws it
64
+ ```
65
+
66
+ It starts shipping if `connect` hasn't been called yet, takes precedence over the static
67
+ `collector_address`, and a new address moves the stream.
68
+
69
+ ### Categories
70
+
71
+ Each line is tagged `GENERAL`, `TRANSCRIPT` (speech transcripts, user utterances) or
72
+ `METADATA` (room and device names, presence). Exports can leave out the last two, and
73
+ the WebUI does so by default.
74
+
75
+ - By logger name: `logger_categories={"hannah.stt": TRANSCRIPT}` also covers child
76
+ loggers such as `hannah.stt.whisper`.
77
+ - Per call: `log.info("heard: %s", text, extra={hannah_logging.CATEGORY_ATTR: hannah_logging.TRANSCRIPT})`
78
+
79
+ ### Secrets
80
+
81
+ Before a line leaves the process, the library masks:
82
+
83
+ - values of keys such as `password`, `token`, `api_key`, `secret`, `psk`
84
+ - `Bearer` tokens, JWTs, credentials in URLs (`mqtt://user:pw@host`), PEM private keys
85
+ - Telegram bot tokens and well-known token prefixes (`glpat-`, `ghp_`, …)
86
+ - every value passed as `secrets=[…]` or later through `shipping.add_secret(…)`
87
+
88
+ Extra regexes go in `secret_patterns=[…]`. The component's own handlers still see the
89
+ unmasked line.
90
+
91
+ ## Behaviour
92
+
93
+ - **Buffer:** 4 MiB by default (`max_buffer_bytes`). When it is full, the oldest lines are
94
+ dropped and reported to the collector as a gap. While no collector is known, the
95
+ buffer simply keeps running as a ring.
96
+ - **Timestamps** are taken when a line is logged, not when it is sent.
97
+ - **Instance name:** tells several instances of a component apart in the export. Without
98
+ `instance`, the library uses the `HANNAH_LOG_INSTANCE` environment variable, else the
99
+ hostname. In a container whose hostname is just its ID (a new one with every recreate),
100
+ it uses `container` instead. Running the same component in several containers? Set
101
+ `HANNAH_LOG_INSTANCE`, or `hostname:` in Compose.
102
+ - **Discovery:** subscribes to Hannah Core's infrastructure announcements and follows the
103
+ log collector when it moves. If Core is unreachable, the last known collector is kept.
104
+ The static `collector_address` is used while none is announced.
105
+ - **Never blocks the component:** sending runs on its own threads. Connection problems
106
+ are logged once to the `hannah_grpc.logging` logger (not shipped) and retried with backoff.
107
+ - **Shutdown:** at exit, the library tries for up to 2 seconds to send what is still
108
+ buffered (`shipping.close(timeout=…)`).
@@ -0,0 +1,97 @@
1
+ # hannah-grpc-lib (Python)
2
+
3
+ Shared gRPC client code for Hannah components. Until 0.3.1 this package was called
4
+ `hannah-logging` (import `hannah_logging`); that package gets no further updates.
5
+
6
+ ## Logging (`hannah_grpc.logging`)
7
+
8
+ Ships the logs of a Hannah component to the Hannah log collector, so a user can download
9
+ one archive of all components' logs from the WebUI.
10
+
11
+ The library adds one `logging.Handler`. Your existing handlers (stdout, journald, syslog)
12
+ keep working unchanged.
13
+
14
+ ## Installation
15
+
16
+ ```sh
17
+ pip install hannah-grpc-lib
18
+ ```
19
+
20
+ The library relies on the `hannah-proto` version the component already uses, because
21
+ every call to Hannah Core has to carry the matching protocol version.
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ import logging
27
+ import hannah_grpc.logging as hannah_logging # alias it: `from hannah_grpc import logging` would shadow the stdlib
28
+
29
+ logging.basicConfig(level=logging.INFO)
30
+
31
+ # As early as possible: everything logged from here on is buffered.
32
+ shipping = hannah_logging.install(
33
+ "telegram",
34
+ version=__version__,
35
+ secrets=[config.bot_token], # masked wherever they appear
36
+ logger_categories={"hannah.stt": hannah_logging.TRANSCRIPT},
37
+ )
38
+
39
+ # Once the config is loaded:
40
+ shipping.connect(
41
+ hannah_address="localhost:50051", # discovery via Hannah Core
42
+ collector_address=None, # optional static fallback
43
+ )
44
+ ```
45
+
46
+ ### Setting the collector address directly
47
+
48
+ A component that knows the collector address itself (Hannah Core, which announces it)
49
+ passes it in instead of subscribing to discovery:
50
+
51
+ ```python
52
+ shipping.set_collector_address("192.168.1.10:50061") # None withdraws it
53
+ ```
54
+
55
+ It starts shipping if `connect` hasn't been called yet, takes precedence over the static
56
+ `collector_address`, and a new address moves the stream.
57
+
58
+ ### Categories
59
+
60
+ Each line is tagged `GENERAL`, `TRANSCRIPT` (speech transcripts, user utterances) or
61
+ `METADATA` (room and device names, presence). Exports can leave out the last two, and
62
+ the WebUI does so by default.
63
+
64
+ - By logger name: `logger_categories={"hannah.stt": TRANSCRIPT}` also covers child
65
+ loggers such as `hannah.stt.whisper`.
66
+ - Per call: `log.info("heard: %s", text, extra={hannah_logging.CATEGORY_ATTR: hannah_logging.TRANSCRIPT})`
67
+
68
+ ### Secrets
69
+
70
+ Before a line leaves the process, the library masks:
71
+
72
+ - values of keys such as `password`, `token`, `api_key`, `secret`, `psk`
73
+ - `Bearer` tokens, JWTs, credentials in URLs (`mqtt://user:pw@host`), PEM private keys
74
+ - Telegram bot tokens and well-known token prefixes (`glpat-`, `ghp_`, …)
75
+ - every value passed as `secrets=[…]` or later through `shipping.add_secret(…)`
76
+
77
+ Extra regexes go in `secret_patterns=[…]`. The component's own handlers still see the
78
+ unmasked line.
79
+
80
+ ## Behaviour
81
+
82
+ - **Buffer:** 4 MiB by default (`max_buffer_bytes`). When it is full, the oldest lines are
83
+ dropped and reported to the collector as a gap. While no collector is known, the
84
+ buffer simply keeps running as a ring.
85
+ - **Timestamps** are taken when a line is logged, not when it is sent.
86
+ - **Instance name:** tells several instances of a component apart in the export. Without
87
+ `instance`, the library uses the `HANNAH_LOG_INSTANCE` environment variable, else the
88
+ hostname. In a container whose hostname is just its ID (a new one with every recreate),
89
+ it uses `container` instead. Running the same component in several containers? Set
90
+ `HANNAH_LOG_INSTANCE`, or `hostname:` in Compose.
91
+ - **Discovery:** subscribes to Hannah Core's infrastructure announcements and follows the
92
+ log collector when it moves. If Core is unreachable, the last known collector is kept.
93
+ The static `collector_address` is used while none is announced.
94
+ - **Never blocks the component:** sending runs on its own threads. Connection problems
95
+ are logged once to the `hannah_grpc.logging` logger (not shipped) and retried with backoff.
96
+ - **Shutdown:** at exit, the library tries for up to 2 seconds to send what is still
97
+ buffered (`shipping.close(timeout=…)`).
@@ -0,0 +1,7 @@
1
+ """Shared gRPC client code for Hannah components.
2
+
3
+ import hannah_grpc.logging as hannah_logging # ships the component's logs
4
+
5
+ Import the subpackages under an alias: `from hannah_grpc import logging` would shadow
6
+ the standard library's `logging` in your module.
7
+ """
@@ -0,0 +1,155 @@
1
+ """Ships the logs of a Hannah component to the Hannah log collector.
2
+
3
+ import hannah_grpc.logging as hannah_logging
4
+
5
+ shipping = hannah_logging.install("core", version=__version__) # as early as possible
6
+ ...
7
+ shipping.connect(hannah_address="localhost:50051") # once the config is loaded
8
+
9
+ Everything logged from `install` on is buffered in memory (bounded), and sent to the
10
+ collector as soon as one is known — announced by Hannah Core, or a static address.
11
+ The host's own handlers (stdout, journald, syslog) are not touched.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import atexit
16
+ import logging
17
+ import threading
18
+ from typing import Iterable, Mapping, Optional
19
+
20
+ from hannah_proto import logging_pb2
21
+
22
+ from .buffer import LogBuffer
23
+ from .handler import CATEGORY_ATTR, GENERAL, METADATA, TRANSCRIPT, CollectorHandler
24
+ from .instance import resolve_instance
25
+ from .secrets import SecretFilter
26
+ from .shipper import Discovery, Shipper
27
+
28
+ __all__ = [
29
+ "install", "LogShipping", "CollectorHandler", "SecretFilter",
30
+ "GENERAL", "TRANSCRIPT", "METADATA", "CATEGORY_ATTR",
31
+ ]
32
+
33
+ DEFAULT_MAX_BUFFER_BYTES = 4 * 1024 * 1024
34
+
35
+
36
+ class LogShipping:
37
+ """Handle returned by `install`. Owns the handler and the background threads."""
38
+
39
+ def __init__(
40
+ self,
41
+ component: str,
42
+ version: str,
43
+ instance: str,
44
+ handler: CollectorHandler,
45
+ buffer: LogBuffer,
46
+ secret_filter: SecretFilter,
47
+ target_logger: logging.Logger,
48
+ ):
49
+ self.handler = handler
50
+ self._hello = logging_pb2.ShipHello(component=component, instance=instance, version=version)
51
+ self._buffer = buffer
52
+ self._secret_filter = secret_filter
53
+ self._target_logger = target_logger
54
+ self._shipper: Optional[Shipper] = None
55
+ self._discovery: Optional[Discovery] = None
56
+ self._closed = False
57
+ self._lock = threading.Lock()
58
+
59
+ def connect(self, hannah_address: Optional[str] = None, collector_address: Optional[str] = None) -> None:
60
+ """Starts shipping. `hannah_address` enables discovery via Hannah Core;
61
+ `collector_address` is the fallback while none is announced. Only the
62
+ first call has an effect."""
63
+ with self._lock:
64
+ if self._shipper is not None or self._closed:
65
+ return
66
+ self._shipper = Shipper(self._buffer, self._hello, collector_address or None)
67
+ self._shipper.start()
68
+ if hannah_address:
69
+ self._discovery = Discovery(hannah_address, self._shipper.set_discovered)
70
+ self._discovery.start()
71
+
72
+ def set_collector_address(self, address: Optional[str]) -> None:
73
+ """Sets the collector address directly, for a component that knows it without
74
+ discovery (Hannah Core itself). Treated like an address announced by Core: it
75
+ takes precedence over the static fallback, and a change moves the stream.
76
+ None withdraws it. Starts shipping if `connect` hasn't been called yet."""
77
+ self.connect()
78
+ with self._lock:
79
+ shipper = self._shipper
80
+ if shipper is not None:
81
+ shipper.set_discovered(address or None)
82
+
83
+ def add_secret(self, secret: str) -> None:
84
+ """Masks this literal value in everything logged from now on (e.g. a token
85
+ that only becomes known after `install`)."""
86
+ self._secret_filter.add_literal(secret)
87
+
88
+ def close(self, timeout: float = 2.0) -> None:
89
+ """Detaches the handler and gives the shipper up to `timeout` seconds to
90
+ send what is still buffered."""
91
+ self._target_logger.removeHandler(self.handler)
92
+ with self._lock:
93
+ discovery, shipper = self._discovery, self._shipper
94
+ self._discovery = self._shipper = None
95
+ self._closed = True
96
+ if discovery is not None:
97
+ discovery.stop()
98
+ if shipper is not None:
99
+ shipper.stop()
100
+ shipper.join(timeout)
101
+
102
+ @property
103
+ def collector_address(self) -> Optional[str]:
104
+ """The collector address currently in use — announced by Hannah Core, else the
105
+ static fallback. None while none is known or before `connect`."""
106
+ with self._lock:
107
+ shipper = self._shipper
108
+ return shipper.target() if shipper is not None else None
109
+
110
+
111
+ def install(
112
+ component: str,
113
+ *,
114
+ version: str = "",
115
+ instance: Optional[str] = None,
116
+ hannah_address: Optional[str] = None,
117
+ collector_address: Optional[str] = None,
118
+ secrets: Iterable[str] = (),
119
+ secret_patterns: Iterable[str] = (),
120
+ logger_categories: Optional[Mapping[str, int]] = None,
121
+ level: int = logging.NOTSET,
122
+ max_buffer_bytes: int = DEFAULT_MAX_BUFFER_BYTES,
123
+ logger: Optional[logging.Logger] = None,
124
+ ) -> LogShipping:
125
+ """Attaches a CollectorHandler to `logger` (default: the root logger) and starts
126
+ buffering. Pass `hannah_address`/`collector_address` here or later via
127
+ `LogShipping.connect`.
128
+
129
+ component e.g. "core", "telegram" — the name shown in the export
130
+ instance distinguishes several instances; defaults to $HANNAH_LOG_INSTANCE,
131
+ else the hostname ("container" if that is just a container ID)
132
+ secrets literal values to mask (the component's own tokens/passwords)
133
+ secret_patterns extra regexes to mask, on top of the built-in ones
134
+ logger_categories logger name prefix -> category, e.g. {"hannah.stt": TRANSCRIPT};
135
+ a single call can override it with extra={CATEGORY_ATTR: ...}
136
+ """
137
+ buffer = LogBuffer(max_buffer_bytes)
138
+ secret_filter = SecretFilter(secrets, secret_patterns)
139
+ handler = CollectorHandler(buffer, secret_filter, logger_categories, level)
140
+ target_logger = logger or logging.getLogger()
141
+ target_logger.addHandler(handler)
142
+
143
+ shipping = LogShipping(
144
+ component=component,
145
+ version=version,
146
+ instance=resolve_instance(instance),
147
+ handler=handler,
148
+ buffer=buffer,
149
+ secret_filter=secret_filter,
150
+ target_logger=target_logger,
151
+ )
152
+ atexit.register(shipping.close)
153
+ if hannah_address or collector_address:
154
+ shipping.connect(hannah_address, collector_address)
155
+ return shipping
@@ -0,0 +1,84 @@
1
+ """Bounded in-memory buffer between the logging handler and the shipper thread."""
2
+ from __future__ import annotations
3
+
4
+ import collections
5
+ import threading
6
+ from dataclasses import dataclass
7
+ from typing import Deque, List, Optional, Tuple
8
+
9
+ # Rough per-entry overhead on top of message + logger name, so a flood of empty
10
+ # messages still counts against the limit.
11
+ _ENTRY_OVERHEAD = 64
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Entry:
16
+ timestamp_ms: int
17
+ level: int # hannah_proto LogLevel value
18
+ logger: str
19
+ message: str
20
+ category: int # hannah_proto LogCategory value
21
+
22
+ def size(self) -> int:
23
+ return len(self.message) + len(self.logger) + _ENTRY_OVERHEAD
24
+
25
+
26
+ @dataclass
27
+ class Gap:
28
+ dropped: int
29
+ from_ms: int
30
+ to_ms: int
31
+
32
+
33
+ class LogBuffer:
34
+ """Ring buffer limited by size. When full, the oldest entries are dropped and
35
+ recorded as a gap, which is shipped before the remaining entries.
36
+
37
+ `put` never blocks the logging thread beyond a short lock."""
38
+
39
+ def __init__(self, max_bytes: int):
40
+ self._max_bytes = max_bytes
41
+ self._entries: Deque[Entry] = collections.deque()
42
+ self._bytes = 0
43
+ self._gap: Optional[Gap] = None
44
+ self._cond = threading.Condition()
45
+
46
+ def put(self, entry: Entry) -> None:
47
+ with self._cond:
48
+ self._entries.append(entry)
49
+ self._bytes += entry.size()
50
+ while self._bytes > self._max_bytes and self._entries:
51
+ self._drop_oldest()
52
+ self._cond.notify()
53
+
54
+ def _drop_oldest(self) -> None:
55
+ old = self._entries.popleft()
56
+ self._bytes -= old.size()
57
+ if self._gap is None:
58
+ self._gap = Gap(dropped=1, from_ms=old.timestamp_ms, to_ms=old.timestamp_ms)
59
+ else:
60
+ self._gap.dropped += 1
61
+ self._gap.to_ms = max(self._gap.to_ms, old.timestamp_ms)
62
+
63
+ def take(self, max_items: int, timeout: Optional[float]) -> Tuple[Optional[Gap], List[Entry]]:
64
+ """Waits up to `timeout` for data, then removes and returns the pending gap
65
+ (if any) and up to `max_items` entries, oldest first."""
66
+ with self._cond:
67
+ if not self._entries and self._gap is None:
68
+ self._cond.wait(timeout)
69
+ gap, self._gap = self._gap, None
70
+ items: List[Entry] = []
71
+ while self._entries and len(items) < max_items:
72
+ entry = self._entries.popleft()
73
+ self._bytes -= entry.size()
74
+ items.append(entry)
75
+ return gap, items
76
+
77
+ def wake(self) -> None:
78
+ """Wakes a waiting `take`, e.g. to react to a new collector address or shutdown."""
79
+ with self._cond:
80
+ self._cond.notify_all()
81
+
82
+ def __len__(self) -> int:
83
+ with self._cond:
84
+ return len(self._entries)
@@ -0,0 +1,75 @@
1
+ """logging.Handler that feeds the buffer. Runs in addition to the host's own handlers."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Mapping, Optional
6
+
7
+ from hannah_proto import logging_pb2
8
+
9
+ from .buffer import Entry, LogBuffer
10
+ from .secrets import SecretFilter
11
+ from .shipper import OWN_THREADS
12
+
13
+ GENERAL = logging_pb2.LOG_CATEGORY_GENERAL
14
+ TRANSCRIPT = logging_pb2.LOG_CATEGORY_TRANSCRIPT
15
+ METADATA = logging_pb2.LOG_CATEGORY_METADATA
16
+
17
+ # Name of the `extra=` key that tags a single log call, e.g.
18
+ # log.info("heard: %s", text, extra={CATEGORY_ATTR: TRANSCRIPT})
19
+ CATEGORY_ATTR = "log_category"
20
+
21
+
22
+ def to_log_level(levelno: int) -> int:
23
+ if levelno >= logging.CRITICAL:
24
+ return logging_pb2.LOG_LEVEL_CRITICAL
25
+ if levelno >= logging.ERROR:
26
+ return logging_pb2.LOG_LEVEL_ERROR
27
+ if levelno >= logging.WARNING:
28
+ return logging_pb2.LOG_LEVEL_WARNING
29
+ if levelno >= logging.INFO:
30
+ return logging_pb2.LOG_LEVEL_INFO
31
+ return logging_pb2.LOG_LEVEL_DEBUG
32
+
33
+
34
+ class CollectorHandler(logging.Handler):
35
+ """Formats, filters and buffers each record. The timestamp is the record's
36
+ creation time, so entries buffered before the collector was reachable keep
37
+ their original time."""
38
+
39
+ def __init__(
40
+ self,
41
+ buffer: LogBuffer,
42
+ secret_filter: SecretFilter,
43
+ logger_categories: Optional[Mapping[str, int]] = None,
44
+ level: int = logging.NOTSET,
45
+ ):
46
+ super().__init__(level)
47
+ self._buffer = buffer
48
+ self._secret_filter = secret_filter
49
+ # Longest prefix first, so "hannah.stt.transcript" wins over "hannah.stt".
50
+ self._logger_categories = sorted((logger_categories or {}).items(), key=lambda kv: len(kv[0]), reverse=True)
51
+ self.setFormatter(logging.Formatter("%(message)s"))
52
+
53
+ def emit(self, record: logging.LogRecord) -> None:
54
+ if record.thread in OWN_THREADS:
55
+ return
56
+ try:
57
+ message = self._secret_filter(self.format(record))
58
+ self._buffer.put(Entry(
59
+ timestamp_ms=int(record.created * 1000),
60
+ level=to_log_level(record.levelno),
61
+ logger=record.name,
62
+ message=message,
63
+ category=self._category(record),
64
+ ))
65
+ except Exception:
66
+ self.handleError(record)
67
+
68
+ def _category(self, record: logging.LogRecord) -> int:
69
+ explicit = getattr(record, CATEGORY_ATTR, None)
70
+ if explicit:
71
+ return int(explicit)
72
+ for prefix, category in self._logger_categories:
73
+ if record.name == prefix or record.name.startswith(prefix + "."):
74
+ return category
75
+ return GENERAL
@@ -0,0 +1,33 @@
1
+ """Resolves the instance name a component reports to the collector."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import socket
7
+ from typing import Optional
8
+
9
+ INSTANCE_ENV = "HANNAH_LOG_INSTANCE"
10
+ CONTAINER_INSTANCE = "container"
11
+
12
+ # Docker and Podman drop one of these files into every container.
13
+ _CONTAINER_MARKERS = ("/.dockerenv", "/run/.containerenv")
14
+ # Without an explicit hostname, the container gets its short (12) or full (64) ID.
15
+ _CONTAINER_ID = re.compile(r"[0-9a-f]{12}|[0-9a-f]{64}")
16
+
17
+
18
+ def resolve_instance(explicit: Optional[str] = None) -> str:
19
+ """The explicit value, else $HANNAH_LOG_INSTANCE, else "container" when the hostname
20
+ is just a container ID (it changes with every recreate), else the hostname."""
21
+ if explicit:
22
+ return explicit
23
+ from_env = os.environ.get(INSTANCE_ENV, "").strip()
24
+ if from_env:
25
+ return from_env
26
+ host = socket.gethostname()
27
+ if _CONTAINER_ID.fullmatch(host) and _in_container():
28
+ return CONTAINER_INSTANCE
29
+ return host
30
+
31
+
32
+ def _in_container() -> bool:
33
+ return any(os.path.exists(p) for p in _CONTAINER_MARKERS)
@@ -0,0 +1,69 @@
1
+ """Masks secrets in a log message before it leaves the process."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Iterable, List, Pattern, Tuple
6
+
7
+ MASK = "***"
8
+
9
+ # Key names whose value is treated as secret in "key=value", "key: value" and JSON-ish "key": "value".
10
+ _SECRET_KEYS = (
11
+ r"password|passwd|pwd|passphrase|secret|client[_-]?secret|token|access[_-]?token|refresh[_-]?token"
12
+ r"|api[_-]?key|apikey|access[_-]?key|private[_-]?key|psk|auth|authorization|credentials?"
13
+ )
14
+
15
+ _BUILTIN_PATTERNS: List[Tuple[Pattern[str], str]] = [
16
+ # PEM blocks (private keys, certificates with keys).
17
+ (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), MASK),
18
+ # "Authorization: Bearer abc", "Bearer abc"
19
+ (re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+"), r"\1 " + MASK),
20
+ # key=value / key: value / "key": "value" — the key has to *end* in a secret word,
21
+ # so "bot_token=…" is masked but "tokens=512" or "token_count=3" is not.
22
+ (
23
+ re.compile(
24
+ r"""(?ix)
25
+ (?P<key>["']?[\w.-]*(?:""" + _SECRET_KEYS + r""")["']?)
26
+ (?P<sep>\s*[:=]\s*)
27
+ (?P<value>"[^"]*"|'[^']*'|[^\s,;&)}\]]+)
28
+ """
29
+ ),
30
+ lambda m: m.group("key") + m.group("sep") + _quoted_mask(m.group("value")),
31
+ ),
32
+ # Credentials in URLs: scheme://user:password@host
33
+ (re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://[^/\s:@]+):[^/\s@]+@"), r"\1:" + MASK + "@"),
34
+ # JWTs
35
+ (re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), MASK),
36
+ # Telegram bot tokens
37
+ (re.compile(r"\b\d{8,10}:[A-Za-z0-9_-]{35}\b"), MASK),
38
+ # Well-known token prefixes (GitLab, GitHub, npm, PyPI, Slack, OpenAI-style)
39
+ (re.compile(r"\b(?:glpat|gldt|glrt|ghp|gho|ghu|ghs|ghr|github_pat|npm|pypi|xox[abpr]|sk)[-_][A-Za-z0-9_-]{16,}"), MASK),
40
+ ]
41
+
42
+
43
+ def _quoted_mask(value: str) -> str:
44
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
45
+ return value[0] + MASK + value[0]
46
+ return MASK
47
+
48
+
49
+ class SecretFilter:
50
+ """Masks well-known secret shapes plus any literal values the component knows are secret
51
+ (its own tokens and passwords from its config — the most reliable way to catch them)."""
52
+
53
+ def __init__(self, literals: Iterable[str] = (), patterns: Iterable[str] = ()):
54
+ # Longest first, so a secret that contains another one is masked as a whole.
55
+ self._literals = sorted({s for s in literals if s and len(s) >= 4}, key=len, reverse=True)
56
+ self._patterns = list(_BUILTIN_PATTERNS) + [(re.compile(p), MASK) for p in patterns]
57
+
58
+ def add_literal(self, secret: str) -> None:
59
+ if secret and len(secret) >= 4 and secret not in self._literals:
60
+ self._literals.append(secret)
61
+ self._literals.sort(key=len, reverse=True)
62
+
63
+ def __call__(self, message: str) -> str:
64
+ for literal in self._literals:
65
+ if literal in message:
66
+ message = message.replace(literal, MASK)
67
+ for pattern, replacement in self._patterns:
68
+ message = pattern.sub(replacement, message)
69
+ return message