cli2 4.2.10__tar.gz → 4.3.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 (58) hide show
  1. {cli2-4.2.10/cli2.egg-info → cli2-4.3.1}/PKG-INFO +1 -1
  2. {cli2-4.2.10 → cli2-4.3.1}/cli2/__init__.py +1 -0
  3. {cli2-4.2.10 → cli2-4.3.1}/cli2/ansible/action.py +50 -80
  4. {cli2-4.2.10 → cli2-4.3.1}/cli2/client.py +41 -62
  5. {cli2-4.2.10 → cli2-4.3.1}/cli2/examples/client.py +1 -1
  6. cli2-4.3.1/cli2/mask.py +124 -0
  7. {cli2-4.2.10 → cli2-4.3.1/cli2.egg-info}/PKG-INFO +1 -1
  8. {cli2-4.2.10 → cli2-4.3.1}/cli2.egg-info/SOURCES.txt +2 -0
  9. {cli2-4.2.10 → cli2-4.3.1}/setup.py +1 -1
  10. {cli2-4.2.10 → cli2-4.3.1}/tests/test_ansible.py +3 -3
  11. {cli2-4.2.10 → cli2-4.3.1}/tests/test_client.py +32 -23
  12. cli2-4.3.1/tests/test_mask.py +25 -0
  13. {cli2-4.2.10 → cli2-4.3.1}/MANIFEST.in +0 -0
  14. {cli2-4.2.10 → cli2-4.3.1}/README.rst +0 -0
  15. {cli2-4.2.10 → cli2-4.3.1}/classifiers.txt +0 -0
  16. {cli2-4.2.10 → cli2-4.3.1}/cli2/ansible/__init__.py +0 -0
  17. {cli2-4.2.10 → cli2-4.3.1}/cli2/ansible/playbook.py +0 -0
  18. {cli2-4.2.10 → cli2-4.3.1}/cli2/ansible/variables.py +0 -0
  19. {cli2-4.2.10 → cli2-4.3.1}/cli2/asyncio.py +0 -0
  20. {cli2-4.2.10 → cli2-4.3.1}/cli2/cli.py +0 -0
  21. {cli2-4.2.10 → cli2-4.3.1}/cli2/cli2.py +0 -0
  22. {cli2-4.2.10 → cli2-4.3.1}/cli2/colors.py +0 -0
  23. {cli2-4.2.10 → cli2-4.3.1}/cli2/configuration.py +0 -0
  24. {cli2-4.2.10 → cli2-4.3.1}/cli2/decorators.py +0 -0
  25. {cli2-4.2.10 → cli2-4.3.1}/cli2/display.py +0 -0
  26. {cli2-4.2.10 → cli2-4.3.1}/cli2/examples/__init__.py +0 -0
  27. {cli2-4.2.10 → cli2-4.3.1}/cli2/examples/conf.py +0 -0
  28. {cli2-4.2.10 → cli2-4.3.1}/cli2/examples/example.py +0 -0
  29. {cli2-4.2.10 → cli2-4.3.1}/cli2/examples/example_obj.py +0 -0
  30. {cli2-4.2.10 → cli2-4.3.1}/cli2/examples/nesting.py +0 -0
  31. {cli2-4.2.10 → cli2-4.3.1}/cli2/examples/obj.py +0 -0
  32. {cli2-4.2.10 → cli2-4.3.1}/cli2/examples/obj2.py +0 -0
  33. {cli2-4.2.10 → cli2-4.3.1}/cli2/examples/test.py +0 -0
  34. {cli2-4.2.10 → cli2-4.3.1}/cli2/lock.py +0 -0
  35. {cli2-4.2.10 → cli2-4.3.1}/cli2/log.py +0 -0
  36. {cli2-4.2.10 → cli2-4.3.1}/cli2/node.py +0 -0
  37. {cli2-4.2.10 → cli2-4.3.1}/cli2/sphinx.py +0 -0
  38. {cli2-4.2.10 → cli2-4.3.1}/cli2/table.py +0 -0
  39. {cli2-4.2.10 → cli2-4.3.1}/cli2/test.py +0 -0
  40. {cli2-4.2.10 → cli2-4.3.1}/cli2.egg-info/dependency_links.txt +0 -0
  41. {cli2-4.2.10 → cli2-4.3.1}/cli2.egg-info/entry_points.txt +0 -0
  42. {cli2-4.2.10 → cli2-4.3.1}/cli2.egg-info/requires.txt +0 -0
  43. {cli2-4.2.10 → cli2-4.3.1}/cli2.egg-info/top_level.txt +0 -0
  44. {cli2-4.2.10 → cli2-4.3.1}/setup.cfg +0 -0
  45. {cli2-4.2.10 → cli2-4.3.1}/tests/test_ansible_variables.py +0 -0
  46. {cli2-4.2.10 → cli2-4.3.1}/tests/test_asyncio.py +0 -0
  47. {cli2-4.2.10 → cli2-4.3.1}/tests/test_cli.py +0 -0
  48. {cli2-4.2.10 → cli2-4.3.1}/tests/test_command.py +0 -0
  49. {cli2-4.2.10 → cli2-4.3.1}/tests/test_configuration.py +0 -0
  50. {cli2-4.2.10 → cli2-4.3.1}/tests/test_decorators.py +0 -0
  51. {cli2-4.2.10 → cli2-4.3.1}/tests/test_display.py +0 -0
  52. {cli2-4.2.10 → cli2-4.3.1}/tests/test_entry_point.py +0 -0
  53. {cli2-4.2.10 → cli2-4.3.1}/tests/test_group.py +0 -0
  54. {cli2-4.2.10 → cli2-4.3.1}/tests/test_inject.py +0 -0
  55. {cli2-4.2.10 → cli2-4.3.1}/tests/test_lock.py +0 -0
  56. {cli2-4.2.10 → cli2-4.3.1}/tests/test_node.py +0 -0
  57. {cli2-4.2.10 → cli2-4.3.1}/tests/test_restful.py +0 -0
  58. {cli2-4.2.10 → cli2-4.3.1}/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.1
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,27 @@ 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)
189
+ self.mask_initial_values = self.mask.values
196
190
 
197
191
  async def run_wrapped_async(self):
198
192
  self.verbosity = self.task_vars.get('ansible_verbosity', 0)
@@ -209,7 +203,10 @@ class ActionBase(ActionBase):
209
203
  self.client = await self.client_factory()
210
204
  except NotImplementedError:
211
205
  self.client = None
212
- self.collect_masked_values()
206
+ self.mask = cli2.Mask()
207
+ else:
208
+ self.mask = copy.deepcopy(self.client.mask)
209
+ self.mask_init()
213
210
  await self.run_async()
214
211
  except Exception as exc:
215
212
  self.result['failed'] = True
@@ -236,18 +233,27 @@ class ActionBase(ActionBase):
236
233
  self.exc = exc
237
234
  finally:
238
235
  if self.mask and self.verbosity:
236
+ # this task has a mask, so it's probably a no_log, we're
237
+ # running verbose, print masked result
239
238
  self.print_yaml(self.result)
240
239
 
240
+ if self.mask_initial_values != self.mask.values:
241
+ # this module has discovered new values to mask, update the
242
+ # fact with those values
243
+ self.result['ansible_facts'] = dict(
244
+ mask_values=self.mask.values,
245
+ )
246
+
241
247
  if (
242
248
  self._before_data != UNSET_DEFAULT
243
249
  and self._after_data != UNSET_DEFAULT
244
250
  ):
245
251
  diff = difflib.unified_diff(
246
252
  to_nice_yaml(
247
- self.mask_data(self._before_data)
253
+ self.mask(self._before_data)
248
254
  ).splitlines(),
249
255
  to_nice_yaml(
250
- self.mask_data(self._after_data)
256
+ self.mask(self._after_data)
251
257
  ).splitlines(),
252
258
  self._before_label,
253
259
  self._after_label,
@@ -258,7 +264,7 @@ class ActionBase(ActionBase):
258
264
  """
259
265
  Render data as masked yaml.
260
266
  """
261
- data = self.mask_data(data)
267
+ data = self.mask(data)
262
268
  yaml = to_nice_yaml(data)
263
269
  rendered = cli2.yaml_highlight(yaml)
264
270
  self.print(rendered)
@@ -267,42 +273,6 @@ class ActionBase(ActionBase):
267
273
  # this serves for mocking
268
274
  print(data)
269
275
 
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
276
  async def run_async(self):
307
277
  """
308
278
  The method you are supposed to implement.
@@ -406,7 +376,7 @@ class ActionBase(ActionBase):
406
376
  if self.verbosity:
407
377
  display.display(
408
378
  f'<{self.task_vars["inventory_hostname"]}> + '
409
- + self.mask_data(cmd),
379
+ + self.mask(cmd),
410
380
  color='blue',
411
381
  )
412
382
  shell_action = self._shared_loader_obj.action_loader.get(
@@ -422,9 +392,9 @@ class ActionBase(ActionBase):
422
392
 
423
393
  if self.verbosity:
424
394
  if 'stderr_lines' in result:
425
- print(self.mask_data(result['stderr']))
395
+ self.print(self.mask(result['stderr']))
426
396
  if 'stdout_lines' in result:
427
- print(self.mask_data(result['stdout']))
397
+ self.print(self.mask(result['stdout']))
428
398
 
429
399
  result.pop('invocation')
430
400
  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:
@@ -697,7 +698,8 @@ class ModelCommand(Command):
697
698
  return await model.get(id=self['id'].value)
698
699
 
699
700
  async def post_call(self):
700
- await self.client.post_call(self)
701
+ if self.client:
702
+ await self.client.post_call(self)
701
703
 
702
704
 
703
705
  class ModelMetaclass(type):
@@ -850,7 +852,7 @@ class Model(metaclass=ModelMetaclass):
850
852
 
851
853
  @property
852
854
  def data_masked(self):
853
- return self.client.mask_data(self.data)
855
+ return self.client.mask(self.data)
854
856
 
855
857
  @classmethod
856
858
  @hide('expressions')
@@ -1098,7 +1100,7 @@ class Handler:
1098
1100
  self.tries = self.tries_default if tries is None else tries
1099
1101
  self.backoff = self.backoff_default if backoff is None else backoff
1100
1102
 
1101
- async def __call__(self, client, response, tries, mask, log):
1103
+ async def __call__(self, client, response, tries, log):
1102
1104
  seconds = tries * self.backoff
1103
1105
 
1104
1106
  if isinstance(response, Exception):
@@ -1126,24 +1128,24 @@ class Handler:
1126
1128
  return response
1127
1129
 
1128
1130
  if response.status_code in self.refuses:
1129
- raise RefusedResponseError(client, response, tries, mask)
1131
+ raise RefusedResponseError(client, response, tries)
1130
1132
 
1131
1133
  if tries >= self.tries:
1132
- raise RetriesExceededError(client, response, tries, mask)
1134
+ raise RetriesExceededError(client, response, tries)
1133
1135
 
1134
1136
  kwargs = dict(
1135
1137
  status_code=response.status_code,
1136
1138
  tries=tries,
1137
1139
  sleep=seconds,
1138
1140
  )
1139
- key, value = client.response_log_data(response, mask)
1141
+ key, value = client.response_log_data(response)
1140
1142
  if value:
1141
1143
  kwargs[key] = value
1142
1144
 
1143
1145
  if response.status_code in self.retokens:
1144
1146
  if tries:
1145
1147
  # our authentication is just not working, no need to retry
1146
- raise TokenGetError(client, response, tries, mask)
1148
+ raise TokenGetError(client, response, tries)
1147
1149
  log.warn('retoken', **kwargs)
1148
1150
  await client.token_reset()
1149
1151
 
@@ -1179,11 +1181,10 @@ class ResponseError(ClientError):
1179
1181
 
1180
1182
  Request method
1181
1183
  """
1182
- def __init__(self, client, response, tries, mask, msg=None):
1184
+ def __init__(self, client, response, tries, msg=None):
1183
1185
  self.client = client
1184
1186
  self.response = response
1185
1187
  self.tries = tries
1186
- self.mask = mask
1187
1188
  self.msg = msg or getattr(self, 'msg', '').format(self=self)
1188
1189
  super().__init__(self.enhance(self.msg))
1189
1190
 
@@ -1212,10 +1213,7 @@ class ResponseError(ClientError):
1212
1213
  :param exc: httpx.HTTPStatusError
1213
1214
  """
1214
1215
  output = [msg]
1215
- key, value = self.client.request_log_data(
1216
- self.response.request,
1217
- self.mask,
1218
- )
1216
+ key, value = self.client.request_log_data(self.response.request)
1219
1217
  request_msg = ' '.join([
1220
1218
  str(self.response.request.method),
1221
1219
  str(self.response.request.url),
@@ -1227,7 +1225,7 @@ class ResponseError(ClientError):
1227
1225
  if value:
1228
1226
  output.append(display.render(value))
1229
1227
 
1230
- key, value = self.client.response_log_data(self.response, self.mask)
1228
+ key, value = self.client.response_log_data(self.response)
1231
1229
  output.append(
1232
1230
  ''.join([
1233
1231
  colors.bold,
@@ -1318,9 +1316,14 @@ class Client(metaclass=ClientMetaclass):
1318
1316
  retry the request, or raise an exception, or return the request.
1319
1317
  Default is a :py:class:`Handler`
1320
1318
 
1321
- .. py:attribute:: mask
1319
+ .. py:attribute:: mask_keys
1322
1320
 
1323
- List of keys to mask in logging, ie.: ``['password', 'secret']``
1321
+ Use this class attribute to declare keys to mask:
1322
+
1323
+ .. code-block:: python
1324
+
1325
+ class YourClient(cli2.Client):
1326
+ mask_keys = ['password', 'secret']
1324
1327
 
1325
1328
  .. py:attribute:: cli
1326
1329
 
@@ -1350,6 +1353,10 @@ class Client(metaclass=ClientMetaclass):
1350
1353
  Enforce full logging: quiet requests are logged, masking does not
1351
1354
  apply. This is also enabled with environment variable ``DEBUG``.
1352
1355
 
1356
+ .. py:attribute:: mask
1357
+
1358
+ :py:class:`~cli2.mask.Mask` object
1359
+
1353
1360
  .. py:attribute:: models
1354
1361
 
1355
1362
  Declared models for this Client.
@@ -1357,9 +1364,9 @@ class Client(metaclass=ClientMetaclass):
1357
1364
  paginator = Paginator
1358
1365
  models = []
1359
1366
  semaphore = None
1360
- mask = []
1361
1367
  debug = False
1362
1368
  cmdclass = ClientCommand
1369
+ mask_keys = None
1363
1370
 
1364
1371
  def __init__(self, *args, handler=None, semaphore=None, mask=None,
1365
1372
  debug=False, **kwargs):
@@ -1373,8 +1380,12 @@ class Client(metaclass=ClientMetaclass):
1373
1380
 
1374
1381
  self.handler = handler or Handler()
1375
1382
  self.semaphore = semaphore if semaphore else self.semaphore
1376
- self.mask = mask if mask else self.mask
1383
+ self.mask = mask or Mask()
1384
+ if self.mask_keys:
1385
+ for key in self.mask_keys:
1386
+ self.mask.keys.add(key)
1377
1387
  self.debug = debug or os.getenv('DEBUG', self.debug)
1388
+ self.mask.debug = self.debug
1378
1389
 
1379
1390
  if truststore:
1380
1391
  self._client_kwargs.setdefault(
@@ -1444,7 +1455,7 @@ class Client(metaclass=ClientMetaclass):
1444
1455
  def client(self):
1445
1456
  self._client = None
1446
1457
 
1447
- async def send(self, request, handler, mask, retries=True, semaphore=None,
1458
+ async def send(self, request, handler, retries=True, semaphore=None,
1448
1459
  log=None, auth=None, follow_redirects=None):
1449
1460
  """
1450
1461
  Internal request method
@@ -1469,9 +1480,9 @@ class Client(metaclass=ClientMetaclass):
1469
1480
  try:
1470
1481
  response = await _request()
1471
1482
  except Exception as exc:
1472
- await handler(self, exc, tries, mask, log)
1483
+ await handler(self, exc, tries, log)
1473
1484
  else:
1474
- if response := await handler(self, response, tries, mask, log):
1485
+ if response := await handler(self, response, tries, log):
1475
1486
  return response
1476
1487
 
1477
1488
  tries += 1
@@ -1605,7 +1616,6 @@ class Client(metaclass=ClientMetaclass):
1605
1616
  :param tries: Override for :py:attr:`Handler.tries`
1606
1617
  :param backoff: Override for :py:attr:`Handler.backoff`
1607
1618
  :param semaphore: Override for :py:attr:`Client.semaphore`
1608
- :param mask: Override for :py:attr:`Client.mask`
1609
1619
  """
1610
1620
  if not self.token and not self.token_getting:
1611
1621
  await self.token_refresh()
@@ -1644,7 +1654,7 @@ class Client(metaclass=ClientMetaclass):
1644
1654
 
1645
1655
  _log = log.bind(method=method, url=str(request.url))
1646
1656
  if not quiet or self.debug:
1647
- key, value = self.request_log_data(request, mask, quiet)
1657
+ key, value = self.request_log_data(request, quiet)
1648
1658
  kwargs = dict()
1649
1659
  if value:
1650
1660
  kwargs[key] = value
@@ -1658,7 +1668,6 @@ class Client(metaclass=ClientMetaclass):
1658
1668
  handler=handler,
1659
1669
  retries=retries,
1660
1670
  semaphore=semaphore,
1661
- mask=mask,
1662
1671
  log=log,
1663
1672
  auth=auth,
1664
1673
  follow_redirects=follow_redirects,
@@ -1666,7 +1675,7 @@ class Client(metaclass=ClientMetaclass):
1666
1675
 
1667
1676
  kwargs = dict(status_code=response.status_code)
1668
1677
  if not quiet or self.debug:
1669
- key, value = self.response_log_data(response, mask)
1678
+ key, value = self.response_log_data(response)
1670
1679
  if value:
1671
1680
  kwargs[key] = value
1672
1681
 
@@ -1674,20 +1683,18 @@ class Client(metaclass=ClientMetaclass):
1674
1683
 
1675
1684
  return response
1676
1685
 
1677
- def response_log_data(self, response, mask=None):
1678
- mask = mask if mask is not None else self.mask
1686
+ def response_log_data(self, response):
1679
1687
  try:
1680
1688
  data = response.json()
1681
1689
  except json.JSONDecodeError:
1682
1690
  if response.content:
1683
- return 'content', self.mask_content(response.content, mask)
1691
+ return 'content', self.mask(response.content)
1684
1692
  else:
1685
1693
  if data:
1686
- return 'json', self.mask_data(data, mask)
1694
+ return 'json', self.mask(data)
1687
1695
  return None, None
1688
1696
 
1689
- def request_log_data(self, request, mask=None, quiet=False):
1690
- mask = mask if mask is not None else self.mask
1697
+ def request_log_data(self, request, quiet=False):
1691
1698
  content = request.content.decode()
1692
1699
  if not content:
1693
1700
  return None, None
@@ -1697,7 +1704,7 @@ class Client(metaclass=ClientMetaclass):
1697
1704
  except json.JSONDecodeError:
1698
1705
  pass
1699
1706
  else:
1700
- return 'json', self.mask_data(data, mask)
1707
+ return 'json', self.mask(data)
1701
1708
 
1702
1709
  parsed = parse_qs(content)
1703
1710
  if parsed:
@@ -1705,37 +1712,9 @@ class Client(metaclass=ClientMetaclass):
1705
1712
  key: value[0] if len(value) == 1 else value
1706
1713
  for key, value in parsed.items()
1707
1714
  }
1708
- return 'data', self.mask_data(data, mask)
1709
-
1710
- return 'content', self.mask_content(content, mask)
1715
+ return 'data', self.mask(data)
1711
1716
 
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
1717
+ return 'content', self.mask(content)
1739
1718
 
1740
1719
  return data
1741
1720
 
@@ -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,124 @@
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 + ')'
122
+
123
+ def __bool__(self):
124
+ return bool(self.keys or self.values)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: cli2
3
- Version: 4.2.10
3
+ Version: 4.3.1
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.1',
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()
@@ -1191,3 +1195,8 @@ def test_client_command(client_class, httpx_mock):
1191
1195
  assert Client.cli['model']['get'].client is None
1192
1196
  cmd = Client.cli['model']['find']
1193
1197
  cmd('http://x')
1198
+
1199
+ Client.post_call_called = False
1200
+ cmd = Client.cli['model']['get']
1201
+ cmd()
1202
+ assert not Client.post_call_called
@@ -0,0 +1,25 @@
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
23
+
24
+ assert mask
25
+ assert not cli2.Mask()
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