From 06c4675108fac37114e75065229d2de67fd08c7a Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Tue, 16 Jan 2024 14:03:12 -0800 Subject: [PATCH 1/9] transfer-account fails on Windows if tranfser file contains empty lines --- keepercommander/commands/transfer_account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/commands/transfer_account.py b/keepercommander/commands/transfer_account.py index 364416be3..579615aae 100644 --- a/keepercommander/commands/transfer_account.py +++ b/keepercommander/commands/transfer_account.py @@ -77,9 +77,9 @@ def verify_user(username): # type: (str) -> Optional[str] with open(filename, 'r') as f: lines = f.readlines() for line in lines: + line = line.strip() if not line: continue - line = line.strip() if line[0] in {'#', ';', '-'}: continue p = line.partition('->') From 6bcd126900a03e9a3e257d5e82ead2ac7082b94f Mon Sep 17 00:00:00 2001 From: Micah Roberts Date: Wed, 17 Jan 2024 11:37:52 -0700 Subject: [PATCH 2/9] Tunnel add error handling --- .../commands/tunnel/port_forward/endpoint.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/keepercommander/commands/tunnel/port_forward/endpoint.py b/keepercommander/commands/tunnel/port_forward/endpoint.py index d4c2355a5..2059cfb67 100644 --- a/keepercommander/commands/tunnel/port_forward/endpoint.py +++ b/keepercommander/commands/tunnel/port_forward/endpoint.py @@ -496,6 +496,8 @@ async def send_to_web_rtc(self, data): else: if self.print_ready_event.is_set(): self.logger.error(f'Endpoint {self.endpoint_name}: Data channel is not open. Data not sent.') + if self.connection_no > 1: + self.kill_server_event.set() async def send_control_message(self, message_no, data=None): # type: (ControlMessage, Optional[bytes]) -> None """ @@ -852,6 +854,15 @@ async def stop_server(self): except Exception as ex: self.logger.warning(f'Endpoint {self.endpoint_name}: hit exception closing data channel {ex}') + try: + self.server.close() + await asyncio.wait_for(self.server.wait_closed(), timeout=5.0) + except asyncio.TimeoutError: + self.logger.warning( + f"Endpoint {self.endpoint_name}: Timed out while trying to close server") + except Exception as ex: + self.logger.warning(f'Endpoint {self.endpoint_name}: hit exception closing server {ex}') + try: if self.connect_task is not None: self.connect_task.cancel() @@ -867,14 +878,24 @@ async def close_connection(self, connection_no): self.logger.warning(f'Endpoint {self.endpoint_name}: hit exception sending Close connection {ex}') if connection_no in self.connections and connection_no != 0: - self.connections[connection_no].writer.close() - # Wait for it to actually close. try: + self.connections[connection_no].writer.close() + # Wait for it to actually close. await asyncio.wait_for(self.connections[connection_no].writer.wait_closed(), timeout=5.0) except asyncio.TimeoutError: self.logger.warning( f"Endpoint {self.endpoint_name}: Timed out while trying to close connection " f"{connection_no}") + except Exception as ex: + self.logger.warning(f'Endpoint {self.endpoint_name}: hit exception closing connection {ex}') + + try: + # clean up reader + if self.connections[connection_no].reader is not None: + self.connections[connection_no].reader.feed_eof() + self.connections[connection_no].reader = None + except Exception as ex: + self.logger.warning(f'Endpoint {self.endpoint_name}: hit exception closing reader {ex}') if connection_no in self.connections: try: @@ -882,7 +903,10 @@ async def close_connection(self, connection_no): self.connections[connection_no].to_tunnel_task.cancel() except Exception as ex: self.logger.warning(f'Endpoint {self.endpoint_name}: hit exception canceling tasks {ex}') - del self.connections[connection_no] + try: + del self.connections[connection_no] + except Exception as ex: + self.logger.warning(f'Endpoint {self.endpoint_name}: hit exception deleting connection {ex}') self.logger.info(f"Endpoint {self.endpoint_name}: Closed connection {connection_no}") else: self.logger.info(f"Endpoint {self.endpoint_name}: Connection {connection_no} not found") From fac8e4cc7d9ebeedffc2a6c1d1a99cd0c67b4bd7 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Wed, 17 Jan 2024 10:47:15 -0800 Subject: [PATCH 3/9] Release 16.10.2 --- keepercommander/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index f899204d7..fb8fde651 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: ops@keepersecurity.com # -__version__ = '16.10.1' +__version__ = '16.10.2' From 78952452660252155c686c8a96ee8c49c0b63a55 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Thu, 18 Jan 2024 10:36:40 -0800 Subject: [PATCH 4/9] Warn if automator is already created for a node. KC-730 --- keepercommander/commands/automator.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/keepercommander/commands/automator.py b/keepercommander/commands/automator.py index eb7ef6ef5..cd753dfb3 100644 --- a/keepercommander/commands/automator.py +++ b/keepercommander/commands/automator.py @@ -282,12 +282,17 @@ def execute(self, params, **kwargs): if len(nodes) > 1: logging.warning('Node name \'%s\' is not unique. Use Node ID.', node) return - matched_node = nodes[0] + matched_node_id = nodes[0]['node_id'] + self.ensure_loaded(params, False) + if params.automators: # type: list[automator_proto.AutomatorInfo] + n = next((True for x in params.automators if x.nodeId == matched_node_id), None) + if n: + logging.warning('Automator for node \"%s\" already exists', node) + return rq = automator_proto.AdminCreateAutomatorRequest() - rq.nodeId = matched_node['node_id'] + rq.nodeId = matched_node_id rq.name = name - rs = api.communicate_rest(params, rq, 'automator/automator_create', rs_type=automator_proto.AdminResponse) if rs.success: self.dump_automator(rs.automatorInfo[0]) From 531baa479d32e61bf2fac3394f5dba30dbe24e36 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Thu, 18 Jan 2024 15:55:38 -0800 Subject: [PATCH 5/9] debug output --- keepercommander/importer/imp_exp.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/keepercommander/importer/imp_exp.py b/keepercommander/importer/imp_exp.py index 570a16ec3..a1be52ba7 100644 --- a/keepercommander/importer/imp_exp.py +++ b/keepercommander/importer/imp_exp.py @@ -1248,9 +1248,7 @@ def execute_import_folder_record(params, folders, records): for e in chunk: rq.recordRequest.append(e) - rs = api.communicate_rest(params, rq, "folder/import_folders_and_records") - import_rs = folder_pb2.ImportFolderRecordResponse() - import_rs.ParseFromString(rs) + import_rs = api.communicate_rest(params, rq, "folder/import_folders_and_records", rs_type=folder_pb2.ImportFolderRecordResponse) if len(import_rs.folderResponse) > 0: rs_folder.extend(import_rs.folderResponse) if len(import_rs.recordResponse) > 0: @@ -1372,9 +1370,7 @@ def upload_v3_attachments(params, records_with_attachments): # type: (KeeperPar return rq.client_time = api.current_milli_time() - rs = api.communicate_rest(params, rq, 'vault/files_add') - files_add_rs = record_pb2.FilesAddResponse() - files_add_rs.ParseFromString(rs) + files_add_rs = api.communicate_rest(params, rq, 'vault/files_add', rs_type=record_pb2.FilesAddResponse) new_attachments_by_parent_uid = {} # type: Dict[str, List[Tuple[ImportAttachment, bytes, bytes]]] for f in files_add_rs.files: From c2334cf9b71089906debfefab0dc810e0bedb0e2 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Sat, 20 Jan 2024 20:02:09 -0800 Subject: [PATCH 6/9] Bitwarden import: totp field --- keepercommander/importer/bitwarden/bitwarden.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/importer/bitwarden/bitwarden.py b/keepercommander/importer/bitwarden/bitwarden.py index 6cf3f1a3c..44cde5cf4 100644 --- a/keepercommander/importer/bitwarden/bitwarden.py +++ b/keepercommander/importer/bitwarden/bitwarden.py @@ -81,7 +81,7 @@ def do_import(self, filename, **kwargs): if isinstance(login, dict): record.login = login.get('username') or '' record.password = login.get('password') or '' - totp = login.get('password') + totp = login.get('totp') if totp: if not totp.startswith('otpauth://'): totp = f'otpauth://totp/?secret={totp}' From 3c27427c32c0dd8d9aafbff5a25ad3a30774de33 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Mon, 22 Jan 2024 13:24:48 -0800 Subject: [PATCH 7/9] "import --dry-run" creates folder structure. KC-731 --- keepercommander/importer/imp_exp.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/keepercommander/importer/imp_exp.py b/keepercommander/importer/imp_exp.py index a1be52ba7..3079dff1b 100644 --- a/keepercommander/importer/imp_exp.py +++ b/keepercommander/importer/imp_exp.py @@ -854,9 +854,10 @@ def _import(params, file_format, filename, **kwargs): folder_add = prepare_folder_add(params, folders, records, manage_users, manage_records, can_edit, can_share) if folder_add: - fol_rs, _ = execute_import_folder_record(params, folder_add, None) - _ = fol_rs - sync_down.sync_down(params) + if not dry_run: + fol_rs, _ = execute_import_folder_record(params, folder_add, None) + _ = fol_rs + sync_down.sync_down(params) record_keys = {} audit_uids = [] From 29110faf005f5b564002ddcada335aa56a547e12 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Sat, 27 Jan 2024 11:10:34 -0800 Subject: [PATCH 8/9] Support folder colors. KC-732 --- examples/add_user_to_shared_folder.py | 103 ++++++++++++ keepercommander/commands/folder.py | 218 +++++++++++++++++++++----- keepercommander/display.py | 143 +++++------------ keepercommander/subfolder.py | 1 + keepercommander/sync_down.py | 49 +++--- 5 files changed, 343 insertions(+), 171 deletions(-) create mode 100644 examples/add_user_to_shared_folder.py diff --git a/examples/add_user_to_shared_folder.py b/examples/add_user_to_shared_folder.py new file mode 100644 index 000000000..b2ca5707b --- /dev/null +++ b/examples/add_user_to_shared_folder.py @@ -0,0 +1,103 @@ +import argparse +import logging +import os +import sys + +from keepercommander.__main__ import get_params_from_config +from keepercommander import api, utils, crypto +from keepercommander.proto import folder_pb2 + +parser = argparse.ArgumentParser(description='Add user to shared folder') +parser.add_argument('--debug', action='store_true', help='Enables debug logging') +parser.add_argument('-p', '--manage-records', dest='manage_records', action='store_true', + help='account permission: can manage records.') +parser.add_argument('-o', '--manage-users', dest='manage_users', action='store_true', + help='account permission: can manage users.') +parser.add_argument('--add', dest='add', action='append', help='Users to add to a shared folder.') +parser.add_argument('--remove', dest='remove', action='append', help='Users to remove from shared folder.') +parser.add_argument('shared_folder', help='Shared Folder UID') +opts, flags = parser.parse_known_args(sys.argv[1:]) + +logging.basicConfig(level=logging.DEBUG if opts.debug is True else logging.WARNING, format='%(message)s') + +my_params = get_params_from_config(os.path.join(os.path.dirname(__file__), 'config.json')) + +api.login(my_params) +if not my_params.session_token: + exit(1) + +shared_folder_uid = opts.shared_folder + +sf_rq = { + 'command': 'get_shared_folders', + 'shared_folders': [ + { + 'shared_folder_uid': shared_folder_uid + } + ], + 'include': ['sfheaders', 'sfusers'] +} + +sf_rs = api.communicate(my_params, sf_rq) +shared_folder_info = sf_rs['shared_folders'][0] +shared_folder_key = utils.base64_url_decode(shared_folder_info['shared_folder_key']) +shared_folder_key = crypto.decrypt_aes_v1(shared_folder_key, my_params.data_key) + +existing_users = set() +if isinstance(sf_rs.get('users'), list): + existing_users.update((x.get('email').lower() for x in sf_rs['users'])) + +users_to_add = [] +if hasattr(opts, 'add'): + if isinstance(opts.add, list): + for email in opts.add: + email = email.lower() + if email in existing_users: + logging.info('Add user "%s": already belongs to the shared folder') + else: + users_to_add.append(email) + +users_to_remove = [] +if hasattr(opts, 'remove'): + if isinstance(opts.remove, list): + for email in opts.remove: + email = email.lower() + if email in existing_users: + users_to_remove.append(email) + else: + logging.info('Remove user "%s": does not belongs to the shared folder') + +if len(users_to_add) > 0: + public_keys = {x: None for x in users_to_add} + api.load_user_public_keys(my_params, users_to_add, False) + +manage_users = opts.manage_users +manage_records = opts.manage_records + +rq = folder_pb2.SharedFolderUpdateV3Request() +rq.sharedFolderUid = utils.base64_url_decode(opts.shared_folder) +rq.forceUpdate = True + +for user in users_to_add: + arq = folder_pb2.SharedFolderUpdateUser() + arq.username = user + if isinstance(manage_users, bool): + arq.manageUsers = folder_pb2.BOOLEAN_TRUE if manage_users else folder_pb2.BOOLEAN_FALSE + if isinstance(manage_records, bool): + arq.manageRecords = folder_pb2.BOOLEAN_TRUE if manage_records else folder_pb2.BOOLEAN_FALSE + public_keys = my_params.key_cache.get(user) + if public_keys and public_keys.rsa: + user_rsa_key = crypto.load_rsa_public_key(public_keys.rsa) + arq.sharedFolderKey = crypto.encrypt_rsa(shared_folder_key, user_rsa_key) + rq.sharedFolderAddUser.append(arq) + +for user in users_to_remove: + rq.sharedFolderRemoveUser.append(user) + +rs = api.communicate_rest(my_params, rq, 'vault/shared_folder_update_v3', rs_type=folder_pb2.SharedFolderUpdateV3Response) +for add_status in rs.sharedFolderAddUserStatus: + if add_status.status != 'success': + logging.info(f'Failed to add user {add_status.username} to shared folder: {add_status.status}') +for remove_status in rs.sharedFolderRemoveUserStatus: + if remove_status.status != 'success': + logging.info(f'Failed to add user {remove_status.username} to shared folder: {remove_status.status}') diff --git a/keepercommander/commands/folder.py b/keepercommander/commands/folder.py index a9dc963c6..0419dedb3 100644 --- a/keepercommander/commands/folder.py +++ b/keepercommander/commands/folder.py @@ -11,26 +11,33 @@ import argparse import collections -import logging -import re import fnmatch -import shutil import functools -import os import json +import logging +import os +import re +import shutil from collections import OrderedDict from typing import Tuple, List, Optional, Dict, Set, Any +from asciitree import LeftAligned, BoxStyle, drawing +from colorama import Style + +from prompt_toolkit.shortcuts import print_formatted_text +from prompt_toolkit.formatted_text import FormattedText + + from . import base +from .base import user_choice, dump_report_data, suppress_exit, raise_parse_exception, Command, GroupCommand, RecordMixin from .. import api, display, vault, vault_extensions, crypto, utils -from ..proto import folder_pb2, record_pb2 -from ..recordv3 import RecordV3 -from ..subfolder import BaseFolderNode, try_resolve_path, find_folders +from ..error import CommandError, KeeperApiError, Error from ..params import KeeperParams -from ..record import Record -from .base import user_choice, dump_report_data, suppress_exit, raise_parse_exception, Command, GroupCommand, RecordMixin from ..params import LAST_SHARED_FOLDER_UID, LAST_FOLDER_UID -from ..error import CommandError, KeeperApiError, Error +from ..proto import folder_pb2, record_pb2 +from ..record import Record +from ..recordv3 import RecordV3 +from ..subfolder import BaseFolderNode, try_resolve_path, find_folders, SharedFolderNode, get_contained_record_uids def register_commands(commands): @@ -58,8 +65,8 @@ def register_command_info(aliases, command_info): ls_parser = argparse.ArgumentParser(prog='ls', description='List folder contents.', parents=[base.report_output_parser]) ls_parser.add_argument('-l', '--list', dest='detail', action='store_true', help='show detailed list') -ls_parser.add_argument('-f', '--folders', dest='folders', action='store_true', help='display folders') -ls_parser.add_argument('-r', '--records', dest='records', action='store_true', help='display records') +ls_parser.add_argument('-f', '--folders', dest='folders_only', action='store_true', help='display folders only') +ls_parser.add_argument('-r', '--records', dest='records_only', action='store_true', help='display records only') ls_parser.add_argument('-s', '--short', dest='short', action='store_true', help='Do not display record details. (Not used)') ls_parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', help='verbose output') @@ -95,7 +102,9 @@ def register_command_info(aliases, command_info): rmdir_parser.exit = suppress_exit rndir_parser = argparse.ArgumentParser(prog='rndir', description='Rename a folder.') -rndir_parser.add_argument('-n', '--name', dest='name', action='store', required=True, help='folder new name') +rndir_parser.add_argument('-n', '--name', dest='name', action='store', help='folder new name') +rndir_parser.add_argument('--color', dest='color', action='store', choices=['none', 'red', 'green', 'blue', 'orange', 'yellow', 'gray'], + help='folder color') rndir_parser.add_argument('-q', '--quiet', action='store_true', help='rename folder without folder info') rndir_parser.add_argument('folder', nargs='?', type=str, action='store', help='folder path or UID') @@ -108,6 +117,8 @@ def register_command_info(aliases, command_info): mkdir_parser.add_argument('-r', '--manage-records', dest='manage_records', action='store_true', help='anyone can manage records by default') mkdir_parser.add_argument('-s', '--can-share', dest='can_share', action='store_true', help='anyone can share records by default') mkdir_parser.add_argument('-e', '--can-edit', dest='can_edit', action='store_true', help='anyone can edit records by default') +mkdir_parser.add_argument('--color', dest='color', action='store', choices=['none', 'red', 'green', 'blue', 'orange', 'yellow', 'gray'], + help='folder color') mkdir_parser.add_argument('folder', nargs='?', type=str, action='store', help='folder path') mkdir_parser.error = raise_parse_exception mkdir_parser.exit = suppress_exit @@ -179,8 +190,8 @@ def get_parser(self): return ls_parser def execute(self, params, **kwargs): - show_folders = kwargs['folders'] if 'folders' in kwargs else None - show_records = kwargs['records'] if 'records' in kwargs else None + show_folders = kwargs.get('folders_only') is True + show_records = kwargs.get('records_only') is True show_detail = kwargs['detail'] if 'detail' in kwargs else False if not show_folders and not show_records: show_folders = True @@ -240,14 +251,22 @@ def execute(self, params, **kwargs): headers = ['folder_uid', 'name', 'flags'] def folder_flags(f): - flags = '' if f.type == 'shared_folder': - flags = flags + 'S' + flags = 'S' + else: + flags = '' return flags + colors = {} for f in folders: + if f.color: + colors[f.name] = f.color row = [f.uid, f.name, folder_flags(f)] table.append(row) table.sort(key=lambda x: (x[1] or '').lower()) + for i in range(len(table)): + name = table[i][1] + if name in colors: + table[i][1] = display.keeper_colorize(name, colors[name]) if fmt != 'json': headers = base.fields_to_titles(headers) if fmt in ('json', 'csv'): @@ -268,13 +287,14 @@ def folder_flags(f): else: dump_report_data(table, headers, row_number=True, append=True) else: - names = [] + names = [] # type: List[Tuple[str, Optional[str]]] for f in folders: name = f.name or f.uid if len(name) > 40: name = name[:25] + '...' + name[-12:] - names.append(name + '/') - names.sort() + name = name + '/' + names.append((name, f.color)) + names.sort(key=lambda x: x[0]) rnames = [] for r in records: @@ -284,10 +304,10 @@ def folder_flags(f): rnames.append(name) rnames.sort() - names.extend(rnames) + names.extend(((x, None) for x in rnames)) width, _ = shutil.get_terminal_size(fallback=(1, 1)) - max_name = functools.reduce(lambda val, elem: len(elem) if len(elem) > val else val, names, 0) + max_name = functools.reduce(lambda val, elem: len(elem[0]) if len(elem[0]) > val else val, names, 0) cols = width // max_name if cols == 0: cols = 1 @@ -298,10 +318,13 @@ def folder_flags(f): else: break - tbl = FolderListCommand.chunk_list([x.ljust(max_name) if cols > 1 else x for x in names], cols) - - rows = [' '.join(x) for x in tbl] - print('\n'.join(rows)) + # formatted_text = FormattedText((('fg: Tomato', x[0].ljust(max_name + 2)) for x in names)) + # print_formatted_text(formatted_text) + tbl = FolderListCommand.chunk_list([FormattedText([(display.keeper_color_to_prompt(x[1]), x[0].ljust(max_name))]) for x in names], cols) + for row in tbl: + print_formatted_text(*row, sep=' ') + # rows = [' '.join(x) for x in tbl] + # print('\n'.join(rows)) class FolderCdCommand(Command): @@ -336,14 +359,12 @@ def execute(self, params, **kwargs): title = kwargs.get('title') if folder_name in params.folder_cache: folder = params.folder_cache.get(folder_name) - display.formatted_tree(params, folder, verbose=verbose, show_records=records, shares=shares, - hide_shares_key=hide_key, title=title) + formatted_tree(params, folder, verbose=verbose, show_records=records, shares=shares, hide_shares_key=hide_key, title=title) else: folders, pattern = try_resolve_path(params, folder_name, find_all_matches=True) if not pattern: for idx, folder in enumerate(folders): - display.formatted_tree(params, folder, verbose=verbose, show_records=records, shares=shares, - hide_shares_key=hide_key or idx > 0, title=title) + formatted_tree(params, folder, verbose=verbose, show_records=records, shares=shares, hide_shares_key=hide_key or idx > 0, title=title) else: raise CommandError('tree', f'Folder {folder_name} not found') @@ -353,13 +374,14 @@ def get_parser(self): return rndir_parser def execute(self, params, **kwargs): + color = kwargs.get('color') new_name = kwargs.get('name') - if not new_name: - raise CommandError('rendir', 'New folder name parameter is required.') + if not new_name and not color: + raise CommandError('', 'New folder name and/or color parameters are required.') folder_name = kwargs.get('folder') if not folder_name: - raise CommandError('rendir', 'Enter the path or UID of existing folder.') + raise CommandError('', 'Enter the path or UID of existing folder.') folder_uid = None if folder_name in params.folder_cache: @@ -371,7 +393,7 @@ def execute(self, params, **kwargs): if len(pattern) == 0: folder_uid = folder.uid else: - raise CommandError('rendir', f'Folder {folder_name} not found') + raise CommandError('', f'Folder {folder_name} not found') sub_folder = params.subfolder_cache[folder_uid] # type: Dict rq = { @@ -385,12 +407,15 @@ def execute(self, params, **kwargs): encrypted_data = sub_folder.get('data') elif sub_folder['type'] == 'shared_folder': if folder_uid not in params.shared_folder_cache: - raise CommandError('rendir', f'Shared Folder UID \"{folder_uid}\" not found.') + raise CommandError('', f'Shared Folder UID \"{folder_uid}\" not found.') rq['shared_folder_uid'] = folder_uid shared_folder = params.shared_folder_cache[folder_uid] encryption_key = shared_folder['shared_folder_key_unencrypted'] encrypted_data = shared_folder.get('data') - rq['name'] = utils.base64_url_encode(crypto.encrypt_aes_v1(new_name.encode('utf-8'), encryption_key)) + if new_name: + rq['name'] = utils.base64_url_encode(crypto.encrypt_aes_v1(new_name.encode('utf-8'), encryption_key)) + else: + rq['name'] = shared_folder['name'] elif sub_folder['type'] == 'shared_folder_folder': rq['shared_folder_uid'] = sub_folder['shared_folder_uid'] encryption_key = sub_folder['folder_key_unencrypted'] @@ -407,14 +432,22 @@ def execute(self, params, **kwargs): else: data = {} - data['name'] = new_name - rq['data'] = utils.base64_url_encode(crypto.encrypt_aes_v1(json.dumps(data).encode('utf-8'), encryption_key)) + if new_name: + data['name'] = new_name + if color: + if color == 'none': + if 'color' in data: + del data['color'] + else: + data['color'] = color + rq['data'] = utils.base64_url_encode(crypto.encrypt_aes_v1(json.dumps(data).encode('utf-8'), encryption_key)) api.communicate(params, rq) params.sync_data = True - folder = params.folder_cache[folder_uid] if not kwargs.get('quiet'): - logging.info('Folder \"%s\" has been renamed to \"%s\"', folder.name, new_name) + folder = params.folder_cache[folder_uid] + if new_name: + logging.info('Folder \"%s\" has been renamed to \"%s\"', folder.name, new_name) class FolderMakeCommand(Command): @@ -519,8 +552,11 @@ def execute(self, params, **kwargs): if request['folder_type'] == 'shared_folder': request['name'] = utils.base64_url_encode(crypto.encrypt_aes_v1(name.encode('utf-8'), folder_key)) - - data = json.dumps({'name': name}) + data_dict = {'name': name} + color = kwargs.get('color') + if isinstance(color, str) and len(color) > 0 and color != 'none': + data_dict['color'] = kwargs['color'] + data = json.dumps(data_dict) request['data'] = utils.base64_url_encode(crypto.encrypt_aes_v1(data.encode('utf-8'), folder_key)) api.communicate(params, request) @@ -1645,3 +1681,101 @@ def on_abort(roots): remove_trees(transformed) params.current_folder = current_folder + +def formatted_tree(params, folder, verbose=False, show_records=False, shares=False, hide_shares_key=False, title=None): + def print_share_permissions_key(): + perms_key = 'Share Permissions Key:\n' \ + '======================\n' \ + 'RO = Read-Only\n' \ + 'MU = Can Manage Users\n' \ + 'MR = Can Manage Records\n' \ + 'CE = Can Edit\n' \ + 'CS = Can Share\n' \ + '======================\n' + print(perms_key) + + def get_share_info(node): + MU_KEY = 'manage_users' + MR_KEY = 'manage_records' + DMR_KEY = 'default_manage_records' + DMU_KEY = 'default_manage_user' + DCE_KEY = 'default_can_edit' + DCS_KEY = 'default_can_share' + perm_abbrev_lookup = {MU_KEY: 'MU', MR_KEY: 'MR', DMR_KEY: 'MU', DMU_KEY: 'MU', DCE_KEY: 'CE', DCS_KEY: 'CS'} + + def get_users_info(users): + info = [] + for u in users: + email = u.get('username') + if email == params.user: + continue + privs = [v for k, v in perm_abbrev_lookup.items() if u.get(k)] or ['RO'] + info.append(f'[{email}:{",".join(privs)}]') + return 'users:' + ','.join(info) if info else '' + + def get_teams_info(teams): + info = [] + for t in teams: + name = t.get('name') + privs = [v for k, v in perm_abbrev_lookup.items() if t.get(k)] or ['RO'] + info.append(f'[{name}:{",".join(privs)}]') + return 'teams:' + ','.join(info) if info else '' + + result = '' + if isinstance(node, SharedFolderNode): + sf = params.shared_folder_cache.get(node.uid) + teams_info = get_teams_info(sf.get('teams', [])) + users_info = get_users_info(sf.get('users', [])) + default_perms = [v for k, v in perm_abbrev_lookup.items() if sf.get(k)] or ['RO'] + default_perms = 'default:' + ','.join(default_perms) + user_perms = [v for k, v in perm_abbrev_lookup.items() if sf.get(k)] or ['RO'] + user_perms = 'user:' + ','.join(user_perms) + perms = [default_perms, user_perms, teams_info, users_info] + perms = [p for p in perms if p] + result = f' ({"; ".join(perms)})' if shares else '' + + return result + + def tree_node(node): + node_uid = node.record_uid if isinstance(node, Record) else node.uid or '' + node_name = node.title if isinstance(node, Record) else node.name + node_name = f'{node_name} ({node_uid})' + share_info = get_share_info(node) if isinstance(node, SharedFolderNode) and shares else '' + node_name = f'{Style.DIM}{node_name} [Record]{Style.NORMAL}' if isinstance(node, Record) \ + else f'{node_name}{Style.BRIGHT} [SHARED]{Style.NORMAL}{share_info}' if isinstance(node, SharedFolderNode) \ + else node_name + + dir_nodes = [] if isinstance(node, Record) \ + else [params.folder_cache.get(fuid) for fuid in node.subfolders] + rec_nodes = [] + if show_records and isinstance(node, BaseFolderNode): + node_uid = '' if node.type == '/' else node.uid + rec_uids = get_contained_record_uids(params, node_uid).get(node_uid) + records = [api.get_record(params, rec_uid) for rec_uid in rec_uids] + records = [r for r in records if isinstance(r, Record)] + rec_nodes.extend(records) + + dir_nodes.sort(key=lambda f: f.name.lower(), reverse=False) + rec_nodes.sort(key=lambda r: r.title.lower(), reverse=False) + child_nodes = dir_nodes + rec_nodes + + tns = [tree_node(n) for n in child_nodes] + return node_name, OrderedDict(tns) + + root, branches = tree_node(folder) + tree = {root: branches} + tr = LeftAligned(draw=BoxStyle(gfx=drawing.BOX_LIGHT)) + if shares and not hide_shares_key: + print_share_permissions_key() + if title: + print(title) + tree_txt = tr(tree) + tree_txt = re.sub(r'\s+\(\)', '', tree_txt) + if not verbose: + lines = tree_txt.splitlines() + for idx, line in enumerate(lines): + line = re.sub(r'\s+\(.+?\)', '', line, count=1) + lines[idx] = line + tree_txt = '\n'.join(lines) + print(tree_txt) + print('') diff --git a/keepercommander/display.py b/keepercommander/display.py index 33840f0b6..bed98ad98 100644 --- a/keepercommander/display.py +++ b/keepercommander/display.py @@ -8,18 +8,16 @@ # Contact: ops@keepersecurity.com # import json -import re import shutil -from collections import OrderedDict as OD -from typing import Tuple, List, Union +from typing import Tuple, List, Union, Optional -from asciitree import LeftAligned, BoxStyle, drawing from colorama import init, Fore, Back, Style from tabulate import tabulate -from keepercommander import __version__, api -from .record import Record -from .subfolder import BaseFolderNode, SharedFolderNode, get_contained_record_uids +from prompt_toolkit.styles.named_colors import NAMED_COLORS + +from keepercommander import __version__ +from .subfolder import BaseFolderNode init() @@ -38,6 +36,38 @@ class bcolors: HIGHINTENSITYWHITE = '\033[97m' +def keeper_color_to_prompt(color): # type: (Optional[str]) -> str + if color is None: + return '' + if color == 'red': + return 'fg:ansired' + if color == 'green': + return 'fg:ansigreen' + if color == 'blue': + return 'fg:ansiblue' + if color == 'orange': + return 'fg:ansimagenta' + if color == 'yellow': + return 'fg:ansiyellow' + if color == 'gray': + return 'fg:ansibrightblack' + return '' + +def keeper_colorize(text, color): + if color == 'red': + return f'{Fore.RED}{text}{Fore.RESET}' + if color == 'green': + return f'{Fore.GREEN}{text}{Fore.RESET}' + if color == 'blue': + return f'{Fore.BLUE}{text}{Fore.RESET}' + if color == 'orange': + return f'{Fore.MAGENTA}{text}{Fore.RESET}' + if color == 'yellow': + return f'{Fore.YELLOW}{text}{Fore.RESET}' + if color == 'gray': + return f'{Fore.LIGHTBLACK_EX}{text}{Fore.RESET}' + return text + def welcome(): lines = [] # type: List[Union[str, Tuple[str, str]]] @@ -174,105 +204,6 @@ def formatted_teams(teams, **kwargs): team.display() -def formatted_tree(params, folder, verbose=False, show_records=False, shares=False, hide_shares_key=False, title=None): - def print_share_permissions_key(): - perms_key = 'Share Permissions Key:\n' \ - '======================\n' \ - 'RO = Read-Only\n' \ - 'MU = Can Manage Users\n' \ - 'MR = Can Manage Records\n' \ - 'CE = Can Edit\n' \ - 'CS = Can Share\n' \ - '======================\n' - print(perms_key) - - def get_share_info(node): - MU_KEY = 'manage_users' - MR_KEY = 'manage_records' - DMR_KEY = 'default_manage_records' - DMU_KEY = 'default_manage_user' - DCE_KEY = 'default_can_edit' - DCS_KEY = 'default_can_share' - perm_abbrev_lookup = {MU_KEY: 'MU', MR_KEY: 'MR', DMR_KEY: 'MU', DMU_KEY: 'MU', DCE_KEY: 'CE', DCS_KEY: 'CS'} - - def get_users_info(users): - info = [] - for u in users: - email = u.get('username') - if email == params.user: - continue - privs = [v for k, v in perm_abbrev_lookup.items() if u.get(k)] or ['RO'] - info.append(f'[{email}:{",".join(privs)}]') - return 'users:' + ','.join(info) if info else '' - - def get_teams_info(teams): - info = [] - for t in teams: - name = t.get('name') - privs = [v for k, v in perm_abbrev_lookup.items() if t.get(k)] or ['RO'] - info.append(f'[{name}:{",".join(privs)}]') - return 'teams:' + ','.join(info) if info else '' - - result = '' - if isinstance(node, SharedFolderNode): - sf = params.shared_folder_cache.get(node.uid) - teams_info = get_teams_info(sf.get('teams', [])) - users_info = get_users_info(sf.get('users', [])) - default_perms = [v for k, v in perm_abbrev_lookup.items() if sf.get(k)] or ['RO'] - default_perms = 'default:' + ','.join(default_perms) - user_perms = [v for k, v in perm_abbrev_lookup.items() if sf.get(k)] or ['RO'] - user_perms = 'user:' + ','.join(user_perms) - perms = [default_perms, user_perms, teams_info, users_info] - perms = [p for p in perms if p] - result = f' ({"; ".join(perms)})' if shares else '' - - return result - - def tree_node(node): - node_uid = node.record_uid if isinstance(node, Record) else node.uid or '' - node_name = node.title if isinstance(node, Record) else node.name - node_name = f'{node_name} ({node_uid})' - share_info = get_share_info(node) if isinstance(node, SharedFolderNode) and shares else '' - node_name = f'{Style.DIM}{node_name} [Record]{Style.NORMAL}' if isinstance(node, Record) \ - else f'{node_name}{Style.BRIGHT} [SHARED]{Style.NORMAL}{share_info}' if isinstance(node, SharedFolderNode)\ - else node_name - - dir_nodes = [] if isinstance(node, Record) \ - else [params.folder_cache.get(fuid) for fuid in node.subfolders] - rec_nodes = [] - if show_records and isinstance(node, BaseFolderNode): - node_uid = '' if node.type == '/' else node.uid - rec_uids = get_contained_record_uids(params, node_uid).get(node_uid) - records = [api.get_record(params, rec_uid) for rec_uid in rec_uids] - records = [r for r in records if isinstance(r, Record)] - rec_nodes.extend(records) - - dir_nodes.sort(key=lambda f: f.name.lower(), reverse=False) - rec_nodes.sort(key=lambda r: r.title.lower(), reverse=False) - child_nodes = dir_nodes + rec_nodes - - tns = [tree_node(n) for n in child_nodes] - return node_name, OD(tns) - - root, branches = tree_node(folder) - tree = {root: branches} - tr = LeftAligned(draw=BoxStyle(gfx=drawing.BOX_LIGHT)) - if shares and not hide_shares_key: - print_share_permissions_key() - if title: - print(title) - tree_txt = tr(tree) - tree_txt = re.sub(r'\s+\(\)', '', tree_txt) - if not verbose: - lines = tree_txt.splitlines() - for idx, line in enumerate(lines): - line = re.sub(r'\s+\(.+?\)', '', line, count=1) - lines[idx] = line - tree_txt = '\n'.join(lines) - print(tree_txt) - print('') - - def formatted_history(history): """ Show the history of commands""" diff --git a/keepercommander/subfolder.py b/keepercommander/subfolder.py index becc00447..090a6ec75 100644 --- a/keepercommander/subfolder.py +++ b/keepercommander/subfolder.py @@ -213,6 +213,7 @@ def __init__(self, type): self.uid = None self.parent_uid = None self.name = None + self.color = None # type: Optional[str] self.subfolders = [] def get_folder_type(self): diff --git a/keepercommander/sync_down.py b/keepercommander/sync_down.py index 8aceb0deb..6f0325e0d 100644 --- a/keepercommander/sync_down.py +++ b/keepercommander/sync_down.py @@ -11,7 +11,7 @@ import json import logging -from typing import Any, List, Dict +from typing import Any, List, Dict, Optional import google @@ -20,7 +20,7 @@ from .params import KeeperParams, RecordOwner from .proto import SyncDown_pb2, record_pb2, client_pb2, breachwatch_pb2 from .proto.SyncDown_pb2 import BreachWatchRecord, BreachWatchSecurityData -from .subfolder import RootFolderNode, UserFolderNode, SharedFolderNode, SharedFolderFolderNode +from .subfolder import RootFolderNode, UserFolderNode, SharedFolderNode, SharedFolderFolderNode, BaseFolderNode def sync_down(params, record_types=False): # type: (KeeperParams, bool) -> None @@ -341,6 +341,8 @@ def assign_shared_folder(sf, o): del shared_folder['records'] if 'shared_folder_key_unencrypted' in shared_folder: del shared_folder['shared_folder_key_unencrypted'] + if 'data_unencrypted' in shared_folder: + del shared_folder['data_unencrypted'] assign_shared_folder(p_sf, shared_folder) if p_sf.sharedFolderKey: @@ -730,6 +732,10 @@ def convert_user_folder_shared_folder(ufsf): crypto.decrypt_aes_v1(utils.base64_url_decode(data), sf_key) data_json = json.loads(shared_folder['data_unencrypted'].decode('utf-8')) shared_folder['name_unencrypted'] = data_json['name'] + if 'data' in shared_folder and 'data_unencrypted' not in shared_folder: + data = utils.base64_url_decode(shared_folder['data']) + shared_folder['data_unencrypted'] = crypto.decrypt_aes_v1(data, sf_key) + except Exception as e: logging.debug('Shared folder %s name decryption error: %s', shared_folder_uid, e) if 'name_unencrypted' not in shared_folder: @@ -973,46 +979,43 @@ def prepare_folder_tree(params): # type: (KeeperParams) -> None params.root_folder = RootFolderNode() for sf in params.subfolder_cache.values(): + data_unencrypted = sf.get('data_unencrypted') # type: Optional[bytes] + folder_uid = None if sf['type'] == 'user_folder': + folder_uid = sf['folder_uid'] uf = UserFolderNode() - uf.uid = sf['folder_uid'] + uf.uid = folder_uid uf.parent_uid = sf.get('parent_uid') - if 'data_unencrypted' in sf: - try: - data = json.loads(sf['data_unencrypted'].decode()) - except Exception as e: - logging.debug('Error decrypting user folder name. Folder UID: %s. Error: %s', uf.uid, e) - data = {} - else: - data = {} - uf.name = data['name'] if 'name' in data else uf.uid params.folder_cache[uf.uid] = uf elif sf['type'] == 'shared_folder_folder': + folder_uid = sf['folder_uid'] sff = SharedFolderFolderNode() - sff.uid = sf['folder_uid'] + sff.uid = folder_uid sff.shared_folder_uid = sf['shared_folder_uid'] sff.parent_uid = sf.get('parent_uid') or sff.shared_folder_uid - if 'data_unencrypted' in sf: - try: - data = json.loads(sf['data_unencrypted'].decode()) - except Exception as e: - logging.debug('Error decrypting shared folder folder name. Folder UID: %s. Error: %s', sff.uid, e) - data = {} - else: - data = {} - sff.name = data['name'] if 'name' in data else sff.uid params.folder_cache[sff.uid] = sff elif sf['type'] == 'shared_folder': + folder_uid = sf['shared_folder_uid'] shf = SharedFolderNode() - shf.uid = sf['shared_folder_uid'] + shf.uid = folder_uid shf.parent_uid = sf.get('folder_uid') folder = params.shared_folder_cache.get(shf.uid) if folder is not None: + data_unencrypted = folder.get('data_unencrypted') shf.name = folder['name_unencrypted'] params.folder_cache[shf.uid] = shf + if data_unencrypted and folder_uid: + try: + f = params.folder_cache.get(folder_uid) # type: Optional[BaseFolderNode] + data = json.loads(data_unencrypted.decode()) + f.name = data.get('name') or f.name or f.uid + f.color = data.get('color') + except Exception as e: + logging.debug('Error decrypting user folder name. Folder UID: %s. Error: %s', sf.uid, e) + for f in params.folder_cache.values(): parent_folder = params.folder_cache.get(f.parent_uid) if f.parent_uid else params.root_folder if parent_folder: From 41b35eef02d084d7b1f83ead3925d21dc4d002e9 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Tue, 30 Jan 2024 09:00:51 -0800 Subject: [PATCH 9/9] Retrieve audit events for shared folder --- examples/add_user_to_shared_folder.py | 33 +++++++++----- examples/audit_events_for_shared_folder.py | 52 ++++++++++++++++++++++ keepercommander/__main__.py | 5 ++- 3 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 examples/audit_events_for_shared_folder.py diff --git a/examples/add_user_to_shared_folder.py b/examples/add_user_to_shared_folder.py index b2ca5707b..b0197029e 100644 --- a/examples/add_user_to_shared_folder.py +++ b/examples/add_user_to_shared_folder.py @@ -4,14 +4,14 @@ import sys from keepercommander.__main__ import get_params_from_config -from keepercommander import api, utils, crypto +from keepercommander import api, utils, crypto, error from keepercommander.proto import folder_pb2 parser = argparse.ArgumentParser(description='Add user to shared folder') parser.add_argument('--debug', action='store_true', help='Enables debug logging') -parser.add_argument('-p', '--manage-records', dest='manage_records', action='store_true', +parser.add_argument('-p', '--manage-records', dest='manage_records', action='store', choices=['on', 'off'], help='account permission: can manage records.') -parser.add_argument('-o', '--manage-users', dest='manage_users', action='store_true', +parser.add_argument('-o', '--manage-users', dest='manage_users', action='store', choices=['on', 'off'], help='account permission: can manage users.') parser.add_argument('--add', dest='add', action='append', help='Users to add to a shared folder.') parser.add_argument('--remove', dest='remove', action='append', help='Users to remove from shared folder.') @@ -20,7 +20,7 @@ logging.basicConfig(level=logging.DEBUG if opts.debug is True else logging.WARNING, format='%(message)s') -my_params = get_params_from_config(os.path.join(os.path.dirname(__file__), 'config.json')) +my_params = get_params_from_config('') api.login(my_params) if not my_params.session_token: @@ -39,13 +39,16 @@ } sf_rs = api.communicate(my_params, sf_rq) +if len(sf_rs['shared_folders']) == 0: + raise ValueError(f'Shared folder UID "{shared_folder_uid}" not found') + shared_folder_info = sf_rs['shared_folders'][0] shared_folder_key = utils.base64_url_decode(shared_folder_info['shared_folder_key']) shared_folder_key = crypto.decrypt_aes_v1(shared_folder_key, my_params.data_key) existing_users = set() -if isinstance(sf_rs.get('users'), list): - existing_users.update((x.get('email').lower() for x in sf_rs['users'])) +if isinstance(shared_folder_info.get('users'), list): + existing_users.update((x.get('username').lower() for x in shared_folder_info['users'])) users_to_add = [] if hasattr(opts, 'add'): @@ -53,7 +56,7 @@ for email in opts.add: email = email.lower() if email in existing_users: - logging.info('Add user "%s": already belongs to the shared folder') + logging.warning('Add user "%s": already belongs to the shared folder', email) else: users_to_add.append(email) @@ -65,14 +68,18 @@ if email in existing_users: users_to_remove.append(email) else: - logging.info('Remove user "%s": does not belongs to the shared folder') + logging.warning('Remove user "%s": does not belongs to the shared folder', email) if len(users_to_add) > 0: public_keys = {x: None for x in users_to_add} api.load_user_public_keys(my_params, users_to_add, False) -manage_users = opts.manage_users -manage_records = opts.manage_records +manage_users = None +manage_records = None +if opts.manage_users: + manage_users = opts.manage_users == 'on' +if opts.manage_records: + manage_records = opts.manage_records == 'on' rq = folder_pb2.SharedFolderUpdateV3Request() rq.sharedFolderUid = utils.base64_url_decode(opts.shared_folder) @@ -90,6 +97,8 @@ user_rsa_key = crypto.load_rsa_public_key(public_keys.rsa) arq.sharedFolderKey = crypto.encrypt_rsa(shared_folder_key, user_rsa_key) rq.sharedFolderAddUser.append(arq) + else: + logging.warning('Add user "%s": User public key is not available', user) for user in users_to_remove: rq.sharedFolderRemoveUser.append(user) @@ -97,7 +106,7 @@ rs = api.communicate_rest(my_params, rq, 'vault/shared_folder_update_v3', rs_type=folder_pb2.SharedFolderUpdateV3Response) for add_status in rs.sharedFolderAddUserStatus: if add_status.status != 'success': - logging.info(f'Failed to add user {add_status.username} to shared folder: {add_status.status}') + logging.warning(f'Failed to add user {add_status.username} to shared folder: {add_status.status}') for remove_status in rs.sharedFolderRemoveUserStatus: if remove_status.status != 'success': - logging.info(f'Failed to add user {remove_status.username} to shared folder: {remove_status.status}') + logging.warning(f'Failed to add user {remove_status.username} to shared folder: {remove_status.status}') diff --git a/examples/audit_events_for_shared_folder.py b/examples/audit_events_for_shared_folder.py new file mode 100644 index 000000000..b0730c7c1 --- /dev/null +++ b/examples/audit_events_for_shared_folder.py @@ -0,0 +1,52 @@ +import argparse +import logging +import sys + +from keepercommander import api, utils, crypto +from keepercommander.__main__ import get_params_from_config +from keepercommander.proto import folder_pb2 +from keepercommander.commands.aram import AuditReportCommand + +parser = argparse.ArgumentParser(description='Add user to shared folder') +parser.add_argument('--debug', action='store_true', help='Enables debug logging') +parser.add_argument('--created', dest='created', action='store', + help='Filter: Created date. Predefined filters: ' + 'today, yesterday, last_7_days, last_30_days, month_to_date, last_month, ' + 'year_to_date, last_year') +parser.add_argument('shared_folder', help='Shared Folder UID') +opts, flags = parser.parse_known_args(sys.argv[1:]) + +logging.basicConfig(level=logging.DEBUG if opts.debug is True else logging.WARNING, format='%(message)s') + +my_params = get_params_from_config('') + +api.login(my_params) +if not my_params.session_token: + exit(1) + +shared_folder_uid = opts.shared_folder + +sf_rq = { + 'command': 'get_shared_folders', + 'shared_folders': [ + { + 'shared_folder_uid': shared_folder_uid + } + ], + 'include': ['sfheaders', 'sfrecords'] +} + +sf_rs = api.communicate(my_params, sf_rq) +if len(sf_rs['shared_folders']) == 0: + raise ValueError(f'Shared folder UID "{shared_folder_uid}" not found') + +shared_folder_info = sf_rs['shared_folders'][0] + +record_uids = list() +if isinstance(shared_folder_info.get('records'), list): + record_uids.extend((x.get('record_uid') for x in shared_folder_info['records'])) + +command = AuditReportCommand() +table = command.execute(my_params, report_type='raw', record_uid=record_uids, created=opts.created, limit=-1, + max_record_details=True, report_format='fields', format='csv') +print(table) \ No newline at end of file diff --git a/keepercommander/__main__.py b/keepercommander/__main__.py index 59607a034..348c94dff 100644 --- a/keepercommander/__main__.py +++ b/keepercommander/__main__.py @@ -12,6 +12,8 @@ import argparse +from typing import Optional + import certifi import json import logging @@ -26,8 +28,7 @@ from .params import KeeperParams from .config_storage import loader - -def get_params_from_config(config_filename, launched_with_shortcut=False): +def get_params_from_config(config_filename=None, launched_with_shortcut=False): # type: (Optional[str], bool) -> KeeperParams if os.getenv("KEEPER_COMMANDER_DEBUG"): logging.getLogger().setLevel(logging.DEBUG) logging.info('Debug ON')