cli2 4.2.10__tar.gz → 4.3.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.
Files changed (58) hide show
  1. {cli2-4.2.10/cli2.egg-info → cli2-4.3.0}/PKG-INFO +1 -1
  2. {cli2-4.2.10 → cli2-4.3.0}/cli2/__init__.py +1 -0
  3. {cli2-4.2.10 → cli2-4.3.0}/cli2/ansible/action.py +40 -80
  4. {cli2-4.2.10 → cli2-4.3.0}/cli2/client.py +39 -61
  5. {cli2-4.2.10 → cli2-4.3.0}/cli2/examples/client.py +1 -1
  6. cli2-4.3.0/cli2/mask.py +121 -0
  7. {cli2-4.2.10 → cli2-4.3.0/cli2.egg-info}/PKG-INFO +1 -1
  8. {cli2-4.2.10 → cli2-4.3.0}/cli2.egg-info/SOURCES.txt +2 -0
  9. {cli2-4.2.10 → cli2-4.3.0}/setup.py +1 -1
  10. {cli2-4.2.10 → cli2-4.3.0}/tests/test_ansible.py +3 -3
  11. {cli2-4.2.10 → cli2-4.3.0}/tests/test_client.py +27 -23
  12. cli2-4.3.0/tests/test_mask.py +22 -0
  13. {cli2-4.2.10 → cli2-4.3.0}/MANIFEST.in +0 -0
  14. {cli2-4.2.10 → cli2-4.3.0}/README.rst +0 -0
  15. {cli2-4.2.10 → cli2-4.3.0}/classifiers.txt +0 -0
  16. {cli2-4.2.10 → cli2-4.3.0}/cli2/ansible/__init__.py +0 -0
  17. {cli2-4.2.10 → cli2-4.3.0}/cli2/ansible/playbook.py +0 -0
  18. {cli2-4.2.10 → cli2-4.3.0}/cli2/ansible/variables.py +0 -0
  19. {cli2-4.2.10 → cli2-4.3.0}/cli2/asyncio.py +0 -0
  20. {cli2-4.2.10 → cli2-4.3.0}/cli2/cli.py +0 -0
  21. {cli2-4.2.10 → cli2-4.3.0}/cli2/cli2.py +0 -0
  22. {cli2-4.2.10 → cli2-4.3.0}/cli2/colors.py +0 -0
  23. {cli2-4.2.10 → cli2-4.3.0}/cli2/configuration.py +0 -0
  24. {cli2-4.2.10 → cli2-4.3.0}/cli2/decorators.py +0 -0
  25. {cli2-4.2.10 → cli2-4.3.0}/cli2/display.py +0 -0
  26. {cli2-4.2.10 → cli2-4.3.0}/cli2/examples/__init__.py +0 -0
  27. {cli2-4.2.10 → cli2-4.3.0}/cli2/examples/conf.py +0 -0
  28. {cli2-4.2.10 → cli2-4.3.0}/cli2/examples/example.py +0 -0
  29. {cli2-4.2.10 → cli2-4.3.0}/cli2/examples/example_obj.py +0 -0
  30. {cli2-4.2.10 → cli2-4.3.0}/cli2/examples/nesting.py +0 -0
  31. {cli2-4.2.10 → cli2-4.3.0}/cli2/examples/obj.py +0 -0
  32. {cli2-4.2.10 → cli2-4.3.0}/cli2/examples/obj2.py +0 -0
  33. {cli2-4.2.10 → cli2-4.3.0}/cli2/examples/test.py +0 -0
  34. {cli2-4.2.10 → cli2-4.3.0}/cli2/lock.py +0 -0
  35. {cli2-4.2.10 → cli2-4.3.0}/cli2/log.py +0 -0
  36. {cli2-4.2.10 → cli2-4.3.0}/cli2/node.py +0 -0
  37. {cli2-4.2.10 → cli2-4.3.0}/cli2/sphinx.py +0 -0
  38. {cli2-4.2.10 → cli2-4.3.0}/cli2/table.py +0 -0
  39. {cli2-4.2.10 → cli2-4.3.0}/cli2/test.py +0 -0
  40. {cli2-4.2.10 → cli2-4.3.0}/cli2.egg-info/dependency_links.txt +0 -0
  41. {cli2-4.2.10 → cli2-4.3.0}/cli2.egg-info/entry_points.txt +0 -0
  42. {cli2-4.2.10 → cli2-4.3.0}/cli2.egg-info/requires.txt +0 -0
  43. {cli2-4.2.10 → cli2-4.3.0}/cli2.egg-info/top_level.txt +0 -0
  44. {cli2-4.2.10 → cli2-4.3.0}/setup.cfg +0 -0
  45. {cli2-4.2.10 → cli2-4.3.0}/tests/test_ansible_variables.py +0 -0
  46. {cli2-4.2.10 → cli2-4.3.0}/tests/test_asyncio.py +0 -0
  47. {cli2-4.2.10 → cli2-4.3.0}/tests/test_cli.py +0 -0
  48. {cli2-4.2.10 → cli2-4.3.0}/tests/test_command.py +0 -0
  49. {cli2-4.2.10 → cli2-4.3.0}/tests/test_configuration.py +0 -0
  50. {cli2-4.2.10 → cli2-4.3.0}/tests/test_decorators.py +0 -0
  51. {cli2-4.2.10 → cli2-4.3.0}/tests/test_display.py +0 -0
  52. {cli2-4.2.10 → cli2-4.3.0}/tests/test_entry_point.py +0 -0
  53. {cli2-4.2.10 → cli2-4.3.0}/tests/test_group.py +0 -0
  54. {cli2-4.2.10 → cli2-4.3.0}/tests/test_inject.py +0 -0
  55. {cli2-4.2.10 → cli2-4.3.0}/tests/test_lock.py +0 -0
  56. {cli2-4.2.10 → cli2-4.3.0}/tests/test_node.py +0 -0
  57. {cli2-4.2.10 → cli2-4.3.0}/tests/test_restful.py +0 -0
  58. {cli2-4.2.10 → cli2-4.3.0}/tests/test_table.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: cli2
3
- Version: 4.2.10
3
+ Version: 4.3.0
4
4
  Summary: image:: https://yourlabs.io/oss/cli2/badges/master/pipeline.svg
5
5
  Home-page: https://yourlabs.io/oss/cli2
6
6
  Author: James Pic
@@ -41,6 +41,7 @@ except ImportError:
41
41
  from .display import diff, diff_data, render, print, highlight, yaml_highlight
42
42
  from .lock import Lock
43
43
  from .log import configure, log
44
+ from .mask import Mask
44
45
  from .table import Table
45
46
 
46
47
 
@@ -130,37 +130,22 @@ class ActionBase(ActionBase):
130
130
  The client object generated by :py:meth:`client_factory` if you
131
131
  implement it.
132
132
 
133
- .. py:attribute:: mask
134
-
135
- List of result keys to mask, if set, this module will print the result
136
- with masked values, so you can set no_log: True for the task and still
137
- see most of the result.
133
+ .. py:attribute:: mask_keys
138
134
 
139
- .. py:attribute:: masked_keys
135
+ Declare a list of keys to mask:
140
136
 
141
- Property which returns the list of keys in :py:attr:`mask` + those in
142
- the `mask` fact + those in the client if any.
137
+ .. code-block:: python
143
138
 
144
- .. py:attribute:: masked_values
139
+ class AnsibleModule(ansible.ActionBase):
140
+ mask_keys = ['secret', 'password']
145
141
 
146
- First, the plugin will iterate over args and facts to learn all values
147
- which are in :py:attr:`masked_keys`.
142
+ .. py:attribute:: mask
148
143
 
149
- This is then both used and provisioned by :py:meth:`mask_data()`.
144
+ :py:class:`~cli2.mask.Mask` object
150
145
  """
151
- mask = None
152
- masked = Option(fact='mask', default=[])
153
- masked_values = None
154
-
155
- @property
156
- def masked_keys(self):
157
- result = []
158
- if self.mask:
159
- result += self.mask
160
- result += self.masked
161
- if self.client and self.client.mask:
162
- result += self.client.mask
163
- return result
146
+ masked_keys = Option(fact='mask_keys', default=[])
147
+ masked_values = Option(fact='mask_values', default=[])
148
+ mask_keys = None
164
149
 
165
150
  def get(self, arg_name=None, fact_name=None, default=UNSET_DEFAULT):
166
151
  if arg_name and arg_name in self._task.args:
@@ -181,18 +166,26 @@ class ActionBase(ActionBase):
181
166
  asyncio.run(self.run_wrapped_async())
182
167
  return self.result
183
168
 
184
- def collect_masked_values(self):
185
- # collect all values that have to be masked
186
- self.masked_values = []
187
- for key in self.masked_keys:
188
- if self.task_vars.get(key, None):
189
- self.masked_values.append(
190
- self._templar.template(self.task_vars[key])
191
- )
192
- if self._task.args.get(key, None):
193
- self.masked_values.append(
194
- self._templar.template(self._task.args[key])
195
- )
169
+ def mask_init(self):
170
+ # use ansible template value renderer
171
+ self.mask.renderer = lambda value: self._templar.template(value)
172
+
173
+ keys = self.masked_keys + (self.mask_keys or [])
174
+ for key in keys:
175
+ self.mask.keys.add(key)
176
+
177
+ # discover values from facts
178
+ if value := self.task_vars.get(key, None):
179
+ self.mask.values.add(value)
180
+
181
+ # discover value from args
182
+ if value := self._task.args.get(key, None):
183
+ self.mask.values.add(value)
184
+
185
+ # discover values to mask
186
+ for value in self.masked_values:
187
+ if value:
188
+ self.mask.values.add(value)
196
189
 
197
190
  async def run_wrapped_async(self):
198
191
  self.verbosity = self.task_vars.get('ansible_verbosity', 0)
@@ -209,7 +202,10 @@ class ActionBase(ActionBase):
209
202
  self.client = await self.client_factory()
210
203
  except NotImplementedError:
211
204
  self.client = None
212
- self.collect_masked_values()
205
+ self.mask = cli2.Mask()
206
+ else:
207
+ self.mask = copy.deepcopy(self.client.mask)
208
+ self.mask_init()
213
209
  await self.run_async()
214
210
  except Exception as exc:
215
211
  self.result['failed'] = True
@@ -244,10 +240,10 @@ class ActionBase(ActionBase):
244
240
  ):
245
241
  diff = difflib.unified_diff(
246
242
  to_nice_yaml(
247
- self.mask_data(self._before_data)
243
+ self.mask(self._before_data)
248
244
  ).splitlines(),
249
245
  to_nice_yaml(
250
- self.mask_data(self._after_data)
246
+ self.mask(self._after_data)
251
247
  ).splitlines(),
252
248
  self._before_label,
253
249
  self._after_label,
@@ -258,7 +254,7 @@ class ActionBase(ActionBase):
258
254
  """
259
255
  Render data as masked yaml.
260
256
  """
261
- data = self.mask_data(data)
257
+ data = self.mask(data)
262
258
  yaml = to_nice_yaml(data)
263
259
  rendered = cli2.yaml_highlight(yaml)
264
260
  self.print(rendered)
@@ -267,42 +263,6 @@ class ActionBase(ActionBase):
267
263
  # this serves for mocking
268
264
  print(data)
269
265
 
270
- def mask_data(self, data):
271
- """"
272
- Do our best to mask sensitive values in the data param recursively.
273
-
274
- - when data is a dict: it is recursively iterated on, any value that in
275
- is :py:attr:`masked_keys` will have it's value replaced with
276
- ``***MASKED***``, also, the value is added to
277
- :py:attr:`masked_values`.
278
- - when data is a string, each :py:attr:`masked_values` will be replaced
279
- with ``***MASKED***``, so we're actually able to mask sensitive
280
- information from stdout outputs and the likes.
281
- - when data is a list, each item is passed to :py:meth:`mask_data()`.
282
-
283
- Note that the :envvar:`DEBUG` environment variable will prevent any
284
- masking at all.
285
- """
286
- if os.getenv('DEBUG'):
287
- return data
288
-
289
- return self._mask(copy.deepcopy(data))
290
-
291
- def _mask(self, data):
292
- if isinstance(data, dict):
293
- for key, value in data.items():
294
- if key in self.masked_keys:
295
- self.masked_values.append(self._templar.template(value))
296
- data[key] = '***MASKED***'
297
- else:
298
- data[key] = self.mask_data(value)
299
- elif isinstance(data, list):
300
- return [self.mask_data(item) for item in data]
301
- elif isinstance(data, str):
302
- for value in self.masked_values:
303
- data = data.replace(str(value), '***MASKED***')
304
- return data
305
-
306
266
  async def run_async(self):
307
267
  """
308
268
  The method you are supposed to implement.
@@ -406,7 +366,7 @@ class ActionBase(ActionBase):
406
366
  if self.verbosity:
407
367
  display.display(
408
368
  f'<{self.task_vars["inventory_hostname"]}> + '
409
- + self.mask_data(cmd),
369
+ + self.mask(cmd),
410
370
  color='blue',
411
371
  )
412
372
  shell_action = self._shared_loader_obj.action_loader.get(
@@ -422,9 +382,9 @@ class ActionBase(ActionBase):
422
382
 
423
383
  if self.verbosity:
424
384
  if 'stderr_lines' in result:
425
- print(self.mask_data(result['stderr']))
385
+ self.print(self.mask(result['stderr']))
426
386
  if 'stdout_lines' in result:
427
- print(self.mask_data(result['stdout']))
387
+ self.print(self.mask(result['stdout']))
428
388
 
429
389
  result.pop('invocation')
430
390
  return result
@@ -26,6 +26,7 @@ from .asyncio import async_resolve
26
26
  from .cli import Argument, Command, Group, cmd, hide
27
27
  from .colors import colors
28
28
  from .log import log
29
+ from .mask import Mask
29
30
 
30
31
 
31
32
  class Paginator:
@@ -850,7 +851,7 @@ class Model(metaclass=ModelMetaclass):
850
851
 
851
852
  @property
852
853
  def data_masked(self):
853
- return self.client.mask_data(self.data)
854
+ return self.client.mask(self.data)
854
855
 
855
856
  @classmethod
856
857
  @hide('expressions')
@@ -1098,7 +1099,7 @@ class Handler:
1098
1099
  self.tries = self.tries_default if tries is None else tries
1099
1100
  self.backoff = self.backoff_default if backoff is None else backoff
1100
1101
 
1101
- async def __call__(self, client, response, tries, mask, log):
1102
+ async def __call__(self, client, response, tries, log):
1102
1103
  seconds = tries * self.backoff
1103
1104
 
1104
1105
  if isinstance(response, Exception):
@@ -1126,24 +1127,24 @@ class Handler:
1126
1127
  return response
1127
1128
 
1128
1129
  if response.status_code in self.refuses:
1129
- raise RefusedResponseError(client, response, tries, mask)
1130
+ raise RefusedResponseError(client, response, tries)
1130
1131
 
1131
1132
  if tries >= self.tries:
1132
- raise RetriesExceededError(client, response, tries, mask)
1133
+ raise RetriesExceededError(client, response, tries)
1133
1134
 
1134
1135
  kwargs = dict(
1135
1136
  status_code=response.status_code,
1136
1137
  tries=tries,
1137
1138
  sleep=seconds,
1138
1139
  )
1139
- key, value = client.response_log_data(response, mask)
1140
+ key, value = client.response_log_data(response)
1140
1141
  if value:
1141
1142
  kwargs[key] = value
1142
1143
 
1143
1144
  if response.status_code in self.retokens:
1144
1145
  if tries:
1145
1146
  # our authentication is just not working, no need to retry
1146
- raise TokenGetError(client, response, tries, mask)
1147
+ raise TokenGetError(client, response, tries)
1147
1148
  log.warn('retoken', **kwargs)
1148
1149
  await client.token_reset()
1149
1150
 
@@ -1179,11 +1180,10 @@ class ResponseError(ClientError):
1179
1180
 
1180
1181
  Request method
1181
1182
  """
1182
- def __init__(self, client, response, tries, mask, msg=None):
1183
+ def __init__(self, client, response, tries, msg=None):
1183
1184
  self.client = client
1184
1185
  self.response = response
1185
1186
  self.tries = tries
1186
- self.mask = mask
1187
1187
  self.msg = msg or getattr(self, 'msg', '').format(self=self)
1188
1188
  super().__init__(self.enhance(self.msg))
1189
1189
 
@@ -1212,10 +1212,7 @@ class ResponseError(ClientError):
1212
1212
  :param exc: httpx.HTTPStatusError
1213
1213
  """
1214
1214
  output = [msg]
1215
- key, value = self.client.request_log_data(
1216
- self.response.request,
1217
- self.mask,
1218
- )
1215
+ key, value = self.client.request_log_data(self.response.request)
1219
1216
  request_msg = ' '.join([
1220
1217
  str(self.response.request.method),
1221
1218
  str(self.response.request.url),
@@ -1227,7 +1224,7 @@ class ResponseError(ClientError):
1227
1224
  if value:
1228
1225
  output.append(display.render(value))
1229
1226
 
1230
- key, value = self.client.response_log_data(self.response, self.mask)
1227
+ key, value = self.client.response_log_data(self.response)
1231
1228
  output.append(
1232
1229
  ''.join([
1233
1230
  colors.bold,
@@ -1318,9 +1315,14 @@ class Client(metaclass=ClientMetaclass):
1318
1315
  retry the request, or raise an exception, or return the request.
1319
1316
  Default is a :py:class:`Handler`
1320
1317
 
1321
- .. py:attribute:: mask
1318
+ .. py:attribute:: mask_keys
1319
+
1320
+ Use this class attribute to declare keys to mask:
1322
1321
 
1323
- List of keys to mask in logging, ie.: ``['password', 'secret']``
1322
+ .. code-block:: python
1323
+
1324
+ class YourClient(cli2.Client):
1325
+ mask_keys = ['password', 'secret']
1324
1326
 
1325
1327
  .. py:attribute:: cli
1326
1328
 
@@ -1350,6 +1352,10 @@ class Client(metaclass=ClientMetaclass):
1350
1352
  Enforce full logging: quiet requests are logged, masking does not
1351
1353
  apply. This is also enabled with environment variable ``DEBUG``.
1352
1354
 
1355
+ .. py:attribute:: mask
1356
+
1357
+ :py:class:`~cli2.mask.Mask` object
1358
+
1353
1359
  .. py:attribute:: models
1354
1360
 
1355
1361
  Declared models for this Client.
@@ -1357,9 +1363,9 @@ class Client(metaclass=ClientMetaclass):
1357
1363
  paginator = Paginator
1358
1364
  models = []
1359
1365
  semaphore = None
1360
- mask = []
1361
1366
  debug = False
1362
1367
  cmdclass = ClientCommand
1368
+ mask_keys = None
1363
1369
 
1364
1370
  def __init__(self, *args, handler=None, semaphore=None, mask=None,
1365
1371
  debug=False, **kwargs):
@@ -1373,8 +1379,12 @@ class Client(metaclass=ClientMetaclass):
1373
1379
 
1374
1380
  self.handler = handler or Handler()
1375
1381
  self.semaphore = semaphore if semaphore else self.semaphore
1376
- self.mask = mask if mask else self.mask
1382
+ self.mask = mask or Mask()
1383
+ if self.mask_keys:
1384
+ for key in self.mask_keys:
1385
+ self.mask.keys.add(key)
1377
1386
  self.debug = debug or os.getenv('DEBUG', self.debug)
1387
+ self.mask.debug = self.debug
1378
1388
 
1379
1389
  if truststore:
1380
1390
  self._client_kwargs.setdefault(
@@ -1444,7 +1454,7 @@ class Client(metaclass=ClientMetaclass):
1444
1454
  def client(self):
1445
1455
  self._client = None
1446
1456
 
1447
- async def send(self, request, handler, mask, retries=True, semaphore=None,
1457
+ async def send(self, request, handler, retries=True, semaphore=None,
1448
1458
  log=None, auth=None, follow_redirects=None):
1449
1459
  """
1450
1460
  Internal request method
@@ -1469,9 +1479,9 @@ class Client(metaclass=ClientMetaclass):
1469
1479
  try:
1470
1480
  response = await _request()
1471
1481
  except Exception as exc:
1472
- await handler(self, exc, tries, mask, log)
1482
+ await handler(self, exc, tries, log)
1473
1483
  else:
1474
- if response := await handler(self, response, tries, mask, log):
1484
+ if response := await handler(self, response, tries, log):
1475
1485
  return response
1476
1486
 
1477
1487
  tries += 1
@@ -1605,7 +1615,6 @@ class Client(metaclass=ClientMetaclass):
1605
1615
  :param tries: Override for :py:attr:`Handler.tries`
1606
1616
  :param backoff: Override for :py:attr:`Handler.backoff`
1607
1617
  :param semaphore: Override for :py:attr:`Client.semaphore`
1608
- :param mask: Override for :py:attr:`Client.mask`
1609
1618
  """
1610
1619
  if not self.token and not self.token_getting:
1611
1620
  await self.token_refresh()
@@ -1644,7 +1653,7 @@ class Client(metaclass=ClientMetaclass):
1644
1653
 
1645
1654
  _log = log.bind(method=method, url=str(request.url))
1646
1655
  if not quiet or self.debug:
1647
- key, value = self.request_log_data(request, mask, quiet)
1656
+ key, value = self.request_log_data(request, quiet)
1648
1657
  kwargs = dict()
1649
1658
  if value:
1650
1659
  kwargs[key] = value
@@ -1658,7 +1667,6 @@ class Client(metaclass=ClientMetaclass):
1658
1667
  handler=handler,
1659
1668
  retries=retries,
1660
1669
  semaphore=semaphore,
1661
- mask=mask,
1662
1670
  log=log,
1663
1671
  auth=auth,
1664
1672
  follow_redirects=follow_redirects,
@@ -1666,7 +1674,7 @@ class Client(metaclass=ClientMetaclass):
1666
1674
 
1667
1675
  kwargs = dict(status_code=response.status_code)
1668
1676
  if not quiet or self.debug:
1669
- key, value = self.response_log_data(response, mask)
1677
+ key, value = self.response_log_data(response)
1670
1678
  if value:
1671
1679
  kwargs[key] = value
1672
1680
 
@@ -1674,20 +1682,18 @@ class Client(metaclass=ClientMetaclass):
1674
1682
 
1675
1683
  return response
1676
1684
 
1677
- def response_log_data(self, response, mask=None):
1678
- mask = mask if mask is not None else self.mask
1685
+ def response_log_data(self, response):
1679
1686
  try:
1680
1687
  data = response.json()
1681
1688
  except json.JSONDecodeError:
1682
1689
  if response.content:
1683
- return 'content', self.mask_content(response.content, mask)
1690
+ return 'content', self.mask(response.content)
1684
1691
  else:
1685
1692
  if data:
1686
- return 'json', self.mask_data(data, mask)
1693
+ return 'json', self.mask(data)
1687
1694
  return None, None
1688
1695
 
1689
- def request_log_data(self, request, mask=None, quiet=False):
1690
- mask = mask if mask is not None else self.mask
1696
+ def request_log_data(self, request, quiet=False):
1691
1697
  content = request.content.decode()
1692
1698
  if not content:
1693
1699
  return None, None
@@ -1697,7 +1703,7 @@ class Client(metaclass=ClientMetaclass):
1697
1703
  except json.JSONDecodeError:
1698
1704
  pass
1699
1705
  else:
1700
- return 'json', self.mask_data(data, mask)
1706
+ return 'json', self.mask(data)
1701
1707
 
1702
1708
  parsed = parse_qs(content)
1703
1709
  if parsed:
@@ -1705,37 +1711,9 @@ class Client(metaclass=ClientMetaclass):
1705
1711
  key: value[0] if len(value) == 1 else value
1706
1712
  for key, value in parsed.items()
1707
1713
  }
1708
- return 'data', self.mask_data(data, mask)
1714
+ return 'data', self.mask(data)
1709
1715
 
1710
- return 'content', self.mask_content(content, mask)
1711
-
1712
- def mask_content(self, content, mask=None):
1713
- """
1714
- Implement content masking for non JSON content here.
1715
- """
1716
- return content
1717
-
1718
- def mask_data(self, data, mask=None):
1719
- """
1720
- Apply mask for all :py:attr:`mask` in data.
1721
- """
1722
- if self.debug:
1723
- return data
1724
-
1725
- mask = mask if mask is not None else self.mask
1726
-
1727
- if isinstance(data, list):
1728
- return [self.mask_data(item, mask) for item in data]
1729
-
1730
- if isinstance(data, dict):
1731
- for key, value in data.items():
1732
- if key in mask:
1733
- data[key] = '***MASKED***'
1734
- if isinstance(value, dict):
1735
- data[key] = self.mask_data(value, mask)
1736
- if isinstance(value, list):
1737
- data[key] = [self.mask_data(item, mask) for item in value]
1738
- return data
1716
+ return 'content', self.mask(content)
1739
1717
 
1740
1718
  return data
1741
1719
 
@@ -13,7 +13,7 @@ class APIClient(cli2.Client):
13
13
  ./manage.py migrate
14
14
  ./manage.py runserver
15
15
  """
16
- mask = ["Capacity"]
16
+ mask_keys = ['Capacity']
17
17
 
18
18
  def __init__(self, *args, **kwargs):
19
19
  kwargs.setdefault('base_url', 'http://localhost:8000')
@@ -0,0 +1,121 @@
1
+ """
2
+ Secret data masking module.
3
+ """
4
+
5
+ import copy
6
+ import os
7
+
8
+
9
+ class LearnedError(Exception):
10
+ """
11
+ Raised when a new value is learned, to run masking again.
12
+ """
13
+ pass
14
+
15
+
16
+ class Mask:
17
+ """
18
+ Masking object that can learn values.
19
+
20
+ .. code-block:: python
21
+
22
+ mask = cli2.Mask(keys=['password'], values=['1337p4ssw0rD'])
23
+ result = mask(dict(password='xx', text='some 1337p4ssw0rD noise xx'))
24
+
25
+ Will cause result to be:
26
+
27
+ .. code-block:: yaml
28
+
29
+ password: ***MASKED***
30
+ text: some ***MASKED*** noise ***MASKED***
31
+
32
+ Because:
33
+
34
+ - ``1337p4ssw0rD`` was given as a value to mask
35
+ - ``xx`` was the value of a key named ``password`` which was given as a key
36
+ to mask
37
+
38
+ .. py:attribute:: keys
39
+
40
+ Set of keys that contain values to mask
41
+
42
+ .. py:attribute:: values
43
+
44
+ Set of values to mask
45
+
46
+ .. py:attribute:: renderer
47
+
48
+ Optionnal callback to render discovered values to mask
49
+
50
+ .. py:attribute:: debug
51
+
52
+ Enabled by the :envvar:`DEBUG` environment variable, makes this a no-op
53
+ (don't mask anything).
54
+ """
55
+
56
+ def __init__(self, keys=None, values=None, renderer=None, debug=False):
57
+ self.keys = set(keys) if keys else set()
58
+ self.values = set(values) if values else set()
59
+ self.renderer = renderer
60
+ if os.getenv('DEBUG'):
61
+ self.debug = True
62
+ else:
63
+ self.debug = debug
64
+
65
+ def __call__(self, data):
66
+ """"
67
+ Do our best to mask sensitive values in the data param recursively,
68
+ returning a masked copy of the passed data.
69
+
70
+ - when data is a dict: it is recursively iterated on, any value that in
71
+ is :py:attr:`keys` will have it's value replaced with
72
+ ``***MASKED***``, also, the value is added to
73
+ :py:attr:`values`.
74
+ - when data is a string, each :py:attr:`values` will be replaced
75
+ with ``***MASKED***``, so we're actually able to mask sensitive
76
+ information from stdout outputs and the likes.
77
+ - when data is a list, each item is passed to :py:meth:`_mask()`.
78
+
79
+ Note that the :envvar:`DEBUG` environment variable will prevent any
80
+ masking at all.
81
+
82
+ :param data: Any kind of data to mask, will return a deepcopy of that.
83
+ """
84
+ if self.debug:
85
+ return data
86
+
87
+ while True:
88
+ try:
89
+ return self._mask(copy.deepcopy(data))
90
+ except LearnedError:
91
+ continue
92
+ else:
93
+ break
94
+
95
+ def _mask(self, data):
96
+ """
97
+ Actual, in-place masking method.
98
+ """
99
+ if isinstance(data, dict):
100
+ for key, value in data.items():
101
+ if key in self.keys:
102
+ if self.renderer:
103
+ value = self.renderer(value)
104
+ if value not in self.values:
105
+ self.values.add(value)
106
+ raise LearnedError()
107
+ data[key] = '***MASKED***'
108
+ else:
109
+ data[key] = self._mask(value)
110
+ elif isinstance(data, list):
111
+ return [self._mask(item) for item in data]
112
+ elif isinstance(data, str):
113
+ for value in self.values:
114
+ data = data.replace(str(value), '***MASKED***')
115
+ return data
116
+
117
+ def __repr__(self):
118
+ result = 'Mask(keys=[' + ', '.join(self.keys) + ']'
119
+ if self.values:
120
+ result += f', number_of_values={len(self.values)}'
121
+ return result + ')'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: cli2
3
- Version: 4.2.10
3
+ Version: 4.3.0
4
4
  Summary: image:: https://yourlabs.io/oss/cli2/badges/master/pipeline.svg
5
5
  Home-page: https://yourlabs.io/oss/cli2
6
6
  Author: James Pic
@@ -13,6 +13,7 @@ cli2/decorators.py
13
13
  cli2/display.py
14
14
  cli2/lock.py
15
15
  cli2/log.py
16
+ cli2/mask.py
16
17
  cli2/node.py
17
18
  cli2/sphinx.py
18
19
  cli2/table.py
@@ -49,6 +50,7 @@ tests/test_entry_point.py
49
50
  tests/test_group.py
50
51
  tests/test_inject.py
51
52
  tests/test_lock.py
53
+ tests/test_mask.py
52
54
  tests/test_node.py
53
55
  tests/test_restful.py
54
56
  tests/test_table.py
@@ -44,7 +44,7 @@ from setuptools import setup
44
44
 
45
45
  setup(
46
46
  name='cli2',
47
- version='4.2.10',
47
+ version='4.3.0',
48
48
  setup_requires='setupmeta',
49
49
  packages=['cli2'],
50
50
  install_requires=[
@@ -9,7 +9,7 @@ from cli2 import ansible
9
9
 
10
10
 
11
11
  class ActionModule(ansible.ActionBase):
12
- mask = ['a']
12
+ mask_keys = ['a']
13
13
 
14
14
  async def run_async(self):
15
15
  self.result['x'] = dict(a='a', b='b', c='c', d='foo a rrr')
@@ -19,7 +19,7 @@ class ActionModule(ansible.ActionBase):
19
19
  async def test_mask(monkeypatch):
20
20
  printer = mock.Mock()
21
21
  monkeypatch.setattr(ActionModule, 'print', printer)
22
- module = await ActionModule.run_test_async(facts=dict(mask=['b']))
22
+ module = await ActionModule.run_test_async(facts=dict(mask_keys=['b']))
23
23
  # result is untouched
24
24
  assert module.result == {'x':
25
25
  {'a': 'a', 'b': 'b', 'c': 'c', 'd': 'foo a rrr'}
@@ -32,7 +32,7 @@ async def test_mask(monkeypatch):
32
32
  @pytest.mark.asyncio
33
33
  async def test_response_error(httpx_mock):
34
34
  class Client(cli2.Client):
35
- mask = ['secret']
35
+ mask_keys = ['secret']
36
36
 
37
37
  class Action(ansible.ActionBase):
38
38
  async def client_factory(self):
@@ -16,9 +16,9 @@ class HandlerSentinel(cli2.Handler):
16
16
  super().__init__(*args, **kwargs)
17
17
  self.calls = []
18
18
 
19
- async def __call__(self, client, response, tries, mask, log):
19
+ async def __call__(self, client, response, tries, log):
20
20
  self.calls.append((client, response.status_code, tries))
21
- return await super().__call__(client, response, tries, mask, log)
21
+ return await super().__call__(client, response, tries, log)
22
22
 
23
23
 
24
24
  @pytest.fixture
@@ -256,11 +256,11 @@ async def test_handler(client_class):
256
256
  handler = cli2.Handler(accepts=[201], refuses=[218], retokens=[418])
257
257
 
258
258
  response = httpx.Response(status_code=201)
259
- result = await handler(client, response, 0, [], log)
259
+ result = await handler(client, response, 0, log)
260
260
  assert result == response
261
261
 
262
262
  response = httpx.Response(status_code=200)
263
- result = await handler(client, response, 0, [], log)
263
+ result = await handler(client, response, 0, log)
264
264
  log.info.assert_called_once_with(
265
265
  'retry', status_code=200, tries=0, sleep=.0
266
266
  )
@@ -269,7 +269,7 @@ async def test_handler(client_class):
269
269
  response = httpx.Response(status_code=200, content='[2]')
270
270
  response.request = httpx.Request('POST', '/', json=[1])
271
271
  with pytest.raises(cli2.RetriesExceededError) as exc:
272
- await handler(client, response, handler.tries + 1, [], log)
272
+ await handler(client, response, handler.tries + 1, log)
273
273
  log.info.assert_called_once_with(
274
274
  'retry', status_code=200, tries=0, sleep=.0
275
275
  )
@@ -280,7 +280,7 @@ async def test_handler(client_class):
280
280
  response = httpx.Response(status_code=200)
281
281
  response.request = httpx.Request('GET', '/')
282
282
  with pytest.raises(cli2.RetriesExceededError) as exc:
283
- await handler(client, response, handler.tries + 1, [], log)
283
+ await handler(client, response, handler.tries + 1, log)
284
284
 
285
285
  msg = 'Unacceptable response <Response [200 OK]> after 31 tries\n\x1b[0m\x1b[1mGET /\x1b[0m\n\x1b[1mHTTP 200\x1b[0m' # noqa
286
286
  assert str(exc.value) == msg
@@ -288,7 +288,7 @@ async def test_handler(client_class):
288
288
  response = httpx.Response(status_code=218)
289
289
  response.request = httpx.Request('POST', '/')
290
290
  with pytest.raises(cli2.RefusedResponseError) as exc:
291
- await handler(client, response, 1, [], log)
291
+ await handler(client, response, 1, log)
292
292
 
293
293
  assert exc.value.response
294
294
  assert exc.value.request
@@ -299,12 +299,12 @@ async def test_handler(client_class):
299
299
  response = httpx.Response(status_code=418)
300
300
  response.request = httpx.Request('POST', '/')
301
301
  with pytest.raises(cli2.TokenGetError):
302
- await handler(client, response, 1, [], log)
302
+ await handler(client, response, 1, log)
303
303
 
304
304
  assert not client.client_reset.await_count
305
305
  exc = httpx.TransportError('foo')
306
306
  exc.request = response.request
307
- result = await handler(client, exc, 0, [], log)
307
+ result = await handler(client, exc, 0, log)
308
308
  log.warn.assert_called_once_with(
309
309
  'reconnect',
310
310
  error="TransportError('foo')",
@@ -316,13 +316,13 @@ async def test_handler(client_class):
316
316
 
317
317
  with pytest.raises(httpx.TransportError) as exc:
318
318
  await handler(
319
- client, httpx.TransportError('x'), handler.tries + 1, [], log
319
+ client, httpx.TransportError('x'), handler.tries + 1, log
320
320
  )
321
321
 
322
322
  response = httpx.Response(status_code=418)
323
323
  assert not client.token_reset.await_count
324
324
  log.warn.reset_mock()
325
- result = await handler(client, response, 0, [], log)
325
+ result = await handler(client, response, 0, log)
326
326
  log.warn.assert_called_once_with(
327
327
  'retoken', status_code=418, tries=0, sleep=.0
328
328
  )
@@ -332,7 +332,7 @@ async def test_handler(client_class):
332
332
  handler = cli2.Handler(accepts=[], refuses=[222])
333
333
 
334
334
  response = httpx.Response(status_code=123)
335
- result = await handler(client, response, 0, [], log)
335
+ result = await handler(client, response, 0, log)
336
336
  assert result == response
337
337
 
338
338
 
@@ -867,7 +867,7 @@ def test_datetime_default_fmt(client_class):
867
867
 
868
868
  @pytest.mark.asyncio
869
869
  async def test_mask_recursive(client_class, monkeypatch):
870
- client = client_class(mask=['scrt', 'password'])
870
+ client = client_class(mask=cli2.Mask(['scrt', 'password']))
871
871
  client.client.send = mock.AsyncMock()
872
872
 
873
873
  log = mock.Mock()
@@ -901,7 +901,7 @@ async def test_mask_recursive(client_class, monkeypatch):
901
901
  'key', ('json', 'data'),
902
902
  )
903
903
  async def test_mask_logs(client_class, key, monkeypatch):
904
- client = client_class(mask=['scrt', 'password'])
904
+ client = client_class(mask=cli2.Mask(['scrt', 'password']))
905
905
  client.client.send = mock.AsyncMock()
906
906
 
907
907
  log = mock.Mock()
@@ -933,27 +933,30 @@ async def test_mask_logs(client_class, key, monkeypatch):
933
933
  @pytest.mark.asyncio
934
934
  async def test_mask_exceptions(client_class):
935
935
  class TestClient(client_class):
936
- mask = ['foo']
936
+ mask_keys = ['foo']
937
937
 
938
938
  client = TestClient()
939
939
 
940
940
  response = httpx.Response(status_code=218, content='{"c": 3, "d": 4}')
941
941
  response.request = httpx.Request('POST', '/', json=dict(a=1, b=2))
942
- error = cli2.ResponseError(client, response, 1, ['a', 'c'])
942
+ client.mask.keys.add('a')
943
+ client.mask.keys.add('c')
944
+ error = cli2.ResponseError(client, response, 1)
943
945
  expected = "\n\x1b[0m\x1b[1mPOST /\x1b[0m\n\x1b[94ma\x1b[39;49;00m:\x1b[37m \x1b[39;49;00m\x1b[33m'\x1b[39;49;00m\x1b[33m***MASKED***\x1b[39;49;00m\x1b[33m'\x1b[39;49;00m\x1b[37m\x1b[39;49;00m\n\x1b[94mb\x1b[39;49;00m:\x1b[37m \x1b[39;49;00m2\x1b[37m\x1b[39;49;00m\n\n\x1b[1mHTTP 218\x1b[0m\n\x1b[94mc\x1b[39;49;00m:\x1b[37m \x1b[39;49;00m\x1b[33m'\x1b[39;49;00m\x1b[33m***MASKED***\x1b[39;49;00m\x1b[33m'\x1b[39;49;00m\x1b[37m\x1b[39;49;00m\n\x1b[94md\x1b[39;49;00m:\x1b[37m \x1b[39;49;00m4\x1b[37m\x1b[39;49;00m\n" # noqa
944
946
  assert str(error) == expected
945
947
 
946
948
  # this needs to work with form data too
947
949
  response = httpx.Response(status_code=218, content='{"c": 3, "d": 4}')
948
950
  response.request = httpx.Request('POST', '/', data=dict(a=1, b=2))
949
- error = cli2.ResponseError(client, response, 1, ['a', 'c'])
951
+ client.mask = cli2.Mask(['a', 'c'])
952
+ error = cli2.ResponseError(client, response, 1)
950
953
  expected = "\n\x1b[0m\x1b[1mPOST /\x1b[0m\n\x1b[94ma\x1b[39;49;00m:\x1b[37m \x1b[39;49;00m\x1b[33m'\x1b[39;49;00m\x1b[33m***MASKED***\x1b[39;49;00m\x1b[33m'\x1b[39;49;00m\x1b[37m\x1b[39;49;00m\n\x1b[94mb\x1b[39;49;00m:\x1b[37m \x1b[39;49;00m\x1b[33m'\x1b[39;49;00m\x1b[33m2\x1b[39;49;00m\x1b[33m'\x1b[39;49;00m\x1b[37m\x1b[39;49;00m\n\n\x1b[1mHTTP 218\x1b[0m\n\x1b[94mc\x1b[39;49;00m:\x1b[37m \x1b[39;49;00m\x1b[33m'\x1b[39;49;00m\x1b[33m***MASKED***\x1b[39;49;00m\x1b[33m'\x1b[39;49;00m\x1b[37m\x1b[39;49;00m\n\x1b[94md\x1b[39;49;00m:\x1b[37m \x1b[39;49;00m4\x1b[37m\x1b[39;49;00m\n" # noqa
951
954
  assert str(error) == expected
952
955
 
953
956
 
954
957
  @pytest.mark.asyncio
955
958
  async def test_request_mask(client_class, monkeypatch):
956
- client = client_class(mask=['password'])
959
+ client = client_class(mask=cli2.Mask(['password']))
957
960
  client.client.send = mock.AsyncMock()
958
961
 
959
962
  log = mock.Mock()
@@ -965,7 +968,8 @@ async def test_request_mask(client_class, monkeypatch):
965
968
  data = dict(foo='bar', password='secret')
966
969
  response.request = httpx.Request('POST', '/', json=data)
967
970
  client.client.send.return_value = response
968
- await client.post('/', json=data, mask=['scrt'])
971
+ client.mask.keys.add('scrt')
972
+ await client.post('/', json=data)
969
973
  log.bind.assert_called_once_with(
970
974
  method='POST',
971
975
  url='http://lol/'
@@ -973,7 +977,7 @@ async def test_request_mask(client_class, monkeypatch):
973
977
  log = log.bind.return_value
974
978
  log.debug.assert_called_once_with(
975
979
  'request',
976
- json=dict(foo='bar', password='secret'),
980
+ json=dict(foo='bar', password='***MASKED***'),
977
981
  )
978
982
  log.info.assert_called_once_with(
979
983
  'response',
@@ -1025,11 +1029,11 @@ async def test_log_quiet(client_class, monkeypatch):
1025
1029
  def test_class_override(client_class):
1026
1030
  class TestClient(client_class):
1027
1031
  semaphore = 'foo'
1028
- mask = 'bar'
1032
+ mask_keys = ['bar']
1029
1033
  debug = True
1030
1034
 
1031
1035
  assert TestClient().semaphore == 'foo'
1032
- assert TestClient().mask == 'bar'
1036
+ assert TestClient().mask.keys == {'bar'}
1033
1037
  assert TestClient().debug
1034
1038
 
1035
1039
 
@@ -1081,7 +1085,7 @@ def test_id_value(client_class):
1081
1085
 
1082
1086
  @pytest.mark.asyncio
1083
1087
  async def test_debug(client_class, monkeypatch):
1084
- client = client_class(mask=['scrt', 'password'], debug=True)
1088
+ client = client_class(mask=cli2.Mask(['scrt', 'password']), debug=True)
1085
1089
  client.client.send = mock.AsyncMock()
1086
1090
 
1087
1091
  log = mock.Mock()
@@ -0,0 +1,22 @@
1
+ import cli2
2
+ import pytest
3
+
4
+
5
+ def test_mask():
6
+ mask = cli2.Mask(['seckey'], ['secval'])
7
+ fixture = {
8
+ 'a': {
9
+ 'b': 'secval b secret',
10
+ 'seckey': 'secret',
11
+ },
12
+ 'b': ['secval foo secret'],
13
+ }
14
+
15
+ expected = {
16
+ 'a': {
17
+ 'b': '***MASKED*** b ***MASKED***',
18
+ 'seckey': '***MASKED***',
19
+ },
20
+ 'b': ['***MASKED*** foo ***MASKED***']
21
+ }
22
+ assert mask(fixture) == expected
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes