From 04f71ee7bac6edaa5c93964ec66cd89a04c850f1 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Tue, 14 Jan 2025 13:12:12 -0800 Subject: [PATCH 1/6] Transfer account: EC-Only --- keepercommander/commands/transfer_account.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/keepercommander/commands/transfer_account.py b/keepercommander/commands/transfer_account.py index c85399cf7..05cf97a63 100644 --- a/keepercommander/commands/transfer_account.py +++ b/keepercommander/commands/transfer_account.py @@ -300,10 +300,14 @@ def transfer_user_account(params, username, target_user, target_public_key): record_key = user_data_key else: raise Exception(f'Unsupported record key type') - if ec_public_key: + + if aes_key: + encrypted_record_key = crypto.encrypt_aes_v2(record_key, aes_key) + record_key_type = 'encrypted_by_data_key_gcm' + elif ec_public_key: encrypted_record_key = crypto.encrypt_ec(record_key, ec_public_key) record_key_type = 'encrypted_by_public_key_ecc' - elif rsa_public_key: + elif not params.forbid_rsa and rsa_public_key: encrypted_record_key = crypto.encrypt_rsa(record_key, rsa_public_key) record_key_type = 'encrypted_by_public_key' else: From 9035ebbf96081e732b985c0b423b1807ff8f3b02 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Fri, 17 Jan 2025 15:07:00 -0800 Subject: [PATCH 2/6] PAM legacy commands --- keepercommander/commands/base.py | 8 +- keepercommander/commands/discoveryrotation.py | 13 +- .../commands/discoveryrotation_v1.py | 1755 +++++++++++++++++ 3 files changed, 1774 insertions(+), 2 deletions(-) create mode 100644 keepercommander/commands/discoveryrotation_v1.py diff --git a/keepercommander/commands/base.py b/keepercommander/commands/base.py index 17af8d142..b6f26a8dd 100644 --- a/keepercommander/commands/base.py +++ b/keepercommander/commands/base.py @@ -113,12 +113,18 @@ def register_commands(commands, aliases, command_info): commands['2fa'] = TwoFaCommand() command_info['2fa'] = '2FA management' - if sys.version_info.major >= 3 and sys.version_info.minor >= 8 and sys.version_info.minor < 13: + if sys.version_info.major == 3 and 8 <= sys.version_info.minor < 13: from . import discoveryrotation discoveryrotation.register_commands(commands) discoveryrotation.register_command_info(aliases, command_info) +def register_pam_legacy_commands(): + from . import discoveryrotation_v1 + discoveryrotation_v1.register_commands(commands) + discoveryrotation_v1.register_command_info(aliases, command_info) + + def register_enterprise_commands(commands, aliases, command_info): from . import enterprise enterprise.register_commands(commands) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 00fc17333..083f5b834 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -31,7 +31,7 @@ from keeper_secrets_manager_core.utils import url_safe_str_to_bytes, bytes_to_base64, base64_to_bytes from .base import (Command, GroupCommand, user_choice, dump_report_data, report_output_parser, field_to_title, - FolderMixin) + FolderMixin, register_pam_legacy_commands) from .folder import FolderMoveCommand from .ksm import KSMCommand from .pam import gateway_helper, router_helper @@ -92,6 +92,7 @@ def __init__(self): self.register_command('rotation', PAMRotationCommand(), 'Manage Rotations', 'r') self.register_command('action', GatewayActionCommand(), 'Execute action on the Gateway', 'a') self.register_command('tunnel', PAMTunnelCommand(), 'Manage Tunnels', 't') + self.register_command('legacy', PAMLegacyCommand(), 'Switch to legacy PAM commands') class PAMGatewayCommand(GroupCommand): @@ -192,6 +193,16 @@ def __init__(self): self.register_command('acl', PAMDebugACLCommand(), 'Control ACL of PAM Users', 'c') +class PAMLegacyCommand(Command): + parser = argparse.ArgumentParser(prog='pam legacy', description="Switch to using obsolete PAM commands") + + def get_parser(self): + return PAMLegacyCommand.parser + + def execute(self, params, **kwargs): + register_pam_legacy_commands() + + class PAMCmdListJobs(Command): parser = argparse.ArgumentParser(prog='pam action job-list') parser.add_argument('--jobId', '-j', required=False, dest='job_id', action='store', help='ID of the Job running') diff --git a/keepercommander/commands/discoveryrotation_v1.py b/keepercommander/commands/discoveryrotation_v1.py new file mode 100644 index 000000000..a28dd0665 --- /dev/null +++ b/keepercommander/commands/discoveryrotation_v1.py @@ -0,0 +1,1755 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 1 else [] + 'kwargs': kwargs + } + + params.ws.send(command_payload, destinations) + + +class PAMCreateRecordRotationCommand(Command): + parser = argparse.ArgumentParser(prog='pam rotation set') + record_group = parser.add_mutually_exclusive_group(required=True) + record_group.add_argument('--record', dest='record_name', action='store', help='Record UID, name, or pattern to be rotated manually or via schedule') + record_group.add_argument('--folder', dest='folder_name', action='store', help='Folder UID or name that holds records to be rotated manually or via schedule') + parser.add_argument('--force', '-f', dest='force', action='store_true', help='Do not ask for confirmation') + parser.add_argument('--config', dest='config_uid', action='store', help='UID of the PAM Configuration') + parser.add_argument('--resource', dest='resource_uid', action='store', help='UID of the resource record.') + schedule_group = parser.add_mutually_exclusive_group() + schedule_group.add_argument('--schedulejson', '-sj', required=False, dest='schedule_json_data', action='append', help='Json of the scheduler. Example: -sj \'{"type": "WEEKLY", "utcTime": "15:44", "weekday": "SUNDAY", "intervalCount": 1}\'') + schedule_group.add_argument('--schedulecron', '-sc', required=False, dest='schedule_cron_data', action='append', help='Cron tab string of the scheduler. Example: to run job daily at 5:56PM UTC enter following cron -sc "56 17 * * *"') + schedule_group.add_argument('--on-demand', '-sm', required=False, dest='on_demand', action='store_true', help='Schedule On Demand') + parser.add_argument('--complexity', '-x', required=False, dest='pwd_complexity', action='store', help='Password complexity: length, upper, lower, digits, symbols. Ex. 32,5,5,5,5') + state_group = parser.add_mutually_exclusive_group() + state_group.add_argument('--enable', dest='enable', action='store_true', help='Enable rotation') + state_group.add_argument('--disable', dest='disable', action='store_true', help='Disable rotation') + + def get_parser(self): + return PAMCreateRecordRotationCommand.parser + + def execute(self, params, **kwargs): + record_uids = set() # type: Set[str] + + folder_uids = set() + record_pattern = '' + record_name = kwargs.get('record_name') + if record_name: + if record_name in params.record_cache: + record_uids.add(record_name) + else: + rs = try_resolve_path(params, record_name, find_all_matches=True) + if rs is not None: + folder, record_title = rs + if record_title: + record_pattern = record_title + if isinstance(folder, BaseFolderNode): + folder_uids.add(folder.uid) + elif isinstance(folder, list): + for f in folder: + if isinstance(f, BaseFolderNode): + folder_uids.add(f.uid) + else: + logging.warning('Record \"%s\" not found. Skipping.', record_name) + + folder_name = kwargs.get('folder_name') + if folder_name: + if folder_name in params.folder_cache: + folder_uids.add(folder_name) + else: + rs = try_resolve_path(params, folder_name, find_all_matches=True) + if rs is not None: + folder, record_title = rs + if not record_title: + + def add_folders(sub_folder): # type: (BaseFolderNode) -> None + folder_uids.add(sub_folder.uid or '') + + if isinstance(folder, BaseFolderNode): + folder = [folder] + if isinstance(folder, list): + for f in folder: + FolderMixin.traverse_folder_tree(params, f.uid, add_folders) + else: + logging.warning('Folder \"%s\" not found. Skipping.', folder_name) + + if folder_uids: + regex = re.compile(fnmatch.translate(record_pattern), re.IGNORECASE).match if record_pattern else None + for folder_uid in folder_uids: + folder_records = params.subfolder_record_cache.get(folder_uid) + if not folder_records: + continue + if record_pattern and record_pattern in folder_records: + record_uids.add(record_pattern) + else: + for record_uid in folder_records: + if record_uid not in record_uids: + r = vault.KeeperRecord.load(params, record_uid) + if r: + if regex and not regex(r.title): + continue + record_uids.add(record_uid) + + pam_records = [] # type: List[vault.TypedRecord] + valid_record_types = {'pamDatabase', 'pamDirectory', 'pamMachine', 'pamUser'} + for record_uid in record_uids: + record = vault.KeeperRecord.load(params, record_uid) + if record and isinstance(record, vault.TypedRecord) and record.record_type in valid_record_types: + pam_records.append(record) + + if len(pam_records) == 0: + rts = ', '.join(valid_record_types) + raise CommandError('', f'No PAM record is found. Valid PAM record types: {rts}') + else: + logging.info('Selected %d PAM record(s) for rotation', len(pam_records)) + + pam_configurations = {x.record_uid: x for x in vault_extensions.find_records(params, record_version=6) if isinstance(x, vault.TypedRecord)} + + config_uid = kwargs.get('config_uid') + pam_config = None # type: Optional[vault.TypedRecord] + if config_uid: + if config_uid in pam_configurations: + pam_config = pam_configurations[config_uid] + else: + raise CommandError('', f'Record uid {config_uid} is not a PAM Configuration record.') + + schedule_json_data = kwargs.get('schedule_json_data') + schedule_cron_data = kwargs.get('schedule_cron_data') # See this page for more details: http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html#examples + schedule_on_demand = kwargs.get('on_demand') is True + schedule_data = None # type: Optional[List] + if isinstance(schedule_json_data, list): + schedule_data = [json.loads(x) for x in schedule_json_data] + elif isinstance(schedule_cron_data, list): + schedule_data = [] + for cron in schedule_cron_data: + comps = [x.strip() for x in cron.split(' ')] + if len(comps) != 5: + raise CommandError('', f'Cron value is expected to have 5 components: minute hour day_of_month month day_of_week') + schedule_data.append(TypedField.import_schedule_field(cron)) + elif schedule_on_demand is True: + schedule_data = [] + + pwd_complexity = kwargs.get("pwd_complexity") + pwd_complexity_rule_list = None # type: Optional[dict] + if pwd_complexity is not None: + if pwd_complexity: + pwd_complexity_list = [s.strip() for s in pwd_complexity.split(',')] + if len(pwd_complexity_list) != 5 or not all(n.isnumeric() for n in pwd_complexity_list): + raise CommandError('', 'Invalid rules to generate password. Format is "length, upper, lower, digits, symbols". Ex: 32,5,5,5,5') + pwd_complexity_rule_list = { + 'length': int(pwd_complexity_list[0]), + 'caps': int(pwd_complexity_list[1]), + 'lowercase': int(pwd_complexity_list[2]), + 'digits': int(pwd_complexity_list[3]), + 'special': int(pwd_complexity_list[4]) + } + else: + pwd_complexity_rule_list = {} + + resource_uid = kwargs.get('resource_uid') + if isinstance(resource_uid, str) and len(resource_uid) > 0: + if pam_config is None: + raise CommandError('', '"--resource" parameter requires "--config" parameter to be set as well.') + resource_field = pam_config.get_typed_field('pamResources') + if resource_field and isinstance(resource_field.value, list) and len(resource_field.value) > 0: + resources = resource_field.value[0] + if isinstance(resources, dict): + resource_uids = resources.get('resourceRef') + if isinstance(resource_uids, list): + if resource_uid not in resource_uids: + raise CommandError('', f'PAM Configuration "{pam_config.record_uid}" does not have admin credential for UID "{resource_uid}"') + else: + raise CommandError('', f'PAM Configuration "{pam_config.record_uid}'" does not have admin credentials") + + skipped_header = ['record_uid', 'record_title', 'problem', 'description'] + skipped_records = [] + valid_header = ['record_uid', 'record_title', 'enabled', 'configuration_uid', 'resource_uid', 'schedule', 'complexity'] + valid_records = [] + + requests = [] # type: List[router_pb2.RouterRecordRotationRequest] + for record in pam_records: + current_record_rotation = params.record_rotation_cache.get(record.record_uid) + + # 1. PAM Configuration UID + record_config_uid = config_uid + record_pam_config = pam_config + if not record_config_uid: + if current_record_rotation: + record_config_uid = current_record_rotation.get('configuration_uid') + pc = vault.KeeperRecord.load(params, record_config_uid) + if pc is None: + skipped_records.append([record.record_uid, record.title, 'PAM Configuration was deleted', 'Specify a configuration UID parameter [--config]']) + continue + if not isinstance(pc, vault.TypedRecord) or pc.version != 6: + skipped_records.append([record.record_uid, record.title, 'PAM Configuration is invalid', 'Specify a configuration UID parameter [--config]']) + continue + record_pam_config = pc + else: + skipped_records.append([record.record_uid, record.title, 'No current PAM Configuration', 'Specify a configuration UID parameter [--config]']) + continue + + # 2. Schedule + record_schedule_data = schedule_data + if record_schedule_data is None: + if current_record_rotation: + try: + current_schedule = current_record_rotation.get('schedule') + if current_schedule: + record_schedule_data = json.loads(current_schedule) + except: + pass + else: + schedule_field = record_pam_config.get_typed_field('schedule', 'defaultRotationSchedule') + if schedule_field and isinstance(schedule_field.value, list) and len(schedule_field.value) > 0: + if isinstance(schedule_field.value[0], dict): + record_schedule_data = [schedule_field.value[0]] + + # 3. Password complexity + if pwd_complexity_rule_list is None: + if current_record_rotation: + pwd_complexity_rule_list_encrypted = utils.base64_url_decode(current_record_rotation['pwd_complexity']) + else: + pwd_complexity_rule_list_encrypted = b'' + else: + if len(pwd_complexity_rule_list) > 0: + pwd_complexity_rule_list_encrypted = router_helper.encrypt_pwd_complexity(pwd_complexity_rule_list, record.record_key) + else: + pwd_complexity_rule_list_encrypted = b'' + + # 4. Resource record + record_resource_uid = resource_uid + if record_resource_uid is None: + if current_record_rotation: + record_resource_uid = current_record_rotation.get('resourceUid') + if record_resource_uid is None: + resource_field = record_pam_config.get_typed_field('pamResources') + if resource_field and isinstance(resource_field.value, list) and len(resource_field.value) > 0: + resources = resource_field.value[0] + if isinstance(resources, dict): + resource_uids = resources.get('resourceRef') + if isinstance(resource_uids, list) and len(resource_uids) > 0: + if len(resource_uids) == 1: + record_resource_uid = resource_uids[0] + else: + skipped_records.append([record.record_uid, record.title, f'PAM Configuration: {len(resource_uids)} admin resources', + 'Specify both configuration UID and resource UID [--config, --resource]']) + continue + + # 5. Enable rotation + disabled = current_record_rotation.get('disabled') if current_record_rotation else False + if kwargs.get('enable') is True: + disabled = False + elif kwargs.get('disable') is True: + disabled = True + + schedule = 'On-Demand' + if isinstance(record_schedule_data, list) and len(record_schedule_data) > 0: + if isinstance(record_schedule_data[0], dict): + schedule = record_schedule_data[0].get('type') + complexity = '' + if pwd_complexity_rule_list_encrypted: + try: + decrypted_complexity = crypto.decrypt_aes_v2(pwd_complexity_rule_list_encrypted, record.record_key) + c = json.loads(decrypted_complexity.decode()) + complexity = f"{c.get('length', 0)},{c.get('caps', 0)},{c.get('lowercase', 0)},{c.get('digits', 0)},{c.get('special', 0)}" + except: + pass + valid_records.append([record.record_uid, record.title, not disabled, record_config_uid, record_resource_uid, schedule, complexity]) + + # 6. Construct Request object + rq = router_pb2.RouterRecordRotationRequest() + if current_record_rotation: + rq.revision = current_record_rotation.get('revision') + rq.recordUid = utils.base64_url_decode(record.record_uid) + rq.configurationUid = utils.base64_url_decode(record_config_uid) + rq.resourceUid = utils.base64_url_decode(record_resource_uid) if record_resource_uid else b'' + rq.schedule = json.dumps(record_schedule_data) if record_schedule_data else '' + rq.pwdComplexity = pwd_complexity_rule_list_encrypted + rq.disabled = disabled + requests.append(rq) + + force = kwargs.get('force') is True + + if len(skipped_records) > 0: + skipped_header = [field_to_title(x) for x in skipped_header] + dump_report_data(skipped_records, skipped_header, title='The following record(s) were skipped') + + if len(requests) > 0 and not force: + answer = user_choice('\nDo you want to cancel password rotation?', 'Yn', 'Y') + if answer.lower().startswith('y'): + return + + if len(requests) > 0: + valid_header = [field_to_title(x) for x in valid_header] + dump_report_data(valid_records, valid_header, title='The following record(s) will be updated') + if not force: + answer = user_choice('\nDo you want to update password rotation?', 'Yn', 'Y') + if answer.lower().startswith('n'): + return + + for rq in requests: + record_uid = utils.base64_url_encode(rq.recordUid) + try: + router_set_record_rotation_information(params, rq) + except KeeperApiError as kae: + logging.warning('Record "%s": Set rotation error "%s": %s', record_uid, kae.result_code, kae.message) + params.sync_data = True + + +class PAMListRecordRotationCommand(Command): + parser = argparse.ArgumentParser(prog='pam rotation list') + parser.add_argument('--verbose', '-v', dest='is_verbose', action='store_true', help='Verbose output') + + def get_parser(self): + return PAMListRecordRotationCommand.parser + + def execute(self, params, **kwargs): + + is_verbose = kwargs.get('is_verbose') + + rq = pam_pb2.PAMGenericUidsRequest() + schedules_proto = router_get_rotation_schedules(params, rq) + if schedules_proto: + schedules = list(schedules_proto.schedules) + else: + schedules = [] + + enterprise_all_controllers = list(gateway_helper.get_all_gateways(params)) + enterprise_controllers_connected_resp = router_get_connected_gateways(params) + enterprise_controllers_connected_uids_bytes = \ + [x.controllerUid for x in enterprise_controllers_connected_resp.controllers] + + all_pam_config_records = pam_configurations_get_all(params) + table = [] + + headers = [] + headers.append('Record UID') + headers.append('Record Title') + headers.append('Record Type') + headers.append('Schedule') + + headers.append('Gateway') + if is_verbose: + headers.append('Gateway UID') + + headers.append('PAM Configuration (Type)') + if is_verbose: + headers.append('PAM Configuration UID') + + for s in schedules: + row = [] + + record_uid = utils.base64_url_encode(s.recordUid) + controller_uid = s.controllerUid + controller_details = next((ctr for ctr in enterprise_all_controllers if ctr.controllerUid == controller_uid), None) + configuration_uid = s.configurationUid + configuration_uid_str = utils.base64_url_encode(configuration_uid) + pam_configuration = next((pam_config for pam_config in all_pam_config_records if pam_config.get('record_uid') == configuration_uid_str), None) + + is_controller_online = any((poc for poc in enterprise_controllers_connected_uids_bytes if poc == controller_uid)) + + row_color = '' + if record_uid in params.record_cache: + row_color = bcolors.HIGHINTENSITYWHITE + rec = params.record_cache[record_uid] + + data_json = rec['data_unencrypted'].decode('utf-8') if isinstance(rec['data_unencrypted'], bytes) else rec['data_unencrypted'] + data = json.loads(data_json) + + record_title = data.get('title') + record_type = data.get('type') or '' + else: + row_color = bcolors.WHITE + + record_title = '[record inaccessible]' + record_type = '[record inaccessible]' + + row.append(f'{row_color}{record_uid}') + row.append(record_title) + row.append(record_type) + + if s.noSchedule is True: + # Per Sergey A: + # > noSchedule=true means manual + # > false is by default in proto and matches the default state for most records (would have a schedule) + schedule_str = '[Manual Rotation]' + else: + if s.scheduleData: + schedule_arr = s.scheduleData.replace('RotateActionJob|', '').split('.') + if len(schedule_arr) == 4: + schedule_str = f'{schedule_arr[0]} on {schedule_arr[1]} at {schedule_arr[2]} UTC with interval count of {schedule_arr[3]}' + elif len(schedule_arr) == 3: + schedule_str = f'{schedule_arr[0]} at {schedule_arr[1]} UTC with interval count of {schedule_arr[2]}' + else: + schedule_str = s.scheduleData + else: + schedule_str = f'{bcolors.FAIL}[empty]' + + row.append(f'{schedule_str}') + + # Controller Info + + enterprise_controllers_connected = router_get_connected_gateways(params) + connected_controller = None + if enterprise_controllers_connected and controller_details: + # Find connected controller (TODO: Optimize, don't search for controllers every time, no N^n) + router_controllers = [x.controllerUid for x in enterprise_controllers_connected.controllers] + connected_controller = next( + (x for x in router_controllers if x == controller_details.controllerUid), None) + + if connected_controller: + controller_stat_color = bcolors.OKGREEN + else: + controller_stat_color = bcolors.WHITE + + + controller_color = bcolors.WHITE + if is_controller_online: + controller_color = bcolors.OKGREEN + + if controller_details: + row.append(f'{controller_stat_color}{controller_details.controllerName}{bcolors.ENDC}') + else: + row.append(f'{controller_stat_color}[Does not exist]{bcolors.ENDC}') + + if is_verbose: + row.append(f'{controller_color}{utils.base64_url_encode(controller_uid)}{bcolors.ENDC}') + + if not pam_configuration: + if not is_verbose: + row.append(f"{bcolors.FAIL}[No config found]{bcolors.ENDC}") + else: + row.append(f"{bcolors.FAIL}[No config found. Looks like configuration {configuration_uid_str} was removed but rotation schedule was not modified{bcolors.ENDC}") + + else: + pam_data_decrypted = pam_decrypt_configuration_data(pam_configuration) + pam_config_name = pam_data_decrypted.get('title') + pam_config_type = pam_data_decrypted.get('type') + row.append(f"{pam_config_name} ({pam_config_type})") + + if is_verbose: + row.append(f'{utils.base64_url_encode(configuration_uid)}{bcolors.ENDC}') + + table.append(row) + + table.sort(key=lambda x: (x[1])) + + dump_report_data(table, headers, fmt='table', filename="", row_number=False, column_width=None) + + print(f"\n{bcolors.OKBLUE}----------------------------------------------------------{bcolors.ENDC}") + print(f"{bcolors.OKBLUE}Example to rotate record to which this user has access to:{bcolors.ENDC}") + print(f"\t{bcolors.OKBLUE}pam action rotate -r [RECORD UID]{bcolors.ENDC}") + + +class PAMGatewayListCommand(Command): + parser = argparse.ArgumentParser(prog='dr-gateway') + parser.add_argument('--force', '-f', required=False, default=False, dest='is_force', action='store_true', help='Force retrieval of gateways') + parser.add_argument('--verbose', '-v', required=False, default=False, dest='is_verbose', action='store_true', help='Verbose output') + + def get_parser(self): + return PAMGatewayListCommand.parser + + def execute(self, params, **kwargs): + + is_force = kwargs.get('is_force') + is_verbose = kwargs.get('is_verbose') + + is_router_down = False + krouter_url = router_helper.get_router_url(params) + enterprise_controllers_connected = None + try: + enterprise_controllers_connected = router_get_connected_gateways(params) + + except requests.exceptions.ConnectionError as errc: + is_router_down = True + if not is_force: + logging.warning(f"Looks like router is down. Use '{bcolors.OKGREEN}-f{bcolors.ENDC}' flag to " + f"retrieve list of all available routers associated with your enterprise.\n\nRouter" + f" URL [{krouter_url}]") + return + else: + logging.info(f"{bcolors.WARNING}Looks like router is down. Router URL [{krouter_url}]{bcolors.ENDC}") + + except Exception as e: + logging.warning(f"Unhandled error during retrieval of the connected gateways.") + raise e + + enterprise_controllers_all = gateway_helper.get_all_gateways(params) + + if not enterprise_controllers_all: + print(f"{bcolors.OKBLUE}\nThis Enterprise does not have Gateways yet. To create new Gateway, use command " + f"`{bcolors.ENDC}{bcolors.OKGREEN}pam gateway new{bcolors.ENDC}{bcolors.OKBLUE}`\n\n" + f"NOTE: If you have added new Gateway, you might still need to initialize it before it is " + f"listed.{bcolors.ENDC}") + return + + table = [] + + headers = [] + headers.append('KSM Application Name (UID)') + headers.append('Gateway Name') + headers.append('Gateway UID') + headers.append('Status') + + if is_verbose: + headers.append('Device Name') + headers.append('Device Token') + headers.append('Created On') + headers.append('Last Modified') + headers.append('Node ID') + + for c in enterprise_controllers_all: + + connected_controller = None + if enterprise_controllers_connected: + # Find connected controller (TODO: Optimize, don't search for controllers every time, no N^n) + router_controllers = list(enterprise_controllers_connected.controllers) + connected_controller = next((x for x in router_controllers if x.controllerUid == c.controllerUid), None) + + row_color = '' + if not is_router_down: + row_color = bcolors.FAIL + + if connected_controller: + row_color = bcolors.OKGREEN + + add_cookie = False + + row = [] + + ksm_app_uid_str = utils.base64_url_encode(c.applicationUid) + ksm_app = KSMCommand.get_app_record(params, ksm_app_uid_str) + + if ksm_app: + ksm_app_data_unencrypted_json = ksm_app.get('data_unencrypted') + ksm_app_data_unencrypted_dict = json.loads(ksm_app_data_unencrypted_json) + ksm_app_title = ksm_app_data_unencrypted_dict.get('title') + ksm_app_info = f'{ksm_app_title} ({ksm_app_uid_str})' + else: + ksm_app_info = f'[APP NOT ACCESSIBLE OR DELETED] ({ksm_app_uid_str})' + + row.append(f'{row_color if ksm_app else bcolors.WHITE}{ksm_app_info}{bcolors.ENDC}') + row.append(f'{row_color}{c.controllerName}{bcolors.ENDC}') + row.append(f'{row_color}{utils.base64_url_encode(c.controllerUid)}{bcolors.ENDC}') + + if is_router_down: + status = 'UNKNOWN' + elif connected_controller: + status = "ONLINE" + else: + status = "OFFLINE" + + row.append(f'{row_color}{status}{bcolors.ENDC}') + + if is_verbose: + row.append(f'{row_color}{c.deviceName}{bcolors.ENDC}') + row.append(f'{row_color}{c.deviceToken}{bcolors.ENDC}') + row.append(f'{row_color}{datetime.fromtimestamp(c.created/1000)}{bcolors.ENDC}') + row.append(f'{row_color}{datetime.fromtimestamp(c.lastModified/1000)}{bcolors.ENDC}') + row.append(f'{row_color}{c.nodeId}{bcolors.ENDC}') + + table.append(row) + table.sort(key=lambda x: (x[3] or '', x[0].lower())) + + if is_verbose: + krouter_host = get_router_url(params) + print(f"\n{bcolors.BOLD}Router Host: {bcolors.OKBLUE}{krouter_host}{bcolors.ENDC}\n") + + dump_report_data(table, headers, fmt='table', filename="", + row_number=False, column_width=None) + + +class PAMConfigurationListCommand(Command): + parser = argparse.ArgumentParser(prog='pam config list') + parser.add_argument('--config', '-c', required=False, dest='pam_configuration', action='store', + help='Specific PAM Configuration UID') + parser.add_argument('--verbose', '-v', required=False, dest='verbose', action='store_true', help='Verbose') + + def get_parser(self): + return PAMConfigurationListCommand.parser + + def execute(self, params, **kwargs): + pam_configuration_uid = kwargs.get('pam_configuration') + is_verbose = kwargs.get('verbose') + + if not pam_configuration_uid: # Print ALL root level configs + PAMConfigurationListCommand.print_root_rotation_setting(params, is_verbose) + else: # Print element configs (config that is not a root) + PAMConfigurationListCommand.print_pam_configuration_details(params, pam_configuration_uid, is_verbose) + + @staticmethod + def print_pam_configuration_details(params, config_uid, is_verbose=False): + configuration = vault.KeeperRecord.load(params, config_uid) + if not configuration: + raise Exception(f'Configuration {config_uid} not found') + if configuration.version != 6: + raise Exception(f'{config_uid} is not PAM Configuration') + if not isinstance(configuration, vault.TypedRecord): + raise Exception(f'{config_uid} is not PAM Configuration') + + facade = PamConfigurationRecordFacade() + facade.record = configuration + table = [] + header = ['name', 'value'] + table.append(['UID', configuration.record_uid]) + table.append(['Name', configuration.title]) + table.append(['Config Type', configuration.record_type]) + folder_uid = facade.folder_uid + sf = None + if folder_uid in params.shared_folder_cache: + sf = api.get_shared_folder(params, folder_uid) + table.append(['Shared Folder', f'{sf.name} ({sf.shared_folder_uid})' if sf else '']) + table.append(['Gateway UID', facade.controller_uid]) + table.append(['Resource Record UIDs', facade.resource_ref]) + + for field in configuration.fields: + if field.type in ('pamResources', 'fileRef'): + continue + values = list(field.get_external_value()) + if not values: + continue + field_name = field.get_field_name() + if field.type == 'schedule': + field_name = 'Default Schedule' + + table.append([field_name, values]) + dump_report_data(table, header, no_header=True, right_align=(0,)) + + @staticmethod + def print_root_rotation_setting(params, is_verbose=False): + table = [] + headers = ['UID', 'Config Name', 'Config Type', 'Shared Folder', 'Gateway UID', 'Resource Record UIDs'] + if is_verbose: + headers.append('Fields') + + configurations = list(vault_extensions.find_records(params, record_version=6)) + facade = PamConfigurationRecordFacade() + for c in configurations: # type: vault.TypedRecord + if c.record_type in ('pamAwsConfiguration', 'pamAzureConfiguration', 'pamNetworkConfiguration'): + facade.record = c + shared_folder_parents = find_parent_top_folder(params, c.record_uid) + if shared_folder_parents: + sf = shared_folder_parents[0] + row = [c.record_uid, c.title, c.record_type, f'{sf.name} ({sf.uid})', + facade.controller_uid, facade.resource_ref] + + if is_verbose: + fields = [] + for field in c.fields: + if field.type in ('pamResources', 'fileRef'): + continue + value = ', '.join(field.get_external_value()) + if value: + fields.append(f'{field.get_field_name()}: {value}') + row.append(fields) + + table.append(row) + else: + logging.warning(f'Following configuration is not in the shared folder: UID: %s, Title: %s', c.record_uid, c.title) + else: + logging.warning(f'Following configuration has unsupported type: UID: %s, Title: %s', c.record_uid, c.title) + + table.sort(key=lambda x: (x[1] or '')) + dump_report_data(table, headers, fmt='table', filename="", row_number=False, column_width=None) + + +common_parser = argparse.ArgumentParser(add_help=False) +common_parser.add_argument('--config-type', '-ct', dest='config_type', action='store', + choices=['network', 'aws', 'azure'], help='PAM Configuration Type', ) +common_parser.add_argument('--title', '-t', dest='title', action='store', help='Title of the PAM Configuration') +common_parser.add_argument('--gateway', '-g', dest='gateway', action='store', help='Gateway UID or Name') +common_parser.add_argument('--shared-folder', '-sf', dest='shared_folder', action='store', + help='Share Folder where this PAM Configuration is stored. Should be one of the folders to ' + 'which the gateway has access to.') +common_parser.add_argument('--resource-record', '-rr', dest='resource_records', action='append', + help='Resource Record UID') +common_parser.add_argument('--schedule', '-sc', dest='default_schedule', action='store', help='Default Schedule: Use CRON syntax') +common_parser.add_argument('--port-mapping', '-pm', dest='port_mapping', action='append', help='Port Mapping') +network_group = common_parser.add_argument_group('network', 'Local network configuration') +network_group.add_argument('--network-id', dest='network_id', action='store', help='Network ID') +network_group.add_argument('--network-cidr', dest='network_cidr', action='store', help='Network CIDR') +aws_group = common_parser.add_argument_group('aws', 'AWS configuration') +aws_group.add_argument('--aws-id', dest='aws_id', action='store', help='AWS ID') +aws_group.add_argument('--access-key-id', dest='access_key_id', action='store', help='Access Key Id') +aws_group.add_argument('--access-secret-key', dest='access_secret_key', action='store', help='Access Secret Key') +aws_group.add_argument('--region-name', dest='region_names', action='append', help='Region Names') +azure_group = common_parser.add_argument_group('azure', 'Azure configuration') +azure_group.add_argument('--azure-id', dest='azure_id', action='store', help='Azure Id') +azure_group.add_argument('--client-id', dest='client_id', action='store', help='Client Id') +azure_group.add_argument('--client-secret', dest='client_secret', action='store', help='Client Secret') +azure_group.add_argument('--subscription_id', dest='subscription_id', action='store', + help='Subscription Id') +azure_group.add_argument('--tenant-id', dest='tenant_id', action='store', help='Tenant Id') +azure_group.add_argument('--resource-group', dest='resource_group', action='append', help='Resource Group') + + +class PamConfigurationEditMixin(RecordEditMixin): + pam_record_types = None + + def __init__(self): + super().__init__() + + @staticmethod + def get_pam_record_types(params): + if PamConfigurationEditMixin.pam_record_types is None: + rts = [y for x, y in params.record_type_cache.items() if x // 1000000 == record_pb2.RT_PAM] + PamConfigurationEditMixin.pam_record_types = [] + for rt in rts: + try: + rt_obj = json.loads(rt) + if '$id' in rt_obj: + PamConfigurationEditMixin.pam_record_types.append(rt_obj['$id']) + except: + pass + return PamConfigurationEditMixin.pam_record_types + + def parse_pam_configuration(self, params, record, **kwargs): + # type: (KeeperParams, vault.TypedRecord, Dict[str, Any]) -> None + field = record.get_typed_field('pamResources') + if not field: + value = {} + field = vault.TypedField.new_field('pamResources', value) + record.fields.append(field) + + if len(field.value) == 0: + field.value.append(dict()) + value = field.value[0] + + gateway_uid = None # type: Optional[str] + gateway = kwargs.get('gateway') # type: Optional[str] + if gateway: + gateways = gateway_helper.get_all_gateways(params) + gateway_uid = next((utils.base64_url_encode(x.controllerUid) for x in gateways + if utils.base64_url_encode(x.controllerUid) == gateway + or x.controllerName.casefold() == gateway.casefold()), None) + if gateway_uid: + value['controllerUid'] = gateway_uid + + # apps = KSMCommand.get_app_info(params, utils.base64_url_encode(gateway.applicationUid)) + # if not apps: + # raise Exception(f'Application for gateway %s not found', gateway_name) + # app = apps[0] + # shares = [x for x in app.shares if x.shareType == APIRequest_pb2.SHARE_TYPE_FOLDER] + # if len(shares) == 0: + # raise Exception(f'Gateway %s has no shared folders', gateway.controllerName) + + shared_folder_uid = None # type: Optional[str] + folder_name = kwargs.get('shared_folder') # type: Optional[str] + if folder_name: + if folder_name in params.shared_folder_cache: + shared_folder_uid = folder_name + else: + for sf_uid in params.shared_folder_cache: + sf = api.get_shared_folder(params, sf_uid) + if sf: + if sf.name.casefold() == folder_name.casefold(): + shared_folder_uid = sf_uid + break + if shared_folder_uid: + value['folderUid'] = shared_folder_uid + + rr = kwargs.get('resource_records') + rrr = kwargs.get('remove_records') + if rr or rrr: + pam_record_lookup = {} + rti = PamConfigurationEditMixin.get_pam_record_types(params) + for r in vault_extensions.find_records(params, record_type=rti): + pam_record_lookup[r.record_uid] = r.record_uid + pam_record_lookup[r.title.lower()] = r.record_uid + + record_uids = set() + if 'resourceRef' in value: + record_uids.update(value['resourceRef']) + if isinstance(rrr, list): + for r in rrr: + if r in pam_record_lookup: + record_uids.remove(r) + continue + r_l = r.lower() + if r_l in pam_record_lookup: + record_uids.remove(r_l) + continue + logging.warning(f'Failed to find PAM record: {r}') + if isinstance(rr, list): + for r in rr: + if r in pam_record_lookup: + record_uids.add(r) + continue + r_l = r.lower() + if r_l in pam_record_lookup: + record_uids.add(r_l) + self.warnings.append(f'Failed to find PAM record: {r}') + + value['resourceRef'] = list(record_uids) + + def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, vault.TypedRecord, ...) -> None + extra_properties = [] + self.parse_pam_configuration(params, record, **kwargs) + port_mapping = kwargs.get('port_mapping') + if isinstance(port_mapping, list) and len(port_mapping) > 0: + pm = "\n".join(port_mapping) + extra_properties.append(f'multiline.portMapping={pm}') + schedule = kwargs.get('default_schedule') + if schedule: + extra_properties.append(f'schedule.defaultRotationSchedule={schedule}') + + if record.record_type == 'pamNetworkConfiguration': + network_id = kwargs.get('network_id') + if network_id: + extra_properties.append(f'text.networkId={network_id}') + network_cidr = kwargs.get('network_cidr') + if network_cidr: + extra_properties.append(f'text.networkCIDR={network_cidr}') + elif record.record_type == 'pamAwsConfiguration': + aws_id = kwargs.get('aws_id') + if aws_id: + extra_properties.append(f'text.awsId={aws_id}') + access_key_id = kwargs.get('access_key_id') + if access_key_id: + extra_properties.append(f'secret.accessKeyId={access_key_id}') + access_secret_key = kwargs.get('access_secret_key') + if access_secret_key: + extra_properties.append(f'secret.accessSecretKey={access_secret_key}') + region_names = kwargs.get('region_names') + if region_names: + regions = '\n'.join(region_names) + extra_properties.append(f'multiline.regionNames={regions}') + elif record.record_type == 'pamAzureConfiguration': + azure_id = kwargs.get('azure_id') + if azure_id: + extra_properties.append(f'text.azureId={azure_id}') + client_id = kwargs.get('client_id') + if client_id: + extra_properties.append(f'secret.clientId={client_id}') + client_secret = kwargs.get('client_secret') + if client_secret: + extra_properties.append(f'secret.clientSecret={client_secret}') + subscription_id = kwargs.get('subscription_id') + if subscription_id: + extra_properties.append(f'secret.subscriptionId={subscription_id}') + tenant_id = kwargs.get('tenant_id') + if tenant_id: + extra_properties.append(f'secret.tenantId={tenant_id}') + resource_group = kwargs.get('resource_group') + if isinstance(resource_group, list) and len(resource_group) > 0: + rg = '\n'.join(resource_group) + extra_properties.append(f'multiline.resourceGroups={rg}') + if extra_properties: + self.assign_typed_fields(record, [RecordEditMixin.parse_field(x) for x in extra_properties]) + + def verify_required(self, record): # type: (vault.TypedRecord) -> None + for field in record.fields: + if field.required: + if len(field.value) == 0: + if field.type == 'schedule': + field.value = [{ + 'type': 'RUN_ONCE', + 'time': '2000-01-01T00:00:00', + 'tz': 'Etc/UTC', + }] + else: + self.warnings.append(f'Empty required field: "{field.get_field_name()}"') + for custom in record.custom: + if custom.required: + custom.required = False + + +class PAMConfigurationNewCommand(Command, PamConfigurationEditMixin): + parser = argparse.ArgumentParser(prog='pam config new', parents=[common_parser]) + + def __init__(self): + super().__init__() + + def get_parser(self): + return PAMConfigurationNewCommand.parser + + def execute(self, params, **kwargs): + self.warnings.clear() + + config_type = kwargs.get('config_type') + if not config_type: + raise CommandError('pam-config-new', '--config-type parameter is required') + if config_type == 'aws': + record_type = 'pamAwsConfiguration' + elif config_type == 'azure': + record_type = 'pamAzureConfiguration' + else: + record_type = 'pamNetworkConfiguration' + + title = kwargs.get('title') + if not title: + raise CommandError('pam-config-new', '--title parameter is required') + + record = vault.TypedRecord(version=6) + record.type_name = record_type + record.title = title + + rt_fields = RecordEditMixin.get_record_type_fields(params, record.record_type) + if rt_fields: + RecordEditMixin.adjust_typed_record_fields(record, rt_fields) + + self.parse_properties(params, record, **kwargs) + + field = record.get_typed_field('pamResources') + if not field: + raise CommandError('pam-config-new', 'PAM configuration record does not contain resource field') + + gateway_uid = None + shared_folder_uid = None + value = field.get_default_value(dict) + if value: + gateway_uid = value.get('controllerUid') + shared_folder_uid = value.get('folderUid') + + if not shared_folder_uid: + raise CommandError('pam-config-new', '--shared_folder parameter is required to create a PAM configuration') + + self.verify_required(record) + + pam_configuration_create_record_v6(params, record, shared_folder_uid) + + # Moving v6 record into the folder + api.sync_down(params) + FolderMoveCommand().execute(params, src=record.record_uid, dst=shared_folder_uid, force=True) + + params.environment_variables[LAST_RECORD_UID] = record.record_uid + params.sync_data = True + + if gateway_uid: + pcc = pam_pb2.PAMConfigurationController() + pcc.configurationUid = utils.base64_url_decode(record.record_uid) + pcc.controllerUid = utils.base64_url_decode(gateway_uid) + api.communicate_rest(params, pcc, 'pam/set_configuration_controller') + + for w in self.warnings: + logging.warning(w) + + return record.record_uid + + +class PAMConfigurationEditCommand(Command, PamConfigurationEditMixin): + parser = argparse.ArgumentParser(prog='pam config edit', parents=[common_parser]) + parser.add_argument('--remove-resource-record', '-rrr', dest='remove_records', action='append', + help='Resource Record UID to remove') + parser.add_argument('--config', '-c', required=True, dest='config', action='store', + help='PAM Configuration UID or Title') + + def __init__(self): + super(PAMConfigurationEditCommand, self).__init__() + + def get_parser(self): + return PAMConfigurationEditCommand.parser + + def execute(self, params, **kwargs): + self.warnings.clear() + + configuration = None + config_name = kwargs.get('config') + if config_name in params.record_cache: + configuration = vault.KeeperRecord.load(params, config_name) + else: + l_name = config_name.casefold() + for c in vault_extensions.find_records(params, record_version=6): + if c.title.casefold() == l_name: + configuration = c + break + if not configuration: + raise CommandError('pam-config-edit', f'PAM configuration "{config_name}" not found') + if not isinstance(configuration, vault.TypedRecord) or configuration.version != 6: + raise CommandError('pam-config-edit', f'PAM configuration "{config_name}" not found') + + config_type = kwargs.get('config_type') + if config_type: + if not config_type: + raise CommandError('pam-config-new', '--config-type parameter is required') + if config_type == 'aws': + record_type = 'pamAwsConfiguration' + elif config_type == 'azure': + record_type = 'pamAzureConfiguration' + elif config_type == 'network': + record_type = 'pamNetworkConfiguration' + else: + record_type = configuration.record_type + + if record_type != configuration.record_type: + configuration.type_name = record_type + rt_fields = RecordEditMixin.get_record_type_fields(params, record_type) + if rt_fields: + RecordEditMixin.adjust_typed_record_fields(configuration, rt_fields) + + title = kwargs.get('title') + if title: + configuration.title = title + + field = configuration.get_typed_field('pamResources') + if not field: + raise CommandError('pam-config-edit', 'PAM configuration record does not contain resource field') + + orig_gateway_uid = '' + orig_shared_folder_uid = '' + value = field.get_default_value(dict) + if value: + orig_gateway_uid = value.get('controllerUid') or '' + orig_shared_folder_uid = value.get('folderUid') or '' + + self.parse_properties(params, configuration, **kwargs) + self.verify_required(configuration) + + record_management.update_record(params, configuration) + + value = field.get_default_value(dict) + if value: + gateway_uid = value.get('controllerUid') or '' + if gateway_uid != orig_gateway_uid: + pcc = pam_pb2.PAMConfigurationController() + pcc.configurationUid = utils.base64_url_decode(configuration.record_uid) + pcc.controllerUid = utils.base64_url_decode(gateway_uid) + api.communicate_rest(params, pcc, 'pam/set_configuration_controller') + shared_folder_uid = value.get('folderUid') or '' + if shared_folder_uid != orig_shared_folder_uid: + FolderMoveCommand().execute(params, src=configuration.record_uid, dst=shared_folder_uid) + + for w in self.warnings: + logging.warning(w) + params.sync_data = True + + +class PAMConfigurationRemoveCommand(Command): + parser = argparse.ArgumentParser(prog='pam config remove') + parser.add_argument('--config', '-c', required=True, dest='pam_config', action='store', + help='PAM Configuration UID. To view all rotation settings with their UIDs, ' + 'use command `pam config list`') + + def get_parser(self): + return PAMConfigurationRemoveCommand.parser + + def execute(self, params, **kwargs): + pam_config_name = kwargs.get('pam_config') + pam_config_uid = None + for config in vault_extensions.find_records(params, record_version=6): + if config.record_uid == pam_config_name: + pam_config_uid = config.record_uid + break + if config.title.casefold() == pam_config_name.casefold(): + pass + if not pam_config_name: + raise Exception(f'Configuration "{pam_config_name}" not found') + + pam_configuration_remove(params, pam_config_uid) + params.sync_data = True + + +class PAMRouterGetRotationInfo(Command): + parser = argparse.ArgumentParser(prog='dr-router-get-rotation-info-parser') + parser.add_argument('--record-uid', '-r', required=True, dest='record_uid', action='store', + help='Record UID to rotate') + + def get_parser(self): + return PAMRouterGetRotationInfo.parser + + def execute(self, params, **kwargs): + + record_uid = kwargs.get('record_uid') + record_uid_bytes = url_safe_str_to_bytes(record_uid) + + rri = record_rotation_get(params, record_uid_bytes) + rri_status_name = router_pb2.RouterRotationStatus.Name(rri.status) + if rri_status_name == 'RRS_ONLINE': + + print(f'Rotation Status: {bcolors.OKBLUE}Ready to rotate ({rri_status_name}){bcolors.ENDC}') + configuration_uid = utils.base64_url_encode(rri.configurationUid) + print(f'PAM Config UID: {bcolors.OKBLUE}{configuration_uid}{bcolors.ENDC}') + print(f'Node ID: {bcolors.OKBLUE}{rri.nodeId}{bcolors.ENDC}') + + print(f"Gateway Name where the rotation will be performed: {bcolors.OKBLUE}{(rri.controllerName if rri.controllerName else '-')}{bcolors.ENDC}") + print(f"Gateway Uid: {bcolors.OKBLUE}{(utils.base64_url_encode(rri.controllerUid) if rri.controllerUid else '-') } {bcolors.ENDC}") + if rri.resourceUid: + resource_id = utils.base64_url_encode(rri.resourceUid) + resource_ok = False + if resource_id in params.record_cache: + configuration = vault.KeeperRecord.load(params, configuration_uid) + if isinstance(configuration, vault.TypedRecord): + field = configuration.get_typed_field('pamResources') + if field and isinstance(field.value, list) and len(field.value) == 1: + rv = field.value[0] + if isinstance(rv, dict): + resources = rv.get('resourceRef') + if isinstance(resources, list): + resource_ok = resource_id in resources + print(f"Admin Resource Uid: {bcolors.OKBLUE if resource_ok else bcolors.FAIL}{resource_id}{bcolors.ENDC}") + + # print(f"Router Cookie: {bcolors.OKBLUE}{(rri.cookie if rri.cookie else '-')}{bcolors.ENDC}") + # print(f"scriptName: {bcolors.OKGREEN}{rri.scriptName}{bcolors.ENDC}") + if rri.pwdComplexity: + print(f"Password Complexity: {bcolors.OKGREEN}{rri.pwdComplexity}{bcolors.ENDC}") + try: + record = params.record_cache.get(record_uid) + if record: + complexity = crypto.decrypt_aes_v2(utils.base64_url_decode(rri.pwdComplexity), record['record_key_unencrypted']) + c = json.loads(complexity.decode()) + print(f"Password Complexity Data: {bcolors.OKBLUE}Length: {c.get('length')}; Lowercase: {c.get('lowercase')}; Uppercase: {c.get('caps')}; Digits: {c.get('digits')}; Symbols: {c.get('special')} {bcolors.ENDC}") + except: + pass + else: + print(f"Password Complexity: {bcolors.OKGREEN}[not set]{bcolors.ENDC}") + + print(f"Is Rotation Disabled: {bcolors.OKGREEN}{rri.disabled}{bcolors.ENDC}") + print(f"\nCommand to manually rotate: {bcolors.OKGREEN}pam action rotate -r {record_uid}{bcolors.ENDC}") + else: + print(f'{bcolors.WARNING}Rotation Status: Not ready to rotate ({rri_status_name}){bcolors.ENDC}') + + +class PAMRouterScriptCommand(GroupCommand): + def __init__(self): + super().__init__() + self.register_command('list', PAMScriptListCommand(), 'List script fields') + self.register_command('add', PAMScriptAddCommand(), 'List Record Rotation Schedulers') + self.register_command('edit', PAMScriptEditCommand(), 'Add, delete, or edit script field') + self.register_command('delete', PAMScriptDeleteCommand(), 'Delete script field') + self.default_verb = 'list' + + +class PAMScriptListCommand(Command): + parser = argparse.ArgumentParser(prog='pam rotate script view', parents=[report_output_parser], + description='List script fields') + parser.add_argument('pattern', nargs='?', help='Record UID, path, or search pattern') + + def get_parser(self): + return PAMScriptListCommand.parser + + def execute(self, params, **kwargs): + pattern = kwargs.get('pattern') + + table = [] + header = ['record_uid', 'title', 'record_type', 'script_uid', 'script_name', 'records', 'command'] + for record in vault_extensions.find_records(params, search_str=pattern, record_version=3, + record_type=('pamUser', 'pamDirectory')): + if not isinstance(record, vault.TypedRecord): + continue + for field in (x for x in record.fields if x.type == 'script'): + value = field.get_default_value(dict) + if not value: + continue + file_ref = value.get('fileRef') + if not file_ref: + continue + file_record = vault.KeeperRecord.load(params, file_ref) + if not file_record: + continue + records = value.get('recordRef') + command = value.get('command') + table.append([record.record_uid, record.title, record.record_type, file_record.record_uid, + file_record.title, records, command]) + fmt = kwargs.get('format') + if fmt != 'json': + header = [field_to_title(x) for x in header] + return dump_report_data(table, header, fmt=fmt, filename=kwargs.get('output'), row_number=True) + + +class PAMScriptAddCommand(Command): + parser = argparse.ArgumentParser(prog='pam rotate script add', description='Add script to record') + parser.add_argument('--script', required=True, dest='script', action='store', + help='Script file name') + parser.add_argument('--add-credential', dest='add_credential', action='append', + help='Record with rotation credential') + parser.add_argument('--script-command', dest='script_command', action='store', + help='Script command') + parser.add_argument('record', help='Record UID or Title') + + def get_parser(self): + return PAMScriptAddCommand.parser + + def execute(self, params, **kwargs): + record_name = kwargs.get('record') + if not record_name: + raise CommandError('rotate script', '"record" argument is required') + records = list(vault_extensions.find_records( + params, search_str=record_name, record_version=3, record_type=('pamUser', 'pamDirectory'))) + if len(records) == 0: + raise CommandError('rotate script', f'Record "{record_name}" not found') + if len(records) > 1: + raise CommandError('rotate script', f'Record "{record_name}" is not unique. Use record UID.') + record = records[0] + if not isinstance(record, vault.TypedRecord): + raise CommandError('rotate script', f'Record "{record.title}" is not a rotation record.') + + script_field = next((x for x in record.fields if x.type == 'script'), None) + if not script_field: + script_field = vault.TypedField.new_field('script', [], 'rotationScripts') + record.fields.append(script_field) + + file_name = kwargs.get('script') + full_name = os.path.expanduser(file_name) + if not os.path.isfile(full_name): + raise CommandError('rotate script', f'File "{file_name}" not found.') + + facade = record_facades.FileRefRecordFacade() + facade.record = record + pre = set(facade.file_ref) + upload_task = attachment.FileUploadTask(full_name) + attachment.upload_attachments(params, record, [upload_task]) + post = set(facade.file_ref) + df = post.difference(pre) + if len(df) == 1: + file_uid = df.pop() + facade.file_ref.remove(file_uid) + script_value = { + 'fileRef': file_uid, + 'recordRef': [], + 'command': '', + } + script_field.value.append(script_value) + record_refs = kwargs.get('add_credential') + if isinstance(record_refs, list): + for ref in record_refs: + if ref in params.record_cache: + script_value['recordRef'].append(ref) + cmd = kwargs.get('script_command') + if cmd: + script_value['command'] = cmd + + record_management.update_record(params, record) + params.sync_data = True + + +class PAMScriptEditCommand(Command): + parser = argparse.ArgumentParser(prog='pam rotate script edit', description='Edit script field') + parser.add_argument('--script', required=True, dest='script', action='store', + help='Script UID or name') + parser.add_argument('-ac', '--add-credential', dest='add_credential', action='append', + help='Add a record with rotation credential') + parser.add_argument('-rc', '--remove-credential', dest='remove_credential', action='append', + help='Remove a record with rotation credential') + parser.add_argument('--script-command', dest='script_command', action='store', + help='Script command') + parser.add_argument('record', help='Record UID or Title') + + def get_parser(self): + return PAMScriptEditCommand.parser + + def execute(self, params, **kwargs): + record_name = kwargs.get('record') + if not record_name: + raise CommandError('rotate script', '"record" argument is required') + + script_name = kwargs.get('script') # type: Optional[str] + if not script_name: + raise CommandError('rotate script', '"script" argument is required') + + records = list(vault_extensions.find_records( + params, search_str=record_name, record_version=3, record_type=('pamUser', 'pamDirectory'))) + if len(records) == 0: + raise CommandError('rotate script', f'Record "{record_name}" not found') + if len(records) > 1: + raise CommandError('rotate script', f'Record "{record_name}" is not unique. Use record UID.') + record = records[0] + if not isinstance(record, vault.TypedRecord): + raise CommandError('rotate script', f'Record "{record.title}" is not a rotation record.') + + script_field = next((x for x in record.fields if x.type == 'script'), None) + if script_field is None: + raise CommandError('rotate script', f'Record "{record.title}" has no rotation scripts.') + script_value = next((x for x in script_field.value if x.get('fileRef') == script_name), None) + if script_value is None: + s_name = script_name.casefold() + for x in script_field.value: + file_uid = x.get('fileRef') + file_record = vault.KeeperRecord.load(params, file_uid) + if isinstance(file_record, vault.FileRecord): + if file_record.title.casefold() == s_name: + script_value = x + break + elif file_record.name.casefold() == s_name: + script_value = x + break + + if not isinstance(script_value, dict): + raise CommandError('rotate script', f'Record "{record.title}" does not have script "{script_name}"') + + modified = False + refs = set() + record_refs = script_value.get('recordRef') + if isinstance(record_refs, list): + refs.update(record_refs) + remove_credential = kwargs.get('remove_credential') + if isinstance(remove_credential, list) and remove_credential: + refs.difference_update(remove_credential) + modified = True + add_credential = kwargs.get('add_credential') + if isinstance(add_credential, list) and add_credential: + refs.update(add_credential) + modified = True + if modified: + script_value['recordRef'] = list(refs) + command = kwargs.get('script_command') + if command: + script_value['command'] = command + modified = True + + if not modified: + raise CommandError('rotate script', 'Nothing to do') + + record_management.update_record(params, record) + params.sync_data = True + + +class PAMScriptDeleteCommand(Command): + parser = argparse.ArgumentParser(prog='pam rotate script delete', description='Delete script field') + parser.add_argument('--script', required=True, dest='script', action='store', + help='Script UID or name') + parser.add_argument('record', help='Record UID or Title') + + def get_parser(self): + return PAMScriptDeleteCommand.parser + + def execute(self, params, **kwargs): + record_name = kwargs.get('record') + if not record_name: + raise CommandError('rotate script', '"record" argument is required') + + script_name = kwargs.get('script') # type: Optional[str] + if not script_name: + raise CommandError('rotate script', '"script" argument is required') + + records = list(vault_extensions.find_records( + params, search_str=record_name, record_version=3, record_type=('pamUser', 'pamDirectory'))) + if len(records) == 0: + raise CommandError('rotate script', f'Record "{record_name}" not found') + if len(records) > 1: + raise CommandError('rotate script', f'Record "{record_name}" is not unique. Use record UID.') + record = records[0] + if not isinstance(record, vault.TypedRecord): + raise CommandError('rotate script', f'Record "{record.title}" is not a rotation record.') + + script_field = next((x for x in record.fields if x.type == 'script'), None) + if script_field is None: + raise CommandError('rotate script', f'Record "{record.title}" has no rotation scripts.') + script_value = next((x for x in script_field.value if x.get('fileRef') == script_name), None) + if script_value is None: + s_name = script_name.casefold() + for x in script_field.value: + file_uid = x.get('fileRef') + file_record = vault.KeeperRecord.load(params, file_uid) + if isinstance(file_record, vault.FileRecord): + if file_record.title.casefold() == s_name: + script_value = x + break + elif file_record.name.casefold() == s_name: + script_value = x + break + + if not isinstance(script_value, dict): + raise CommandError('rotate script', f'Record "{record.title}" does not have script "{script_name}"') + + script_field.value.remove(script_value) + record_management.update_record(params, record) + params.sync_data = True + + +class PAMGatewayActionJobCancelCommand(Command): + parser = argparse.ArgumentParser(prog='pam-action-job-cancel-command') + parser.add_argument('job_id') + + def get_parser(self): + return PAMGatewayActionJobCancelCommand.parser + + def execute(self, params, **kwargs): + + job_id = kwargs.get('job_id') + + print(f"Job id to cancel [{job_id}]") + + generic_job_id_inputs = GatewayActionJobInfoInputs(job_id) + + conversation_id = GatewayAction.generate_conversation_id() + router_response = router_send_action_to_gateway( + params=params, + gateway_action=GatewayActionJobCancel(inputs=generic_job_id_inputs, conversation_id=conversation_id), + message_type=pam_pb2.CMT_GENERAL, + is_streaming=False + ) + print_router_response(router_response, conversation_id) + + +class PAMGatewayActionJobCommand(Command): + parser = argparse.ArgumentParser(prog='pam-action-job-command') + parser.add_argument('--gateway', '-g', required=False, dest='gateway_uid', action='store', + help='Gateway UID. Needed only if there are more than one gateway running') + parser.add_argument('job_id') + + def get_parser(self): + return PAMGatewayActionJobCommand.parser + + def execute(self, params, **kwargs): + + job_id = kwargs.get('job_id') + gateway_uid = kwargs.get('gateway_uid') + + print(f"Job id to check [{job_id}]") + + action_inputs = GatewayActionJobInfoInputs(job_id) + + conversation_id = GatewayAction.generate_conversation_id() + router_response = router_send_action_to_gateway( + params=params, + gateway_action=GatewayActionJobInfo( + inputs=action_inputs, + conversation_id=conversation_id), + message_type=pam_pb2.CMT_GENERAL, + is_streaming=False, + destination_gateway_uid_str=gateway_uid + ) + + print_router_response(router_response, original_conversation_id=conversation_id, response_type='job_info') + + +class PAMGatewayActionRotateCommand(Command): + parser = argparse.ArgumentParser(prog='dr-rotate-command') + parser.add_argument('--record-uid', '-r', required=True, dest='record_uid', action='store', + help='Record UID to rotate') + # parser.add_argument('--config', '-c', required=True, dest='configuration_uid', action='store', + # help='Rotation configuration UID') + + def get_parser(self): + return PAMGatewayActionRotateCommand.parser + + def execute(self, params, **kwargs): + record_uid = kwargs.get('record_uid') + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord): + print(f'{bcolors.FAIL}Record [{record_uid}] is not available.{bcolors.ENDC}') + return + + if not hasattr(params, 'pam_controllers'): + router_get_connected_gateways(params) + + # Find record by record uid + ri = record_rotation_get(params, utils.base64_url_decode(record.record_uid)) + ri_pwd_complexity_encrypted = ri.pwdComplexity + if not ri_pwd_complexity_encrypted: + rule_list_dict = { + 'length': 20, + 'caps': 1, + 'lowercase': 1, + 'digits': 1, + 'special': 1, + } + ri_pwd_complexity_encrypted = utils.base64_url_encode(router_helper.encrypt_pwd_complexity(rule_list_dict, record.record_key)) + # else: + # rule_list_json = crypto.decrypt_aes_v2(utils.base64_url_decode(ri_pwd_complexity_encrypted), record.record_key) + # complexity = json.loads(rule_list_json.decode()) + + ri_rotation_setting_uid = utils.base64_url_encode(ri.configurationUid) # Configuration on the UI is "Rotation Setting" + resource_uid = utils.base64_url_encode(ri.resourceUid) + + pam_config = vault.KeeperRecord.load(params, ri_rotation_setting_uid) + if not isinstance(pam_config, vault.TypedRecord): + print(f'{bcolors.FAIL}PAM Configuration [{ri_rotation_setting_uid}] is not available.{bcolors.ENDC}') + return + facade = PamConfigurationRecordFacade() + facade.record = pam_config + + # Find connected controllers + enterprise_controllers_connected = router_get_connected_gateways(params) + + if enterprise_controllers_connected: + # Find connected controller (TODO: Optimize, don't search for controllers every time, no N^n) + router_controllers = list(enterprise_controllers_connected.controllers) + controller_from_config_bytes = utils.base64_url_decode(facade.controller_uid) + connected_controller = next((x.controllerUid for x in router_controllers + if x.controllerUid == controller_from_config_bytes), None) + + if not connected_controller: + print(f'{bcolors.WARNING}The Gateway "{facade.controller_uid}" is down.{bcolors.ENDC}') + return + else: + print(f'{bcolors.WARNING}There are no connected gateways.{bcolors.ENDC}') + return + + + # rrs = RouterRotationStatus.Name(ri.status) + # if rrs == 'RRS_NO_ROTATION': + # print(f'{bcolors.FAIL}Record [{record_uid}] does not have rotation associated with it.{bcolors.ENDC}') + # return + # elif rrs == 'RRS_CONTROLLER_DOWN': + # controller_details = next((ctr for ctr in all_enterprise_controllers_all if ctr.controllerUid == ri.controllerUid), None) + # + # print(f'{bcolors.WARNING}The Gateway "{controller_details.controllerName}" [uid={ri_controller_uid}] ' + # f'that is setup to perform this rotation is currently offline.{bcolors.ENDC}') + # return + # elif rrs == 'RRS_NO_CONTROLLER': + # print(f'{bcolors.FAIL}There is no such gateway (uid: {pam_config_data.get("controllerUid")}) exists that is associated to PAM Configuration \'{pam_config_data.get("name")}\' (uid: {CommonHelperMethods.bytes_to_url_safe_str(ri.configurationUid)}).{bcolors.ENDC}') + # return + # elif rrs == 'RRS_ONLINE': + # print(f'{bcolors.OKGREEN}Gateway is online{bcolors.ENDC}') + # else: + # print(f'{bcolors.FAIL}Unknown router rotation status [{rrs}]{bcolors.ENDC}') + # return + + action_inputs = GatewayActionRotateInputs( + record_uid=record_uid, + configuration_uid=ri_rotation_setting_uid, + pwd_complexity_encrypted=ri_pwd_complexity_encrypted, + resource_uid=resource_uid + ) + + conversation_id = GatewayAction.generate_conversation_id() + + router_response = router_send_action_to_gateway( + params=params, gateway_action=GatewayActionRotate(inputs=action_inputs, conversation_id=conversation_id, + gateway_destination=facade.controller_uid), + message_type=pam_pb2.CMT_ROTATE, is_streaming=False) + + print_router_response(router_response, conversation_id) + + +class PAMGatewayActionServerInfoCommand(Command): + parser = argparse.ArgumentParser(prog='dr-info-command') + parser.add_argument('--gateway', '-g', required=False, dest='gateway_uid', action='store', help='Gateway UID') + parser.add_argument('--verbose', '-v', required=False, dest='verbose', action='store_true', help='Verbose Output') + + def get_parser(self): + return PAMGatewayActionServerInfoCommand.parser + + def execute(self, params, **kwargs): + destination_gateway_uid_str = kwargs.get('gateway_uid') + is_verbose = kwargs.get('verbose') + router_response = router_send_action_to_gateway( + params=params, + gateway_action=GatewayActionGatewayInfo(is_scheduled=False), + message_type=pam_pb2.CMT_GENERAL, + is_streaming=False, + destination_gateway_uid_str=destination_gateway_uid_str + ) + + print_router_response(router_response, response_type='gateway_info', is_verbose=is_verbose) + + +class PAMGatewayRemoveCommand(Command): + dr_remove_controller_parser = argparse.ArgumentParser(prog='dr-remove-gateway') + dr_remove_controller_parser.add_argument('--gateway', '-g', required=True, dest='gateway', + help='UID of the Gateway', action='store') + + def get_parser(self): + return PAMGatewayRemoveCommand.dr_remove_controller_parser + + def execute(self, params, **kwargs): + gateway_name = kwargs.get('gateway') + gateways = gateway_helper.get_all_gateways(params) + + gateway = next((x for x in gateways + if utils.base64_url_encode(x.controllerUid) == gateway_name + or x.controllerName.lower() == gateway_name.lower()), None) + if gateway: + gateway_helper.remove_gateway(params, gateway.controllerUid) + logging.info('Gateway %s has been removed.', gateway.controllerName) + else: + logging.warning('Gateway %s not found', gateway_name) + + +class PAMCreateGatewayCommand(Command): + + dr_create_controller_parser = argparse.ArgumentParser(prog='dr-create-gateway') + dr_create_controller_parser.add_argument('--name', '-n', required=True, dest='gateway_name', + help='Name of the Gateway', + action='store') + dr_create_controller_parser.add_argument('--application', '-a', required=True, dest='ksm_app', + help='KSM Application name or UID. Use command `sm app list` to view ' + 'available KSM Applications.', action='store') + dr_create_controller_parser.add_argument('--token-expires-in-min', '-e', type=int, dest='token_expire_in_min', + action='store', + help='Time for the one time token to expire. Maximum 1440 minutes (24 hrs). Default: 60', + default=60) + dr_create_controller_parser.add_argument('--return_value', '-r', dest='return_value', action='store_true', + help='Return value from the command for automation purposes') + dr_create_controller_parser.add_argument('--config-init', '-c', type=str, dest='config_init', action='store', + choices=['json', 'b64'], help='Initialize client config and return configuration string.') # json, b64, file + + def get_parser(self): + return PAMCreateGatewayCommand.dr_create_controller_parser + + def execute(self, params, **kwargs): + + gateway_name = kwargs.get('gateway_name') + ksm_app = kwargs.get('ksm_app') + is_return_value = kwargs.get('return_value') + config_init = kwargs.get('config_init') + token_expire_in_min = kwargs.get('token_expire_in_min') + + ott_expire_in_min = token_expire_in_min + + logging.debug(f'gateway_name =[{gateway_name}]') + logging.debug(f'ksm_app =[{ksm_app}]') + logging.debug(f'ott_expire_in_min =[{ott_expire_in_min}]') + + one_time_token = gateway_helper.create_gateway(params, gateway_name, ksm_app, config_init, ott_expire_in_min) + + if is_return_value: + return one_time_token + else: + print(f'The one time token has been created in application [{bcolors.OKBLUE}{ksm_app}{bcolors.ENDC}].\n\n' + f'The new Gateway named {bcolors.OKBLUE}{gateway_name}{bcolors.ENDC} will show up in a list ' + f'of gateways once it is initialized on the Gateway.\n\n') + + if config_init: + print('Use following initialized config be used in the controller:') + else: + print(f'Following one time token will expire in {bcolors.OKBLUE}{ott_expire_in_min}{bcolors.ENDC} ' + f'minutes):') + + print('-----------------------------------------------') + print(bcolors.OKGREEN + one_time_token + bcolors.ENDC) + print('-----------------------------------------------') + + +class SocketNotConnectedException(Exception): + pass + +def retrieve_gateway_public_key(gateway_uid, params, api, utils) -> bytes: + gateway_uid_bytes = utils.base64_url_decode(gateway_uid) + get_ksm_pubkeys_rq = GetKsmPublicKeysRequest() + get_ksm_pubkeys_rq.controllerUids.append(gateway_uid_bytes) + get_ksm_pubkeys_rs = api.communicate_rest(params, get_ksm_pubkeys_rq, 'vault/get_ksm_public_keys', + rs_type=GetKsmPublicKeysResponse) + + if len(get_ksm_pubkeys_rs.keyResponses) == 0: + # No keys found + print(f"{bcolors.FAIL}No keys found for gateway {gateway_uid}{bcolors.ENDC}") + return b'' + try: + gateway_public_key_bytes = get_ksm_pubkeys_rs.keyResponses[0].publicKey + except Exception as e: + # No public key found + print(f"{bcolors.FAIL}Error getting public key for gateway {gateway_uid}: {e}{bcolors.ENDC}") + gateway_public_key_bytes = b'' + + return gateway_public_key_bytes From f31d891c6cdffdd8fa1a58159e2252cab7fc6a1e Mon Sep 17 00:00:00 2001 From: idimov-keeper <78815270+idimov-keeper@users.noreply.github.com> Date: Tue, 21 Jan 2025 13:36:20 -0600 Subject: [PATCH 3/6] Added pam split command (#1362) * added pam split command * fixed a typo --- keepercommander/commands/discoveryrotation.py | 273 +++++++++++++++++- .../commands/tunnel/port_forward/endpoint.py | 10 +- keepercommander/record_management.py | 4 +- 3 files changed, 281 insertions(+), 6 deletions(-) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 083f5b834..3de403167 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -59,7 +59,8 @@ from ..error import CommandError, KeeperApiError from ..params import KeeperParams, LAST_RECORD_UID from ..proto import pam_pb2, router_pb2, record_pb2 -from ..subfolder import find_parent_top_folder, try_resolve_path, BaseFolderNode +from ..subfolder import find_folders, find_parent_top_folder, \ + try_resolve_path, BaseFolderNode from ..vault import TypedField from .discover.job_start import PAMGatewayActionDiscoverJobStartCommand from .discover.job_status import PAMGatewayActionDiscoverJobStatusCommand @@ -92,6 +93,7 @@ def __init__(self): self.register_command('rotation', PAMRotationCommand(), 'Manage Rotations', 'r') self.register_command('action', GatewayActionCommand(), 'Execute action on the Gateway', 'a') self.register_command('tunnel', PAMTunnelCommand(), 'Manage Tunnels', 't') + self.register_command('split', PAMSplitCommand(), 'Split credentials from legacy PAM Machine', 's') self.register_command('legacy', PAMLegacyCommand(), 'Switch to legacy PAM commands') @@ -2587,10 +2589,17 @@ def execute(self, params, **kwargs): # Generate a 256-bit (32-byte) random seed seed = os.urandom(32) dirty = False - if not traffic_encryption_key.value: + if not traffic_encryption_key or not traffic_encryption_key.value: base64_seed = bytes_to_base64(seed) record_seed = vault.TypedField.new_field('trafficEncryptionSeed', base64_seed, "") - record.custom.append(record_seed) + # if field is present update in-place, if in rec definition add to fields[] else custom[] + record_types_with_seed = ("pamDatabase", "pamDirectory", "pamMachine", "pamRemoteBrowser") + if traffic_encryption_key: + traffic_encryption_key.value = [base64_seed] + elif record.get_record_type() in record_types_with_seed: + record.fields.append(record_seed) # DU-469 + else: + record.custom.append(record_seed) dirty = True if dirty: record_management.update_record(params, record) @@ -3111,3 +3120,261 @@ def get_gateway_uid_from_record(self, params, record_uid): gateway_uid = value.get('controllerUid', '') or '' return gateway_uid + + +class PAMSplitCommand(Command): + pam_cmd_parser = argparse.ArgumentParser(prog='pam split') + pam_cmd_parser.add_argument('pam_machine_record', type=str, action='store', + help='The record UID or title of the legacy PAM Machine ' + 'record with built-in PAM User credentials.') + pam_cmd_parser.add_argument('--configuration', '-c', required=False, dest='pam_config', action='store', + help='The PAM Configuration Name or UID - If the legacy record was configured ' + 'for rotation this command will try to autodetect PAM Configuration settings ' + 'otherwise you\'ll be prompted to provide the PAM Config.') + pam_cmd_parser.add_argument('--folder', '-f', required=False, dest='pam_user_folder', action='store', + help='The folder where to store the new PAM User record - ' + 'folder names/paths are case sensitive!' + '(if skipped - PAM User will be created into the ' + 'same folder as PAM Machine)') + + def get_parser(self): + return PAMSplitCommand.pam_cmd_parser + + def execute(self, params, **kwargs): + def remove_field(record, field): # type: (vault.TypedRecord, vault.TypedField) -> bool + # Since TypedRecord.get_typed_field scans both fields[] and custom[] + # we need corresponding remove field lookup + fld = next((x for x in record.fields if field.type == x.type and + (not field.label or + (x.label and field.label.casefold() == x.label.casefold()))), None) + if fld is not None: + record.fields.remove(field) + return True + + fld = next((x for x in record.custom if field.type == x.type and + (not field.label or + (x.label and field.label.casefold() == x.label.casefold()))), None) + if fld is not None: + record.custom.remove(field) + return True + + return False + + def resolve_record(params, name): + record_uid = None + if name in params.record_cache: + record_uid = name # unique record UID + else: + # lookup unique folder/record path + rs = try_resolve_path(params, name) + if rs is not None: + folder, name = rs + if folder is not None and name is not None: + folder_uid = folder.uid or '' + if folder_uid in params.subfolder_record_cache: + for uid in params.subfolder_record_cache[folder_uid]: + r = api.get_record(params, uid) + if r.title.lower() == name.lower(): + record_uid = uid + break + if not record_uid: + # lookup unique record title + records = [] + for uid in params.record_cache: + data_json = params.record_cache[uid].get("data_unencrypted", "{}") or {} + data = json.loads(data_json) + if "pamMachine" == str(data.get("type", "")): + title = data.get('title', '') or '' + if title.lower() == name.lower(): + records.append(uid) + uniq_recs = len(set(records)) + if uniq_recs > 1: + print(f"{bcolors.FAIL}Multiple PAM Machine records match title '{name}' - " + f"specify unique record path/name.{bcolors.ENDC}") + elif records: + record_uid = records[0] + return record_uid + + def resolve_folder(params, name): + folder_uid = '' + if name: + # lookup unique folder path + folder_uid = FolderMixin.resolve_folder(params, name) + # lookup unique folder name/uid + if not folder_uid and name != '/': + folders = [] + for fkey in params.subfolder_cache: + data_json = params.subfolder_cache[fkey].get('data_unencrypted', '{}') or {} + data = json.loads(data_json) + fname = data.get('name', '') or '' + if fname == name: + folders.append(fkey) + uniq_items = len(set(folders)) + if uniq_items > 1: + print(f"{bcolors.FAIL}Multiple folders match '{name}' - specify unique " + f"folder name or use folder UID (or omit --folder parameter to create " + f"PAM User record in same folder as PAM Machine record).{bcolors.ENDC}") + folders = [] + folder_uid = folders[0] if folders else '' + return folder_uid + + def resolve_pam_config(params, record_uid, pam_config_option): + # PAM Config lookup - Legacy PAM Machine will have associated PAM Config + # only if it is set up for rotation - otherwise PAM Config must be provided + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + pamcfg_rec = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, record_uid) + if not pamcfg_rec and not pam_config_option: + print(f"{bcolors.FAIL}Unable to find PAM Config associated with record '{record_uid}' " + "- please provide PAM Config with --configuration|-c option. " + "(Note: Legacy PAM Machine is linked to PAM Config only if " + f"the machine is set up for rotation).{bcolors.ENDC}") + return + + pamcfg_cmd = '' + if pam_config_option: + pam_uids = [] + for uid in params.record_cache: + if params.record_cache[uid].get('version', 0) == 6: + r = api.get_record(params, uid) + if r.record_uid == pam_config_option or r.title.lower() == pam_config_option.lower(): + pam_uids.append(uid) + uniq_recs = len(set(pam_uids)) + if uniq_recs > 1: + print(f"{bcolors.FAIL}Multiple PAM Config records match '{pam_config_option}' - " + f"specify unique record UID/Title.{bcolors.ENDC}") + elif pam_uids: + pamcfg_cmd = pam_uids[0] + elif not pamcfg_rec: + print(f"{bcolors.FAIL}Unable to find PAM Configuration '{pam_config_option}'.{bcolors.ENDC}") + + # PAM Config set on command line overrides the PAM Machine associated PAM Config + pam_config_uid = pamcfg_cmd or pamcfg_rec or "" + if pamcfg_cmd and pamcfg_rec and pamcfg_cmd != pamcfg_rec: + print(f"{bcolors.WARNING}PAM Config associated with record '{record_uid}' " + "is different from PAM Config set with --configuration|-c option. " + f"Using the configuration from command line option.{bcolors.ENDC}") + + return pam_config_uid + + # Parse command params + pam_config = kwargs.get('pam_config', '') # PAM Configuration Name or UID + folder = kwargs.get('pam_user_folder', '') # destination folder + record_uid = kwargs.get('pam_machine_record', '') # existing record UID + + record_uid = resolve_record(params, record_uid) or record_uid + record = vault.KeeperRecord.load(params, record_uid) + if not record: + raise CommandError('', f"{bcolors.FAIL}Record {record_uid} not found.{bcolors.ENDC}") + if not isinstance(record, vault.TypedRecord) or record.record_type != "pamMachine": + raise CommandError('', f"{bcolors.FAIL}Record {record_uid} is not of the expected type 'pamMachine'.{bcolors.ENDC}") + + pam_config_uid = resolve_pam_config(params, record_uid, pam_config) + if not pam_config_uid: + print(f"{bcolors.FAIL}Please provide a valid PAM Configuration.{bcolors.ENDC}") + return + # print(f"{bcolors.WARNING}Failed to find PAM Configuration for {record_uid} " + # "and unable to link new PAM User to PAM Machine. Remember to manually link " + # f"Administrative Credentials record later.{bcolors.ENDC}") + + folder_uid = resolve_folder(params, folder) + if folder and not folder_uid: + print(f"{bcolors.WARNING}Unable to find destination folder '{folder}' " + "(Note: folder names/paths are case sensitive) " + "- PAM User record will be stored into same folder " + f"as the originating PAM Machine record.{bcolors.ENDC}") + + flogin = record.get_typed_field('login') + vlogin = flogin.get_default_value(str) if flogin else '' + fpass = record.get_typed_field('password') + vpass = fpass.get_default_value(str) if fpass else '' + fpkey = record.get_typed_field('secret') + vpkey = fpkey.get_default_value(str) if fpkey else '' + if not(vlogin or vpass or vpkey): + if not(flogin or fpass or fpkey): + print(f"{bcolors.WARNING}Record {record_uid} is already in the new format.{bcolors.ENDC}") + else: + # No values present - just drop the old fields and add new ones + # thus converting the record to the new pamMachine format + # NB! If record was edited - newer clients moved these to custom fields + if flogin: + remove_field(record, flogin) + if fpass: + remove_field(record, fpass) + if fpkey: + remove_field(record, fpkey) + + if not record.get_typed_field('trafficEncryptionSeed'): + record_seed = vault.TypedField.new_field('trafficEncryptionSeed', "", "") + record.fields.append(record_seed) + if not record.get_typed_field('pamSettings'): + pam_settings = vault.TypedField.new_field('pamSettings', "", "") + record.fields.append(pam_settings) + + record_management.update_record(params, record) + params.sync_data = True + + print(f"{bcolors.WARNING}Record {record_uid} has no data to split and " + "was converted to the new format. Remember to manually add " + f"Administrative Credentials later.{bcolors.ENDC}") + return + elif not vlogin or not(vpass or vpkey): + print(f"{bcolors.WARNING}Record {record_uid} has incomplete user data " + "but splitting anyway. Remember to manually update linked " + f"Administrative Credentials record later.{bcolors.ENDC}") + + # Create new pamUser record + user_rec = vault.KeeperRecord.create(params, 'pamUser') + user_rec.type_name = 'pamUser' + user_rec.title = str(record.title) + ' Admin User' + if flogin: + field = user_rec.get_typed_field('login') + field.value = flogin.value + if fpass: + field = user_rec.get_typed_field('password') + field.value = fpass.value + if fpkey: + field = user_rec.get_typed_field('secret') + field.value = fpkey.value + + if not folder_uid: # use the folder of the PAM Machine record + folders = list(find_folders(params, record.record_uid)) + uniq_items = len(set(folders)) + if uniq_items < 1: + print(f"{bcolors.WARNING}The new record will be created in root folder.{bcolors.ENDC}") + elif uniq_items > 1: + print(f"{bcolors.FAIL}Record '{record.record_uid}' is probably " + "a linked record with copies/links across multiple folders " + f"and PAM User record will be created in folder '{folders[0]}'.{bcolors.ENDC}") + folder_uid = folders[0] if folders else '' # '' means root folder + + record_management.add_record_to_folder(params, user_rec, folder_uid) + pam_user_uid = params.environment_variables.get(LAST_RECORD_UID, '') + api.sync_down(params) + + if flogin: + remove_field(record, flogin) + if fpass: + remove_field(record, fpass) + if fpkey: + remove_field(record, fpkey) + + if not record.get_typed_field('trafficEncryptionSeed'): + record_seed = vault.TypedField.new_field('trafficEncryptionSeed', "", "") + record.fields.append(record_seed) + if not record.get_typed_field('pamSettings'): + pam_settings = vault.TypedField.new_field('pamSettings', "", "") + record.fields.append(pam_settings) + + record_management.update_record(params, record) + params.sync_data = True + + if pam_config_uid: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + tdag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, pam_config_uid) + tdag.link_resource_to_config(record_uid) + tdag.link_user_to_resource(pam_user_uid, record_uid, True, True) + + print(f"PAM Machine record {record_uid} user credentials were split into " + f"a new PAM User record {pam_user_uid}") + + return diff --git a/keepercommander/commands/tunnel/port_forward/endpoint.py b/keepercommander/commands/tunnel/port_forward/endpoint.py index ff350a2cc..d93c1186f 100644 --- a/keepercommander/commands/tunnel/port_forward/endpoint.py +++ b/keepercommander/commands/tunnel/port_forward/endpoint.py @@ -472,7 +472,7 @@ def set_resource_allowed(self, resource_uid, tunneling=None, connections=None, r session_recording=None, typescript_recording=None, allowed_settings_name='allowedSettings', is_config=False, v_type: RefType=str(RefType.PAM_MACHINE)): - v_type = RefType(v_type) + v_type = RefType(v_type) allowed_ref_types = [RefType.PAM_MACHINE, RefType.PAM_DATABASE, RefType.PAM_DIRECTORY, RefType.PAM_BROWSER] if v_type not in allowed_ref_types: # default to machine @@ -506,6 +506,12 @@ def set_resource_allowed(self, resource_uid, tunneling=None, connections=None, r if rotation is not None and rotation != settings.get("rotation", True): settings["rotation"] = rotation dirty = True + # some clients disagree with error "Rotation is disabled by the PAM configuration." + # where it seems to default to False, and PAM configurations block all records + # as a workaround always explicitly set rotation for PAM Config types + if resource_vertex.vertex_type == RefType.PAM_NETWORK and rotation is not None and rotation != settings.get("rotation", False): + settings["rotation"] = rotation + dirty = True if session_recording is not None and session_recording != settings.get("sessionRecording", False): settings["sessionRecording"] = session_recording dirty = True @@ -541,7 +547,7 @@ def print_tunneling_config(self, record_uid, pam_settings=None, config_uid=None) self.linking_dag.load() vertex = self.linking_dag.get_vertex(record_uid) content = self.get_vertex_content(vertex) - config_id = config_uid if config_uid else pam_settings.value[0].get('configUid') + config_id = config_uid if config_uid else pam_settings.value[0].get('configUid') if pam_settings else None if content and content.get('allowedSettings'): allowed_settings = content['allowedSettings'] print(f"{bcolors.OKGREEN}Settings configured for {record_uid}{bcolors.ENDC}") diff --git a/keepercommander/record_management.py b/keepercommander/record_management.py index 13caa8c6b..e68f71daa 100644 --- a/keepercommander/record_management.py +++ b/keepercommander/record_management.py @@ -18,7 +18,7 @@ from . import api, subfolder, utils, crypto, vault, vault_extensions from .error import KeeperApiError -from .params import KeeperParams +from .params import KeeperParams, LAST_RECORD_UID from .proto import record_pb2 @@ -134,6 +134,8 @@ def add_record_to_folder(params, record, folder_uid=None): else: raise ValueError('Unsupported Keeper record') + params.environment_variables[LAST_RECORD_UID] = record.record_uid + class RecordChangeStatus(enum.Flag): Title = enum.auto() From be895a94ea9e8c8b527148a24a541c798c5381b0 Mon Sep 17 00:00:00 2001 From: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> Date: Tue, 21 Jan 2025 14:03:47 -0600 Subject: [PATCH 4/6] Added remote browser isolation to PAM Configuratrion commands --- keepercommander/commands/discoveryrotation.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 3de403167..6107b77c6 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -1366,6 +1366,8 @@ class PAMConfigurationNewCommand(Command, PamConfigurationEditMixin): action='store_true', help='Enable tunneling') parser.add_argument('--enable-rotation', '-er', dest='enable_rotation', action='store_true', help='Enable rotation') + parser.add_argument('--enable-remote-browser-isolation', '-erbi', dest='enable_remotebrowserisolation', + action='store_true', help='Enable remote browser isolation') parser.add_argument('--enable-connections-recording', '-ecr', required=False, dest='recordingenabled', action='store_true', help='Enable recording connections for the resource') parser.add_argument('--enable-typescripts-recording', '-etcr', required=False, dest='typescriptrecordingenabled', @@ -1434,7 +1436,8 @@ def execute(self, params, **kwargs): bool(kwargs.get('enable_tunneling')), bool(kwargs.get('enable_rotation')), bool(kwargs.get('recordingenabled')), - bool(kwargs.get('typescriptrecordingenabled')) + bool(kwargs.get('typescriptrecordingenabled')), + bool(kwargs.get('enable_remotebrowserisolation')) ) tmp_dag.print_tunneling_config(record.record_uid, None) @@ -1473,6 +1476,10 @@ class PAMConfigurationEditCommand(Command, PamConfigurationEditMixin): help='Enable connections') parser.add_argument('--disable-connections', '-dc', required=False, dest='disable_connections', action='store_true', help='Enable connections') + parser.add_argument('--enable-remote-browser-isolation', '-erbi', required=False, dest='enable_remotebrowserisolation', action='store_true', + help='Enable remote browser isolation') + parser.add_argument('--disable-remote-browser-isolation', '-drbi', required=False, dest='disable_remotebrowserisolation', action='store_true', + help='Disable remote browser isolation') parser.add_argument('--enable-connections-recording', '-ecr', required=False, dest='enable_connections_recording', action='store_true', help='Enable connections recording') parser.add_argument('--disable-connections-recording', '-dcr', required=False, dest='disable_connections_recording', @@ -1563,6 +1570,7 @@ def execute(self, params, **kwargs): if ((kwargs.get('enable_connections') and kwargs.get('disable_connections')) or (kwargs.get('enable_tunneling') and kwargs.get('disable_tunneling')) or (kwargs.get('enable_rotation') and kwargs.get('disable_rotation')) or + (kwargs.get('enable_remotebrowserisolation') and kwargs.get('disable_remotebrowserisolation')) or (kwargs.get('enable_connections_recording') and kwargs.get('disable_connections_recording')) or (kwargs.get('enable_typescripts_recording') and kwargs.get('disable_typescripts_recording'))): raise CommandError('pam-config-edit', 'Cannot enable and disable the same feature at the same time') @@ -1570,19 +1578,23 @@ def execute(self, params, **kwargs): # First check if enabled is true then check if disabled is true. if not then set it to None _connections = True if kwargs.get('enable_connections') \ else False if kwargs.get('disable_connections') else None - _tunneling = True if kwargs.get('enable_tunneling') else False if kwargs.get('disable_tunneling') else None - _rotation = True if kwargs.get('enable_rotation') else False if kwargs.get('disable_rotation') else None + _tunneling = True if kwargs.get('enable_tunneling') \ + else False if kwargs.get('disable_tunneling') else None + _rotation = True if kwargs.get('enable_rotation') \ + else False if kwargs.get('disable_rotation') else None + _rbi = True if kwargs.get('enable_remotebrowserisolation') \ + else False if kwargs.get('disable_remotebrowserisolation') else None _recording = True if kwargs.get('enable_connections_recording') \ else False if kwargs.get('disable_connections_recording') else None - _typescript_recording = (True if kwargs.get('enable_typescripts_recording') else False if - kwargs.get('disable_typescripts_recording') else None) + _typescript_recording = True if kwargs.get('enable_typescripts_recording') \ + else False if kwargs.get('disable_typescripts_recording') else None - if (_connections is not None or _tunneling is not None or _rotation is not None or _recording is not None or - _typescript_recording is not None): + if (_connections is not None or _tunneling is not None or _rotation is not None or _rbi is not None or + _recording is not None or _typescript_recording is not None): encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) tmp_dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, configuration.record_uid, is_config=True) - tmp_dag.edit_tunneling_config(_connections, _tunneling, _rotation, _recording, _typescript_recording) + tmp_dag.edit_tunneling_config(_connections, _tunneling, _rotation, _recording, _typescript_recording, _rbi) tmp_dag.print_tunneling_config(configuration.record_uid, None) for w in self.warnings: logging.warning(w) From dc1417e4828ac5c578fe5acc446cf265bb336726 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Tue, 21 Jan 2025 13:29:39 -0800 Subject: [PATCH 5/6] EC-only MSP. MC does not have forbid_rsa flag preserved --- keepercommander/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/keepercommander/api.py b/keepercommander/api.py index 3433ae54d..ed5afe305 100644 --- a/keepercommander/api.py +++ b/keepercommander/api.py @@ -1341,6 +1341,7 @@ def login_and_get_mc_params_login_v3(params: KeeperParams, mc_id): mc_params.rsa_key = params.rsa_key mc_params.rsa_key2 = params.rsa_key2 mc_params.ecc_key = params.ecc_key + mc_params.forbid_rsa = params.forbid_rsa mc_params.session_token = loginv3.CommonHelperMethods.bytes_to_url_safe_str(resp.encryptedSessionToken) mc_params.msp_tree_key = params.enterprise['unencrypted_tree_key'] From 398de0bf10d1ee255f3d46dc8806732d79cad9a0 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Tue, 21 Jan 2025 13:32:32 -0800 Subject: [PATCH 6/6] Release 17.0.5 --- keepercommander/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index 944580af1..7cdfc8ac4 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: ops@keepersecurity.com # -__version__ = '17.0.4' +__version__ = '17.0.5'