nitlsconfig 1.0.0__py3-none-any.whl

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,99 @@
1
+ """Python package to read settings from nitlsconfig and build NI gRPC Device channels from them.
2
+
3
+ The gRPC names below are resolved lazily: importing this package never imports
4
+ grpcio, so a caller that only reads NI TLS configuration does not pay for a
5
+ binary dependency it will not use. See the project README for install options.
6
+ """
7
+
8
+ from importlib.metadata import version
9
+ from importlib.util import find_spec
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from nitlsconfig.audit import audit_session_connect
13
+ from nitlsconfig.cli import (
14
+ CertificateLocation,
15
+ ClientCertMode,
16
+ ClientConfig,
17
+ ClientServerMode,
18
+ LocationScheme,
19
+ ServerCertMode,
20
+ ServerClientMode,
21
+ ServerConfig,
22
+ TrustedCertificateData,
23
+ KnownServerData,
24
+ )
25
+ from nitlsconfig.errors import (
26
+ CommandFailedError,
27
+ CommandTimeoutError,
28
+ ExecutableNotFoundError,
29
+ InvalidOutputError,
30
+ NitlsconfigCliError,
31
+ NitlsconfigError,
32
+ TlsConfigurationError,
33
+ get_tls_connection_error_elaboration,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ # Imported eagerly for type checkers and editors, which do not run __getattr__.
38
+ from nitlsconfig.grpc_channel import (
39
+ RetryPolicy,
40
+ create_grpc_device_channel,
41
+ )
42
+
43
+ __version__ = version("nitlsconfig")
44
+
45
+ # Names re-exported from nitlsconfig.grpc_channel, which requires grpcio.
46
+ # A plain list literal, because pyright only tracks __all__ through a small set
47
+ # of literal forms; anything computed makes it give up on the export list.
48
+ _GRPC_EXPORTS = [
49
+ "RetryPolicy",
50
+ "create_grpc_device_channel",
51
+ ]
52
+
53
+ __all__ = [
54
+ "__version__",
55
+ "audit_session_connect",
56
+ "CertificateLocation",
57
+ "ClientCertMode",
58
+ "ClientConfig",
59
+ "ClientServerMode",
60
+ "LocationScheme",
61
+ "ServerCertMode",
62
+ "ServerClientMode",
63
+ "ServerConfig",
64
+ "NitlsconfigError",
65
+ "NitlsconfigCliError",
66
+ "ExecutableNotFoundError",
67
+ "CommandFailedError",
68
+ "CommandTimeoutError",
69
+ "InvalidOutputError",
70
+ "TlsConfigurationError",
71
+ "TrustedCertificateData",
72
+ "KnownServerData",
73
+ "get_tls_connection_error_elaboration",
74
+ ]
75
+
76
+ # The gRPC names are public API, but only on an install that can supply them.
77
+ # Listing them unconditionally would make `from nitlsconfig import *` raise
78
+ # ImportError without the grpc extra, since star-import resolves every name in
79
+ # __all__. find_spec only locates grpcio; it does not import it, so the lazy
80
+ # __getattr__ below still decides when grpcio is actually loaded.
81
+ if find_spec("grpc") is not None:
82
+ # pyright only tracks __all__ through inline literals, so it cannot follow
83
+ # this and warns that the export list may be incomplete. The TYPE_CHECKING
84
+ # block above already declares these names for static consumers.
85
+ __all__ += _GRPC_EXPORTS # pyright: ignore[reportUnsupportedDunderAll]
86
+
87
+
88
+ def __getattr__(name: str) -> Any:
89
+ """Resolve gRPC exports on first use, so importing this package does not need grpcio."""
90
+ if name in _GRPC_EXPORTS:
91
+ try:
92
+ from nitlsconfig import grpc_channel
93
+ except ImportError as exc: # pragma: no cover - requires an install without the extra
94
+ raise ImportError(
95
+ f"nitlsconfig.{name} requires grpcio, which is not installed. "
96
+ "Install it with: pip install nitlsconfig[grpc]"
97
+ ) from exc
98
+ return getattr(grpc_channel, name)
99
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,12 @@
1
+ """The NI TLS services this package builds transports for.
2
+
3
+ Only the NI gRPC Device Server is supported today. Anything else can still be
4
+ read through :class:`~nitlsconfig.cli.ClientConfig`, but has no channel factory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # The NI TLS registered service name for the NI gRPC Device Server: the file stem of
10
+ # ni-grpc-device.client.caps.yml, the Event Log source, and the record tag are all this
11
+ # one name, so records can be tied back to the configuration they describe.
12
+ SERVICE_NAME = "ni-grpc-device"
nitlsconfig/audit.py ADDED
@@ -0,0 +1,279 @@
1
+ """Audit logging for NI TLS client transports.
2
+
3
+ Records the security posture of transports this package creates, and the outcome of a
4
+ driver's gRPC session initialize RPC, to the platform audit log: the Windows Event Log
5
+ on Windows, syslog on Linux.
6
+
7
+ Transport posture is recorded by the channel factory. The session connect outcome
8
+ cannot be, because a gRPC channel connects lazily, so the driver API layer that issues
9
+ the initialize RPC calls :func:`audit_session_connect` itself.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import logging
16
+ import sys
17
+ import threading
18
+ from enum import Enum
19
+ from typing import Any
20
+
21
+ from nitlsconfig._service import SERVICE_NAME
22
+ from nitlsconfig.channel_tag import get_channel_target
23
+
24
+ # A record is emitted only for something that attests to the security posture of a
25
+ # connection that actually existed. Configuration errors are not audited, because no
26
+ # channel is created and nothing is transmitted; TlsConfigurationError carries that
27
+ # detail to the caller directly. We also do not record *which* server we authenticated,
28
+ # for example its certificate subject: gRPC's Python client API takes certificates as
29
+ # input only, offering no handshake callback and no way to read the server's certificate
30
+ # afterward.
31
+ #
32
+ # Auditing covers the NI gRPC Device Server only, so the service name is fixed
33
+ # package-wide rather than accepted from callers. Should another service ever need audit
34
+ # records, this module grows a service parameter again at that point.
35
+ #
36
+ # Records report what this package observed, assuming the hosting process is not hostile.
37
+ # Nothing here can defend against code in the same process, which can call the standard
38
+ # library logger directly. Untrusted *values* reaching a record are bounded and escaped
39
+ # below, because those do cross a trust boundary.
40
+
41
+ _ROLE = "Client"
42
+ # This source uses mscoree.dll as its EventMessageFile. Event ID 1000 selects its
43
+ # generic literal-message template, which renders the complete first insertion
44
+ # string as the event description. Other IDs can produce Event Viewer's
45
+ # "message was not found in the message table" fallback instead.
46
+ _WINDOWS_EVENT_ID = 1000
47
+
48
+ # Match spdlog's Windows Event Log categories, which use its level enum values:
49
+ # trace=0, debug=1, info=2, warn=3, err=4, critical=5.
50
+ _WINDOWS_EVENT_CATEGORIES = {
51
+ logging.DEBUG: 1,
52
+ logging.INFO: 2,
53
+ logging.WARNING: 3,
54
+ logging.ERROR: 4,
55
+ logging.CRITICAL: 5,
56
+ }
57
+
58
+
59
+ class _TransportSecurity(Enum):
60
+ """Security posture of a created transport."""
61
+
62
+ Unencrypted = "unencrypted"
63
+ ServerAuthenticatedTls = "server_authenticated_tls"
64
+ MutualTls = "mutual_tls"
65
+
66
+
67
+ # Whether the audit logger has had its logging handler attached. The lock guards
68
+ # the check-then-set below: without it, threads creating channels can each attach
69
+ # a logging handler and double every record.
70
+ _logging_handler_lock = threading.Lock()
71
+ _logging_handler_attached = False
72
+
73
+ _MAX_FIELD_LENGTH = 256
74
+
75
+ # The quote is here because messages wrap every field in single quotes; without it
76
+ # a value can close the quote and append text that reads as part of our message.
77
+ _ESCAPES = {"\\": "\\\\", "'": "\\'", "\r": "\\r", "\n": "\\n"}
78
+
79
+
80
+ class _WindowsEventLogHandler(logging.Handler):
81
+ """Write to a pre-registered Windows Event Log source."""
82
+
83
+ def __init__(self, source_name: str) -> None:
84
+ super().__init__()
85
+ self._source_name = source_name
86
+ self._event_log: Any = importlib.import_module("win32evtlog")
87
+ self._user_sid = self._get_current_user_sid()
88
+ self._event_types = {
89
+ logging.DEBUG: self._event_log.EVENTLOG_INFORMATION_TYPE,
90
+ logging.INFO: self._event_log.EVENTLOG_INFORMATION_TYPE,
91
+ logging.WARNING: self._event_log.EVENTLOG_WARNING_TYPE,
92
+ logging.ERROR: self._event_log.EVENTLOG_ERROR_TYPE,
93
+ logging.CRITICAL: self._event_log.EVENTLOG_ERROR_TYPE,
94
+ }
95
+
96
+ @staticmethod
97
+ def _get_current_user_sid() -> Any:
98
+ """Return the current process token's user SID, or None if unavailable."""
99
+ try:
100
+ win32api = importlib.import_module("win32api")
101
+ win32con = importlib.import_module("win32con")
102
+ win32security = importlib.import_module("win32security")
103
+ token = win32security.OpenProcessToken(
104
+ win32api.GetCurrentProcess(), win32con.TOKEN_QUERY
105
+ )
106
+ try:
107
+ user_sid, _ = win32security.GetTokenInformation(token, win32security.TokenUser)
108
+ return user_sid
109
+ finally:
110
+ token.Close()
111
+ except Exception:
112
+ # A missing SID must not prevent the audit event itself from being recorded.
113
+ return None
114
+
115
+ def emit(self, record: logging.LogRecord) -> None:
116
+ """Emit one record without creating or changing Event Log registry keys."""
117
+ event_source = None
118
+ try:
119
+ event_source = self._event_log.RegisterEventSource(None, self._source_name)
120
+ event_type = self._event_types.get(record.levelno, self._event_log.EVENTLOG_ERROR_TYPE)
121
+ event_category = _WINDOWS_EVENT_CATEGORIES.get(
122
+ record.levelno,
123
+ 4, # Unknown/custom Python levels default to spdlog's error category.
124
+ )
125
+ self._event_log.ReportEvent(
126
+ event_source,
127
+ event_type,
128
+ event_category,
129
+ _WINDOWS_EVENT_ID,
130
+ self._user_sid,
131
+ [self.format(record)],
132
+ None,
133
+ )
134
+ finally:
135
+ if event_source is not None:
136
+ self._event_log.DeregisterEventSource(event_source)
137
+
138
+
139
+ def _audit_field(value: object) -> str:
140
+ """Return a bounded audit field with record-breaking characters escaped.
141
+
142
+ Escaping runs before the bound, so the returned length is the real limit;
143
+ escaping afterwards could double it.
144
+
145
+ Remaining C0 controls and DEL become hex escapes: ESC would otherwise emit
146
+ terminal control sequences when a syslog file is read, and NUL can truncate
147
+ the record as it crosses into the platform logging API.
148
+ """
149
+ escaped = "".join(
150
+ _ESCAPES[ch] if ch in _ESCAPES else (ch if " " <= ch != "\x7f" else f"\\x{ord(ch):02x}")
151
+ for ch in str(value)
152
+ )
153
+ if len(escaped) <= _MAX_FIELD_LENGTH:
154
+ return escaped
155
+
156
+ truncated = escaped[: _MAX_FIELD_LENGTH - 3]
157
+ # Cutting mid-escape would leave a trailing backslash that escapes the quote
158
+ # the message puts after this field.
159
+ if (len(truncated) - len(truncated.rstrip("\\"))) % 2:
160
+ truncated = truncated[:-1]
161
+ return truncated + "..."
162
+
163
+
164
+ def _make_logging_handler() -> logging.Handler:
165
+ """Create the platform audit logging handler.
166
+
167
+ Windows uses the Windows Event Log and Linux uses syslog. Audit logging is
168
+ intentionally disabled on every other platform.
169
+
170
+ Falls back to a null logging handler when the platform log is unreachable.
171
+ That keeps audit records out of stderr, which ``logging`` would otherwise
172
+ fall back to for a logger that has no logging handler of its own.
173
+ """
174
+ try:
175
+ # "win32" is the value on every Windows build, 64-bit included; there is no "win64".
176
+ if sys.platform == "win32":
177
+ return _WindowsEventLogHandler(SERVICE_NAME)
178
+
179
+ if sys.platform.startswith("linux"):
180
+ from logging.handlers import SysLogHandler
181
+
182
+ return SysLogHandler(address="/dev/log", facility=SysLogHandler.LOG_DAEMON)
183
+
184
+ return logging.NullHandler()
185
+ except Exception:
186
+ # The platform logging handler failed, not `logging` itself; this diagnostic
187
+ # goes to the host application's ordinary logger, never to the audit channel.
188
+ logging.getLogger(__name__).warning(
189
+ "NI TLS audit logging is unavailable on this system; audit events "
190
+ "will not be recorded.",
191
+ exc_info=True,
192
+ )
193
+ return logging.NullHandler()
194
+
195
+
196
+ def _get_audit_logger() -> logging.Logger:
197
+ """Return the audit logger, attaching its logging handler on first use.
198
+
199
+ Tracks setup ourselves rather than inspecting ``logger.handlers``, since
200
+ anything else in the process may attach logging handlers to the same logger
201
+ and would otherwise make an unconfigured logger look ready.
202
+ """
203
+ global _logging_handler_attached
204
+
205
+ logger = logging.getLogger(f"nitlsconfig.audit.{SERVICE_NAME}.{_ROLE}")
206
+
207
+ with _logging_handler_lock:
208
+ if not _logging_handler_attached:
209
+ logger.setLevel(logging.INFO)
210
+ # Audit records belong in the platform audit log, not in whatever
211
+ # logging the host application has configured on the root logger.
212
+ logger.propagate = False
213
+
214
+ handler = _make_logging_handler()
215
+ # Record pattern: [<service>][<role>] <message>
216
+ handler.setFormatter(logging.Formatter(f"[{SERVICE_NAME}][{_ROLE}] %(message)s"))
217
+ logger.addHandler(handler)
218
+ _logging_handler_attached = True
219
+
220
+ return logger
221
+
222
+
223
+ def _audit_transport_posture(peer_host: str, security: _TransportSecurity) -> None:
224
+ """Record the security posture of a client transport.
225
+
226
+ Never raises: auditing must not disrupt transport creation.
227
+ """
228
+ try:
229
+ peer_host = _audit_field(peer_host)
230
+ message = f"Client transport for service '{SERVICE_NAME}'"
231
+ if peer_host:
232
+ message += f" to '{peer_host}'"
233
+
234
+ if security is _TransportSecurity.Unencrypted:
235
+ message += " is unencrypted (TLS disabled)."
236
+ elif security is _TransportSecurity.ServerAuthenticatedTls:
237
+ message += " uses one-way TLS. Not presenting a client certificate."
238
+ else:
239
+ message += " uses mutual TLS. Presenting a client certificate."
240
+
241
+ logger = _get_audit_logger()
242
+ # Mutual TLS is the secure baseline; weaker postures are auditable warnings.
243
+ if security is _TransportSecurity.MutualTls:
244
+ logger.info(message)
245
+ else:
246
+ logger.warning(message)
247
+ except Exception:
248
+ logging.getLogger(__name__).debug("Unable to record transport audit event.", exc_info=True)
249
+
250
+
251
+ def audit_session_connect(driver_name: str, channel: object, connected: bool) -> None:
252
+ """Record the outcome of a driver's gRPC session initialize RPC. Never raises.
253
+
254
+ Call this from the API layer once the initialize RPC returns, passing the
255
+ driver's logging name (``NI-DCPower``, and so on).
256
+
257
+ Channels this package did not create are ignored, so drivers can call this
258
+ unconditionally. A caller who built their own channel never went through
259
+ NI TLS, so there is no transport posture record to pair the outcome with and
260
+ nothing to attest to.
261
+ """
262
+ try:
263
+ target = get_channel_target(channel)
264
+ if not target:
265
+ return
266
+
267
+ driver_name = _audit_field(driver_name)
268
+ target = _audit_field(target)
269
+
270
+ outcome = "connected" if connected else "failed to connect"
271
+ message = f"{driver_name} gRPC session {outcome} on hostname '{target}'"
272
+
273
+ logger = _get_audit_logger()
274
+ if connected:
275
+ logger.info(message)
276
+ else:
277
+ logger.error(message)
278
+ except Exception:
279
+ logging.getLogger(__name__).debug("Unable to record session audit event.", exc_info=True)
@@ -0,0 +1,39 @@
1
+ """Marks NI gRPC Device channels this package created.
2
+
3
+ A caller holding only a channel cannot tell whether NI TLS had any part in
4
+ building it, so the channel factory tags what it creates. Two features read the
5
+ tag: audit records name the address, and connection-error elaboration speaks only
6
+ for channels we built.
7
+
8
+ The tag is advisory, not a security control. It says where a channel came from,
9
+ never that a connection is trustworthy.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ _TARGET_ATTR = "_nitls_channel_target"
17
+
18
+
19
+ def tag_channel_target(channel: object, target: str) -> None:
20
+ """Record on a channel the ``host:port`` it was created for. Never raises."""
21
+ try:
22
+ setattr(channel, _TARGET_ATTR, target)
23
+ except Exception:
24
+ logging.getLogger(__name__).debug(
25
+ "Unable to tag channel; it will go unaudited and will not elaborate on "
26
+ "connection errors.",
27
+ exc_info=True,
28
+ )
29
+
30
+
31
+ def get_channel_target(channel: object) -> str:
32
+ """Return the ``host:port`` a channel was created for, or empty if we did not create it."""
33
+ target = getattr(channel, _TARGET_ATTR, "")
34
+ return target if isinstance(target, str) else ""
35
+
36
+
37
+ def is_nitls_channel(channel: object) -> bool:
38
+ """Return whether this package created the channel."""
39
+ return bool(get_channel_target(channel))