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.
Files changed (39) hide show
  1. {besapi-4.3.1/src/besapi.egg-info → besapi-4.4.1}/PKG-INFO +1 -1
  2. {besapi-4.3.1 → besapi-4.4.1}/src/besapi/besapi.py +1 -1
  3. {besapi-4.3.1 → besapi-4.4.1}/src/besapi/plugin_utilities.py +244 -20
  4. {besapi-4.3.1 → besapi-4.4.1}/src/besapi/plugin_utilities_linux.py +111 -20
  5. {besapi-4.3.1 → besapi-4.4.1}/src/besapi/plugin_utilities_win.py +43 -0
  6. {besapi-4.3.1 → besapi-4.4.1/src/besapi.egg-info}/PKG-INFO +1 -1
  7. {besapi-4.3.1 → besapi-4.4.1}/src/besapi.egg-info/SOURCES.txt +2 -0
  8. {besapi-4.3.1 → besapi-4.4.1}/tests/test_example_mqtt_homeassistant.py +100 -2
  9. {besapi-4.3.1 → besapi-4.4.1}/tests/test_plugin_utilities_linux.py +91 -0
  10. besapi-4.4.1/tests/test_plugin_utilities_secrets.py +374 -0
  11. besapi-4.4.1/tests/test_plugin_utilities_win_fake.py +76 -0
  12. {besapi-4.3.1 → besapi-4.4.1}/LICENSE.txt +0 -0
  13. {besapi-4.3.1 → besapi-4.4.1}/MANIFEST.in +0 -0
  14. {besapi-4.3.1 → besapi-4.4.1}/README.md +0 -0
  15. {besapi-4.3.1 → besapi-4.4.1}/pyproject.toml +0 -0
  16. {besapi-4.3.1 → besapi-4.4.1}/setup.cfg +0 -0
  17. {besapi-4.3.1 → besapi-4.4.1}/setup.py +0 -0
  18. {besapi-4.3.1 → besapi-4.4.1}/src/besapi/__init__.py +0 -0
  19. {besapi-4.3.1 → besapi-4.4.1}/src/besapi/__main__.py +0 -0
  20. {besapi-4.3.1 → besapi-4.4.1}/src/besapi/schemas/BES.xsd +0 -0
  21. {besapi-4.3.1 → besapi-4.4.1}/src/besapi/schemas/BESAPI.xsd +0 -0
  22. {besapi-4.3.1 → besapi-4.4.1}/src/besapi/schemas/BESActionSettings.xsd +0 -0
  23. {besapi-4.3.1 → besapi-4.4.1}/src/besapi.egg-info/dependency_links.txt +0 -0
  24. {besapi-4.3.1 → besapi-4.4.1}/src/besapi.egg-info/requires.txt +0 -0
  25. {besapi-4.3.1 → besapi-4.4.1}/src/besapi.egg-info/top_level.txt +0 -0
  26. {besapi-4.3.1 → besapi-4.4.1}/src/bescli/__init__.py +0 -0
  27. {besapi-4.3.1 → besapi-4.4.1}/src/bescli/__main__.py +0 -0
  28. {besapi-4.3.1 → besapi-4.4.1}/src/bescli/bescli.py +0 -0
  29. {besapi-4.3.1 → besapi-4.4.1}/tests/test_besapi.py +0 -0
  30. {besapi-4.3.1 → besapi-4.4.1}/tests/test_besapi_coverage.py +0 -0
  31. {besapi-4.3.1 → besapi-4.4.1}/tests/test_besapi_sdk.py +0 -0
  32. {besapi-4.3.1 → besapi-4.4.1}/tests/test_bescli_coverage.py +0 -0
  33. {besapi-4.3.1 → besapi-4.4.1}/tests/test_live_sample_plugin.py +0 -0
  34. {besapi-4.3.1 → besapi-4.4.1}/tests/test_plugin_utilities.py +0 -0
  35. {besapi-4.3.1 → besapi-4.4.1}/tests/test_plugin_utilities_coverage.py +0 -0
  36. {besapi-4.3.1 → besapi-4.4.1}/tests/test_plugin_utilities_logging.py +0 -0
  37. {besapi-4.3.1 → besapi-4.4.1}/tests/test_plugin_utilities_sdk.py +0 -0
  38. {besapi-4.3.1 → besapi-4.4.1}/tests/test_sample_plugin.py +0 -0
  39. {besapi-4.3.1 → besapi-4.4.1}/tests/tests.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: besapi
3
- Version: 4.3.1
3
+ Version: 4.4.1
4
4
  Summary: Library for working with the BigFix REST API
5
5
  Home-page: https://github.com/jgstew/besapi
6
6
  Author: Matt Hansen, James Stewart
@@ -31,7 +31,7 @@ import lxml.objectify
31
31
  import requests
32
32
  import urllib3.poolmanager
33
33
 
34
- __version__ = "4.3.1"
34
+ __version__ = "4.4.1"
35
35
 
36
36
  besapi_logger = logging.getLogger("besapi")
37
37
 
@@ -1,6 +1,42 @@
1
- """This is a set of utility functions for use in multiple plugins.
2
-
3
- see example here: https://github.com/jgstew/besapi/blob/master/examples/export_all_sites.py
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 get_plugin_config(file_name: Union[str, None] = None) -> Any:
239
- """Load the plugin's YAML config file.
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
- return ruamel.yaml.YAML(typ="safe", pure=True).load(stream)
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(function_name: str):
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, with no arguments.
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.debug("`%s` failed, ignoring: %s", function_name, err)
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 crypto_utility_decrypt(
139
- encrypted_value: str,
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
- """Decrypt a value using the BigFix server CryptoUtility binary.
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
- encrypted_value: The encrypted value, including its `{aes,1}` prefix.
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 decrypted string, otherwise None.
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 decrypt.")
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 encrypted value is never interpreted by a shell:
170
+ # so the value is never interpreted by a shell:
167
171
  try:
168
172
  result = subprocess.run( # nosec B603
169
- [crypto_utility_path, "-d", "-i", encrypted_value],
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 would contain the plaintext:
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
- decrypted = (result.stdout or "").strip()
195
+ output = (result.stdout or "").strip()
192
196
 
193
- if not decrypted:
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 plaintext:
198
- logger.debug("CryptoUtility decrypted value length: %s", len(decrypted))
199
- return decrypted
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: besapi
3
- Version: 4.3.1
3
+ Version: 4.4.1
4
4
  Summary: Library for working with the BigFix REST API
5
5
  Home-page: https://github.com/jgstew/besapi
6
6
  Author: Matt Hansen, James Stewart
@@ -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