besapi 4.3.1__tar.gz → 4.4.1__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.
- {besapi-4.3.1/src/besapi.egg-info → besapi-4.4.1}/PKG-INFO +1 -1
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi/besapi.py +1 -1
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi/plugin_utilities.py +244 -20
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi/plugin_utilities_linux.py +111 -20
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi/plugin_utilities_win.py +43 -0
- {besapi-4.3.1 → besapi-4.4.1/src/besapi.egg-info}/PKG-INFO +1 -1
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi.egg-info/SOURCES.txt +2 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_example_mqtt_homeassistant.py +100 -2
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_plugin_utilities_linux.py +91 -0
- besapi-4.4.1/tests/test_plugin_utilities_secrets.py +374 -0
- besapi-4.4.1/tests/test_plugin_utilities_win_fake.py +76 -0
- {besapi-4.3.1 → besapi-4.4.1}/LICENSE.txt +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/MANIFEST.in +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/README.md +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/pyproject.toml +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/setup.cfg +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/setup.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi/__init__.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi/__main__.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi/schemas/BES.xsd +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi/schemas/BESAPI.xsd +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi/schemas/BESActionSettings.xsd +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi.egg-info/dependency_links.txt +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi.egg-info/requires.txt +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/besapi.egg-info/top_level.txt +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/bescli/__init__.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/bescli/__main__.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/src/bescli/bescli.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_besapi.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_besapi_coverage.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_besapi_sdk.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_bescli_coverage.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_live_sample_plugin.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_plugin_utilities.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_plugin_utilities_coverage.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_plugin_utilities_logging.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_plugin_utilities_sdk.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/test_sample_plugin.py +0 -0
- {besapi-4.3.1 → besapi-4.4.1}/tests/tests.py +0 -0
|
@@ -1,6 +1,42 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
"""Utility functions shared by BigFix plugins, such as Server Plugin Services.
|
|
2
|
+
|
|
3
|
+
A plugin is a script run on a schedule, often by the BigFix Server Plugin
|
|
4
|
+
Service on the root server (as root on Linux, SYSTEM on Windows), with an
|
|
5
|
+
unrelated working directory and a minimal PATH. These utilities handle the
|
|
6
|
+
setup every plugin needs, so a plugin can be as short as:
|
|
7
|
+
|
|
8
|
+
import besapi.plugin_utilities
|
|
9
|
+
|
|
10
|
+
with besapi.plugin_utilities.init_plugin(__version__) as (args, bes_conn):
|
|
11
|
+
config = besapi.plugin_utilities.get_plugin_config()
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
Main areas:
|
|
15
|
+
|
|
16
|
+
- Setup: init_plugin() parses the standard args, configures logging to
|
|
17
|
+
`<plugin name>.log` next to the plugin, logs session start and end banners
|
|
18
|
+
and makes the BigFix connection. setup_plugin_argparse(),
|
|
19
|
+
get_plugin_logging_config() and plugin_session() are the parts it uses.
|
|
20
|
+
- Connection: get_besapi_connection() tries explicit args first, then the
|
|
21
|
+
local root server's own credentials, then env vars, then the besapi config
|
|
22
|
+
file. The root server credentials come from the platform specific module,
|
|
23
|
+
besapi.plugin_utilities_win (Windows Registry and DPAPI) or
|
|
24
|
+
besapi.plugin_utilities_linux (MasterOperatorCredentials and CryptoUtility),
|
|
25
|
+
and are only used if this happens to be a root server.
|
|
26
|
+
- Files: resolve_plugin_path() finds files relative to the plugin, not the
|
|
27
|
+
working directory. get_plugin_config() loads `<plugin name>.config.yaml`,
|
|
28
|
+
and consume_trigger_file() lets an action request a plugin run.
|
|
29
|
+
- Secrets: protect_plugin_config_secrets() encrypts plaintext secrets in the
|
|
30
|
+
config file in place, with the same keys as the root server's own REST API
|
|
31
|
+
password, once the plugin has used them successfully.
|
|
32
|
+
get_plugin_config(secret_keys=...) decrypts them again.
|
|
33
|
+
- Commands: find_executable() and run_logged() run external tools with
|
|
34
|
+
their output logged.
|
|
35
|
+
|
|
36
|
+
The platform specific module is imported here only if available, and a
|
|
37
|
+
failure in it never prevents the other connection methods.
|
|
38
|
+
|
|
39
|
+
See examples/bigfix_plugin_mqtt_homeassistant.py for a complete plugin.
|
|
4
40
|
"""
|
|
5
41
|
|
|
6
42
|
import argparse
|
|
@@ -14,6 +50,7 @@ import platform
|
|
|
14
50
|
import shutil
|
|
15
51
|
import subprocess # nosec B404
|
|
16
52
|
import sys
|
|
53
|
+
import tempfile
|
|
17
54
|
from typing import Any, Iterator, List, Sequence, Tuple, Union
|
|
18
55
|
|
|
19
56
|
import besapi
|
|
@@ -38,6 +75,10 @@ except BaseException as import_error: # pylint: disable=broad-exception-caught
|
|
|
38
75
|
logging.debug("platform specific plugin utilities unavailable: %s", import_error)
|
|
39
76
|
|
|
40
77
|
|
|
78
|
+
# marks a secret encrypted by protect_secret(), one prefix per platform module,
|
|
79
|
+
# matching PROTECTED_SECRET_PREFIX in plugin_utilities_linux and _win:
|
|
80
|
+
PROTECTED_SECRET_PREFIXES = ("{cryptoutility}", "{dpapi}")
|
|
81
|
+
|
|
41
82
|
# custom log level for session start / end banners in plugin logs:
|
|
42
83
|
SESSION_LOG_LEVEL = 99
|
|
43
84
|
|
|
@@ -235,24 +276,19 @@ def resolve_plugin_path(path: str) -> Union[str, None]:
|
|
|
235
276
|
return None
|
|
236
277
|
|
|
237
278
|
|
|
238
|
-
def
|
|
239
|
-
"""
|
|
240
|
-
|
|
241
|
-
Requires the optional `ruamel.yaml` library: `pip install besapi[plugins]`
|
|
242
|
-
|
|
243
|
-
Arguments:
|
|
244
|
-
file_name: config file, resolved with resolve_plugin_path(). Defaults
|
|
245
|
-
to `<plugin name>.config.yaml` next to the plugin.
|
|
246
|
-
|
|
247
|
-
Returns:
|
|
248
|
-
The parsed YAML, usually a dict.
|
|
249
|
-
"""
|
|
279
|
+
def _find_plugin_config(file_name: Union[str, None] = None) -> str:
|
|
280
|
+
"""Find the plugin's YAML config file, see get_plugin_config()."""
|
|
250
281
|
file_name = file_name or get_invoke_file_name() + ".config.yaml"
|
|
251
282
|
|
|
252
283
|
config_path = resolve_plugin_path(file_name)
|
|
253
284
|
if not config_path:
|
|
254
285
|
raise FileNotFoundError(f"plugin config file not found: {file_name}")
|
|
255
286
|
|
|
287
|
+
return config_path
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _import_ruamel_yaml():
|
|
291
|
+
"""Import the optional ruamel.yaml library, with a helpful error."""
|
|
256
292
|
try:
|
|
257
293
|
import ruamel.yaml # pylint: disable=import-outside-toplevel
|
|
258
294
|
except ImportError as err:
|
|
@@ -261,9 +297,141 @@ def get_plugin_config(file_name: Union[str, None] = None) -> Any:
|
|
|
261
297
|
"install it with: pip install besapi[plugins]"
|
|
262
298
|
) from err
|
|
263
299
|
|
|
300
|
+
return ruamel.yaml
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _get_config_parent(config: Any, key_path: Sequence[str]) -> Any:
|
|
304
|
+
"""Get the dict holding the last key of key_path, None if it is not set."""
|
|
305
|
+
parent = config
|
|
306
|
+
for key in key_path[:-1]:
|
|
307
|
+
parent = parent.get(key) if isinstance(parent, dict) else None
|
|
308
|
+
|
|
309
|
+
if not isinstance(parent, dict) or not parent.get(key_path[-1]):
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
return parent
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def get_plugin_config(
|
|
316
|
+
file_name: Union[str, None] = None,
|
|
317
|
+
secret_keys: Union[Sequence[Sequence[str]], None] = None,
|
|
318
|
+
) -> Any:
|
|
319
|
+
"""Load the plugin's YAML config file.
|
|
320
|
+
|
|
321
|
+
Requires the optional `ruamel.yaml` library: `pip install besapi[plugins]`
|
|
322
|
+
|
|
323
|
+
Arguments:
|
|
324
|
+
file_name: config file, resolved with resolve_plugin_path(). Defaults
|
|
325
|
+
to `<plugin name>.config.yaml` next to the plugin.
|
|
326
|
+
secret_keys: key paths of secrets in the config, such as
|
|
327
|
+
`[("mqtt", "password")]`. Each one encrypted by
|
|
328
|
+
protect_plugin_config_secrets() is returned decrypted. The file is
|
|
329
|
+
never changed here, since a plaintext secret is not known to work.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
The parsed YAML, usually a dict.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
ValueError: if a secret is encrypted but cannot be decrypted, since
|
|
336
|
+
sending the encrypted value as a password can never work.
|
|
337
|
+
"""
|
|
338
|
+
config_path = _find_plugin_config(file_name)
|
|
339
|
+
ruamel_yaml = _import_ruamel_yaml()
|
|
340
|
+
|
|
264
341
|
logging.info("loading config from: `%s`", config_path)
|
|
265
342
|
with open(config_path, encoding="utf-8") as stream:
|
|
266
|
-
|
|
343
|
+
config = ruamel_yaml.YAML(typ="safe", pure=True).load(stream)
|
|
344
|
+
|
|
345
|
+
for key_path in secret_keys or []:
|
|
346
|
+
parent = _get_config_parent(config, key_path)
|
|
347
|
+
if parent is None or not is_protected_secret(parent[key_path[-1]]):
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
plaintext = unprotect_secret(parent[key_path[-1]])
|
|
351
|
+
if not plaintext:
|
|
352
|
+
name = ".".join(str(key) for key in key_path)
|
|
353
|
+
raise ValueError(
|
|
354
|
+
f"failed to decrypt `{name}` in plugin config `{config_path}`, "
|
|
355
|
+
"replace it with the plaintext value to have it encrypted again"
|
|
356
|
+
)
|
|
357
|
+
parent[key_path[-1]] = plaintext
|
|
358
|
+
|
|
359
|
+
return config
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def protect_plugin_config_secrets(
|
|
363
|
+
secret_keys: Sequence[Sequence[str]], file_name: Union[str, None] = None
|
|
364
|
+
) -> List[str]:
|
|
365
|
+
"""Encrypt plaintext secrets in the plugin's YAML config file, in place.
|
|
366
|
+
|
|
367
|
+
Call this only after the plugin has used the secrets successfully, such as
|
|
368
|
+
after logging in with a password, so a wrong one is never encrypted and is
|
|
369
|
+
left as plaintext to fix. Encrypting only works on a root server with the
|
|
370
|
+
privileges of the plugin service, see protect_secret(), otherwise the file
|
|
371
|
+
is left alone. Comments and layout in the file are kept.
|
|
372
|
+
|
|
373
|
+
This is best effort, a failure is logged and never raised, since the
|
|
374
|
+
plugin has already done its work with the plaintext.
|
|
375
|
+
|
|
376
|
+
Arguments:
|
|
377
|
+
secret_keys: key paths of secrets in the config, such as
|
|
378
|
+
`[("mqtt", "password")]`.
|
|
379
|
+
file_name: config file, see get_plugin_config().
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
The dotted names of the secrets encrypted, such as `["mqtt.password"]`.
|
|
383
|
+
"""
|
|
384
|
+
protected_names: List[str] = []
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
config_path = _find_plugin_config(file_name)
|
|
388
|
+
ruamel_yaml = _import_ruamel_yaml()
|
|
389
|
+
|
|
390
|
+
# round trip mode, unlike the safe loader, keeps comments:
|
|
391
|
+
yaml = ruamel_yaml.YAML()
|
|
392
|
+
yaml.preserve_quotes = True
|
|
393
|
+
|
|
394
|
+
with open(config_path, encoding="utf-8") as stream:
|
|
395
|
+
contents = stream.read()
|
|
396
|
+
|
|
397
|
+
data = yaml.load(contents)
|
|
398
|
+
# round trip mode does not keep the `---` document start by itself:
|
|
399
|
+
yaml.explicit_start = contents.lstrip().startswith("---")
|
|
400
|
+
|
|
401
|
+
for key_path in secret_keys:
|
|
402
|
+
parent = _get_config_parent(data, key_path)
|
|
403
|
+
if parent is None or is_protected_secret(parent[key_path[-1]]):
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
protected = protect_secret(str(parent[key_path[-1]]))
|
|
407
|
+
if not protected:
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
parent[key_path[-1]] = protected
|
|
411
|
+
protected_names.append(".".join(str(key) for key in key_path))
|
|
412
|
+
|
|
413
|
+
if not protected_names:
|
|
414
|
+
logging.debug("no plaintext secrets encrypted in plugin config.")
|
|
415
|
+
return []
|
|
416
|
+
|
|
417
|
+
# write beside the original then swap, so a failure never truncates it:
|
|
418
|
+
file_handle, temp_path = tempfile.mkstemp(
|
|
419
|
+
dir=os.path.dirname(config_path), prefix=".", suffix=".tmp"
|
|
420
|
+
)
|
|
421
|
+
try:
|
|
422
|
+
with os.fdopen(file_handle, "w", encoding="utf-8") as stream:
|
|
423
|
+
yaml.dump(data, stream)
|
|
424
|
+
shutil.copymode(config_path, temp_path)
|
|
425
|
+
os.replace(temp_path, config_path)
|
|
426
|
+
finally:
|
|
427
|
+
if os.path.exists(temp_path):
|
|
428
|
+
os.remove(temp_path)
|
|
429
|
+
except Exception as err: # pylint: disable=broad-exception-caught
|
|
430
|
+
logging.warning("failed to encrypt secrets in plugin config: %s", err)
|
|
431
|
+
return []
|
|
432
|
+
|
|
433
|
+
logging.info("encrypted plaintext %s in plugin config.", protected_names)
|
|
434
|
+
return protected_names
|
|
267
435
|
|
|
268
436
|
|
|
269
437
|
def consume_trigger_file(path: str) -> bool:
|
|
@@ -360,7 +528,9 @@ def get_besapi_connection_env_then_config():
|
|
|
360
528
|
return bes_conn
|
|
361
529
|
|
|
362
530
|
|
|
363
|
-
def _try_platform_utility(
|
|
531
|
+
def _try_platform_utility(
|
|
532
|
+
function_name: str, *args, failure_log_level: int = logging.DEBUG
|
|
533
|
+
):
|
|
364
534
|
"""Call a function from the platform specific utilities module, if possible.
|
|
365
535
|
|
|
366
536
|
These are best effort conveniences for the case where the plugin happens to
|
|
@@ -368,7 +538,10 @@ def _try_platform_utility(function_name: str):
|
|
|
368
538
|
not a usable root server, so the caller falls back to the other methods.
|
|
369
539
|
|
|
370
540
|
Args:
|
|
371
|
-
function_name: The name of the function to call
|
|
541
|
+
function_name: The name of the function to call.
|
|
542
|
+
*args: Arguments to pass to the function.
|
|
543
|
+
failure_log_level: The level to log an error raised by the function at.
|
|
544
|
+
DEBUG by default, since that usually just means not a root server.
|
|
372
545
|
|
|
373
546
|
Returns:
|
|
374
547
|
Whatever the function returned, or None if it could not be used.
|
|
@@ -386,10 +559,10 @@ def _try_platform_utility(function_name: str):
|
|
|
386
559
|
return None
|
|
387
560
|
|
|
388
561
|
try:
|
|
389
|
-
return platform_function()
|
|
562
|
+
return platform_function(*args)
|
|
390
563
|
except BaseException as err: # pylint: disable=broad-exception-caught
|
|
391
564
|
# NOTE: intentionally broad, this must never prevent the other methods:
|
|
392
|
-
logging.
|
|
565
|
+
logging.log(failure_log_level, "`%s` failed, ignoring: %s", function_name, err)
|
|
393
566
|
return None
|
|
394
567
|
|
|
395
568
|
|
|
@@ -406,6 +579,57 @@ def get_root_server_rest_pass() -> Union[str, None]:
|
|
|
406
579
|
return _try_platform_utility("get_linux_credentials_rest_pass")
|
|
407
580
|
|
|
408
581
|
|
|
582
|
+
def is_protected_secret(value: Any) -> bool:
|
|
583
|
+
"""Check if a value is a secret encrypted by protect_secret()."""
|
|
584
|
+
return isinstance(value, str) and value.startswith(PROTECTED_SECRET_PREFIXES)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def protect_secret(plaintext: str) -> Union[str, None]:
|
|
588
|
+
"""Encrypt a secret, such as a password in a plugin config file.
|
|
589
|
+
|
|
590
|
+
This only works on a root server, with the privileges of the plugin service
|
|
591
|
+
(root on Linux, SYSTEM or an administrator on Windows), since the keys used
|
|
592
|
+
are the same as for the server's own REST API password. The result is
|
|
593
|
+
decrypted again before it is returned, so it is known to be usable.
|
|
594
|
+
|
|
595
|
+
NOTE: this checks what the process can do, not which user it runs as, by
|
|
596
|
+
requiring get_root_server_rest_pass() to succeed. A check for root or
|
|
597
|
+
SYSTEM would allow a machine that is not a root server, which lacks the
|
|
598
|
+
keys, and would refuse an administrator on a Windows root server, where
|
|
599
|
+
machine scope DPAPI works and SYSTEM can still decrypt the result. On
|
|
600
|
+
Linux, reading the server's credentials file means root in practice.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
The encrypted secret, otherwise None if it could not be encrypted.
|
|
604
|
+
"""
|
|
605
|
+
# proves this is a root server, and that the keys are readable:
|
|
606
|
+
if not get_root_server_rest_pass():
|
|
607
|
+
logging.debug("not a usable root server, cannot protect secret.")
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
# NOTE: this is a root server by now, so an error here is a real problem:
|
|
611
|
+
protected = _try_platform_utility(
|
|
612
|
+
"protect_secret", plaintext, failure_log_level=logging.ERROR
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
if not protected:
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
# never trust an encrypted value that cannot be decrypted back:
|
|
619
|
+
if unprotect_secret(protected) != plaintext:
|
|
620
|
+
logging.warning("encrypted secret did not decrypt back, not using it.")
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
return protected
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def unprotect_secret(protected: str) -> Union[str, None]:
|
|
627
|
+
"""Decrypt a secret from protect_secret(), otherwise return None."""
|
|
628
|
+
return _try_platform_utility(
|
|
629
|
+
"unprotect_secret", protected, failure_log_level=logging.ERROR
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
409
633
|
def get_besconn_root_server() -> Union[besapi.besapi.BESConnection, None]:
|
|
410
634
|
"""Get a connection using local root server credentials, if this is one.
|
|
411
635
|
|
|
@@ -33,6 +33,14 @@ BES_APPLICATION_DIRS = [
|
|
|
33
33
|
CRYPTO_UTILITY_NAME = "CryptoUtility"
|
|
34
34
|
CREDENTIALS_FILE_NAME = "MasterOperatorCredentials"
|
|
35
35
|
|
|
36
|
+
CRYPTO_UTILITY_DECRYPT_ARGS = ["-d", "-i"]
|
|
37
|
+
# NOTE: encrypting is the CryptoUtility default, there is no `-e` flag. From its
|
|
38
|
+
# usage: `CryptoUtility [-d] [-u] [-i <input text> | -f <input file path>]`
|
|
39
|
+
CRYPTO_UTILITY_ENCRYPT_ARGS = ["-i"]
|
|
40
|
+
|
|
41
|
+
# marks a secret encrypted by protect_secret(), such as in a plugin config file:
|
|
42
|
+
PROTECTED_SECRET_PREFIX = "{cryptoutility}"
|
|
43
|
+
|
|
36
44
|
# env var overrides, keyed by the file name they override:
|
|
37
45
|
BES_APPLICATION_FILE_ENV_VARS = {
|
|
38
46
|
CRYPTO_UTILITY_NAME: "BESAPI_CRYPTO_UTILITY",
|
|
@@ -135,38 +143,34 @@ def parse_credentials_file(file_path: Union[str, None] = None) -> Dict[str, str]
|
|
|
135
143
|
return dict(config[_INI_SECTION])
|
|
136
144
|
|
|
137
145
|
|
|
138
|
-
def
|
|
139
|
-
|
|
146
|
+
def _run_crypto_utility(
|
|
147
|
+
crypto_args: List[str],
|
|
148
|
+
value: str,
|
|
140
149
|
crypto_utility_path: Union[str, None] = None,
|
|
141
150
|
timeout: int = 30,
|
|
142
151
|
) -> Union[str, None]:
|
|
143
|
-
"""
|
|
144
|
-
|
|
145
|
-
Equivalent to: `CryptoUtility -d -i "<encrypted_value>"`
|
|
152
|
+
"""Run the BigFix server CryptoUtility binary on one value.
|
|
146
153
|
|
|
147
154
|
Args:
|
|
148
|
-
|
|
155
|
+
crypto_args: The args that go before the value, such as `["-d", "-i"]`.
|
|
156
|
+
value: The value to encrypt or decrypt.
|
|
149
157
|
crypto_utility_path: Path to CryptoUtility, located automatically if not given.
|
|
150
158
|
timeout: Seconds to wait for CryptoUtility before giving up.
|
|
151
159
|
|
|
152
160
|
Returns:
|
|
153
|
-
The
|
|
161
|
+
The stripped stdout of CryptoUtility, otherwise None.
|
|
154
162
|
"""
|
|
155
|
-
if not encrypted_value or encrypted_value.strip() == "":
|
|
156
|
-
logger.warning("No encrypted value provided for decryption.")
|
|
157
|
-
return None
|
|
158
|
-
|
|
159
163
|
if not crypto_utility_path:
|
|
160
164
|
crypto_utility_path = find_bes_application_file(CRYPTO_UTILITY_NAME)
|
|
161
165
|
if not crypto_utility_path:
|
|
162
|
-
logger.debug("CryptoUtility not found, cannot
|
|
166
|
+
logger.debug("CryptoUtility not found, cannot run it.")
|
|
163
167
|
return None
|
|
164
168
|
|
|
165
169
|
# NOTE: the command is a list and shell is False,
|
|
166
|
-
# so the
|
|
170
|
+
# so the value is never interpreted by a shell:
|
|
167
171
|
try:
|
|
168
172
|
result = subprocess.run( # nosec B603
|
|
169
|
-
[crypto_utility_path,
|
|
173
|
+
[crypto_utility_path, *crypto_args, value],
|
|
170
174
|
capture_output=True,
|
|
171
175
|
text=True,
|
|
172
176
|
check=False,
|
|
@@ -180,7 +184,7 @@ def crypto_utility_decrypt(
|
|
|
180
184
|
return None
|
|
181
185
|
|
|
182
186
|
if result.returncode != 0:
|
|
183
|
-
# NOTE: never log stdout here, it
|
|
187
|
+
# NOTE: never log stdout here, it could contain the plaintext:
|
|
184
188
|
logger.error(
|
|
185
189
|
"CryptoUtility failed with return code %s: %s",
|
|
186
190
|
result.returncode,
|
|
@@ -188,15 +192,102 @@ def crypto_utility_decrypt(
|
|
|
188
192
|
)
|
|
189
193
|
return None
|
|
190
194
|
|
|
191
|
-
|
|
195
|
+
output = (result.stdout or "").strip()
|
|
192
196
|
|
|
193
|
-
if not
|
|
197
|
+
if not output:
|
|
194
198
|
logger.debug("CryptoUtility returned no data.")
|
|
195
199
|
return None
|
|
196
200
|
|
|
197
|
-
# NOTE: only the length is logged, never the
|
|
198
|
-
logger.debug("CryptoUtility
|
|
199
|
-
return
|
|
201
|
+
# NOTE: only the length is logged, never the value:
|
|
202
|
+
logger.debug("CryptoUtility output length: %s", len(output))
|
|
203
|
+
return output
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def crypto_utility_decrypt(
|
|
207
|
+
encrypted_value: str,
|
|
208
|
+
crypto_utility_path: Union[str, None] = None,
|
|
209
|
+
timeout: int = 30,
|
|
210
|
+
) -> Union[str, None]:
|
|
211
|
+
"""Decrypt a value using the BigFix server CryptoUtility binary.
|
|
212
|
+
|
|
213
|
+
Equivalent to: `CryptoUtility -d -i "<encrypted_value>"`
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
encrypted_value: The encrypted value, including its `{aes,1}` prefix.
|
|
217
|
+
crypto_utility_path: Path to CryptoUtility, located automatically if not given.
|
|
218
|
+
timeout: Seconds to wait for CryptoUtility before giving up.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The decrypted string, otherwise None.
|
|
222
|
+
"""
|
|
223
|
+
if not encrypted_value or encrypted_value.strip() == "":
|
|
224
|
+
logger.warning("No encrypted value provided for decryption.")
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
return _run_crypto_utility(
|
|
228
|
+
CRYPTO_UTILITY_DECRYPT_ARGS, encrypted_value, crypto_utility_path, timeout
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def crypto_utility_encrypt(
|
|
233
|
+
plaintext: str,
|
|
234
|
+
crypto_utility_path: Union[str, None] = None,
|
|
235
|
+
timeout: int = 30,
|
|
236
|
+
) -> Union[str, None]:
|
|
237
|
+
"""Encrypt a value using the BigFix server CryptoUtility binary.
|
|
238
|
+
|
|
239
|
+
Equivalent to: `CryptoUtility -i "<plaintext>"`
|
|
240
|
+
|
|
241
|
+
The output is a `{aes,1}` prefixed value.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
plaintext: The value to encrypt.
|
|
245
|
+
crypto_utility_path: Path to CryptoUtility, located automatically if not given.
|
|
246
|
+
timeout: Seconds to wait for CryptoUtility before giving up.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
The encrypted string exactly as CryptoUtility printed it, otherwise None.
|
|
250
|
+
"""
|
|
251
|
+
if not plaintext or plaintext.strip() == "":
|
|
252
|
+
logger.warning("No plaintext provided for encryption.")
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
return _run_crypto_utility(
|
|
256
|
+
CRYPTO_UTILITY_ENCRYPT_ARGS, plaintext, crypto_utility_path, timeout
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def protect_secret(plaintext: str) -> Union[str, None]:
|
|
261
|
+
"""Encrypt a secret, such as a password in a plugin config file.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
plaintext: The secret to encrypt.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
The encrypted secret with the PROTECTED_SECRET_PREFIX, otherwise None.
|
|
268
|
+
"""
|
|
269
|
+
encrypted = crypto_utility_encrypt(plaintext)
|
|
270
|
+
|
|
271
|
+
if not encrypted:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
return PROTECTED_SECRET_PREFIX + encrypted
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def unprotect_secret(protected: str) -> Union[str, None]:
|
|
278
|
+
"""Decrypt a secret from protect_secret().
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
protected: The encrypted secret, including the PROTECTED_SECRET_PREFIX.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
The decrypted secret, otherwise None, including if the prefix is missing.
|
|
285
|
+
"""
|
|
286
|
+
if not protected or not protected.startswith(PROTECTED_SECRET_PREFIX):
|
|
287
|
+
logger.debug("value is not a protected secret, not decrypting.")
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
return crypto_utility_decrypt(protected[len(PROTECTED_SECRET_PREFIX) :])
|
|
200
291
|
|
|
201
292
|
|
|
202
293
|
def get_linux_credentials_rest_pass(
|
|
@@ -31,6 +31,9 @@ except (ImportError, ModuleNotFoundError) as e:
|
|
|
31
31
|
"This script requires the pywin32 package. Install it via 'pip install pywin32'."
|
|
32
32
|
) from e
|
|
33
33
|
|
|
34
|
+
# marks a secret encrypted by protect_secret(), such as in a plugin config file:
|
|
35
|
+
PROTECTED_SECRET_PREFIX = "{dpapi}"
|
|
36
|
+
|
|
34
37
|
|
|
35
38
|
def win_dpapi_encrypt_str(
|
|
36
39
|
plaintext: str, scope_flags: int = 4, entropy: Union[str, bytes, None] = None
|
|
@@ -115,6 +118,46 @@ def win_dpapi_decrypt_base64(
|
|
|
115
118
|
return None
|
|
116
119
|
|
|
117
120
|
|
|
121
|
+
def protect_secret(plaintext: str) -> Union[str, None]:
|
|
122
|
+
"""Encrypt a secret, such as a password in a plugin config file.
|
|
123
|
+
|
|
124
|
+
Uses machine scope DPAPI, the same as the BigFix server RESTPassword.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
plaintext: The secret to encrypt.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The encrypted secret with the PROTECTED_SECRET_PREFIX, otherwise None.
|
|
131
|
+
"""
|
|
132
|
+
encrypted = win_dpapi_encrypt_str(plaintext)
|
|
133
|
+
|
|
134
|
+
if not encrypted:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
return PROTECTED_SECRET_PREFIX + encrypted
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def unprotect_secret(protected: str) -> Union[str, None]:
|
|
141
|
+
"""Decrypt a secret from protect_secret().
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
protected: The encrypted secret, including the PROTECTED_SECRET_PREFIX.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The decrypted secret, otherwise None, including if the prefix is missing.
|
|
148
|
+
"""
|
|
149
|
+
if not protected or not protected.startswith(PROTECTED_SECRET_PREFIX):
|
|
150
|
+
logger.debug("value is not a protected secret, not decrypting.")
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
return win_dpapi_decrypt_base64(protected[len(PROTECTED_SECRET_PREFIX) :])
|
|
155
|
+
# NOTE: broad, bad base64 and DPAPI failures raise unrelated error types:
|
|
156
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
157
|
+
logger.error("Failed to decrypt protected secret: %s", e)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
118
161
|
def win_registry_value_read(hive, subkey, value_name):
|
|
119
162
|
"""
|
|
120
163
|
Reads a value from the Windows Registry.
|
|
@@ -32,5 +32,7 @@ tests/test_plugin_utilities_coverage.py
|
|
|
32
32
|
tests/test_plugin_utilities_linux.py
|
|
33
33
|
tests/test_plugin_utilities_logging.py
|
|
34
34
|
tests/test_plugin_utilities_sdk.py
|
|
35
|
+
tests/test_plugin_utilities_secrets.py
|
|
36
|
+
tests/test_plugin_utilities_win_fake.py
|
|
35
37
|
tests/test_sample_plugin.py
|
|
36
38
|
tests/tests.py
|