From ab6267f3777511dadedc28222de0fbe07ed744e9 Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Sat, 13 Jun 2026 04:36:06 +0100 Subject: [PATCH 01/18] Support empty user/ports and other improvements (#2122) * Add support for port mapping If connection has empty port, it will default to the ports defined in this file (can be customized for custom default ports) Also added new parameter allow-file-uploads for RBI * Support empty user/ports and other improvements If a KCM connection has no user, the export will log the record for future process. If a KCM connection has no port, it will default to the port mapping defined in KCM_mappings.json Fixed a duplication issue of SFTP parameters caused by a reference error. Set the autodocker docker-file location as default Reworked how logged records are displayed on output file. --- examples/pam-kcm-import/KCM_mappings.json | 11 ++ examples/pam-kcm-import/kcm_export.py | 132 +++++++++++++--------- 2 files changed, 88 insertions(+), 55 deletions(-) diff --git a/examples/pam-kcm-import/KCM_mappings.json b/examples/pam-kcm-import/KCM_mappings.json index d027b7271..2c60f90fe 100644 --- a/examples/pam-kcm-import/KCM_mappings.json +++ b/examples/pam-kcm-import/KCM_mappings.json @@ -1,4 +1,14 @@ { + "ports":{ + "rdp":3389, + "ssh":22, + "vnc":5900, + "telnet":23, + "kubernetes":8080, + "mysql":3306, + "postgresql":5432, + "sql-server":1433 + }, "users":{ "username":"login", "password":"password", @@ -21,6 +31,7 @@ "disable-copy": "pam_settings.connection.disable_copy", "disable-paste": "pam_settings.connection.disable_paste", "force-lossless": null, + "allow-file-uploads":null, "read-only": null, "backspace": null, "url": "url", diff --git a/examples/pam-kcm-import/kcm_export.py b/examples/pam-kcm-import/kcm_export.py index d589ddd71..2aaf45648 100644 --- a/examples/pam-kcm-import/kcm_export.py +++ b/examples/pam-kcm-import/kcm_export.py @@ -205,8 +205,9 @@ def collect_db_config(self): '(2) I have hardcoded them in the Python script' ]) if handle_prompt({'1':'file','2':'code'}) == 'file': - display('## Please upload your docker-compose file', 'cyan') - self.docker_compose = validate_file_upload('yaml') + display('## Please upload your docker-compose file\n(blank for /etc/kcm-setup/docker-compose.yml)', 'cyan') + docker_file = input('File path: ') or '/etc/kcm-setup/docker-compose.yml' + self.docker_compose = validate_file_upload('yaml',docker_file) port={'MYSQL':3306,'POSTGRES':5432} custom_port = None @@ -340,9 +341,9 @@ def generate_data(self): templ = validate_file_upload('json') templ_resources = templ.get('pam_data',{}).get('resources',[]) if len(templ_resources)>1: - self.template_rs = templ_resources[1] + self.template_rs = deepcopy(templ_resources[1]) if self.template_rs.get('users',[]): - self.template_usr = self.template_rs['users'][0] + self.template_usr = deepcopy(self.template_rs['users'][0]) print() self.group_paths = {} @@ -373,6 +374,55 @@ def resolve_path(group_id): self.users = {} self.shared_folders = [] + def handle_mapping(mapping, name, arg, value, dir): + if mapping == 'ignore': + debug(f'Mapping {arg} ignored', self.debug) + return dir + if mapping == 'log': + if name not in self.logged_records: + self.logged_records[name] = {'name':name} + if 'parameters' not in self.logged_records: + self.logged_records[name]['parameters'] = [] + self.logged_records[name]['parameters'].append(arg) + return dir + if mapping is None: + debug(f'Mapping {arg} recognized but not supported', self.debug) + return dir + if '=' in mapping: + mapping, value = mapping.split('=', 1) + keys = mapping.split('.') + set_nested(dir[id], keys, value) + return dir + + def handle_arg(id,name,arg,value,resource,user): + + if value.startswith('${KEEPER_') and id not in self.dynamic_tokens: + debug('Dynamic token detected',self.debug) + self.dynamic_tokens.append(id) + if name not in self.logged_records: + self.logged_records[name] = {'name':name, 'dynamic_token':True} + else: + self.logged_records[name]['dynamic_token'] = True + elif value and arg.startswith('totp-'): + if 'oneTimeCode' not in user: + user['oneTimeCode'] = { + "totp-algorithm": '', + "totp-digits": "", + "totp-period": "", + "totp-secret": "" + } + user['oneTimeCode'][arg] = value + elif value and arg == 'hostname': + resource['host'] = value + elif value and arg == 'port': + resource['pam_settings']['connection']['port'] = value + elif value and arg in self.mappings['users']: + self.users = handle_mapping(self.mappings['users'][arg],name,arg,value,self.users) + elif arg in self.mappings['resources']: + self.connections = handle_mapping(self.mappings['resources'][arg],name,arg,value,self.connections) + else: + display(f'Error: Unknown parameter detected: {arg}. Add it to KCM_mappings.json to resolve this error','bold red') + for connection in self.connection_data: id = connection['connection_id'] name = connection["name"] @@ -445,50 +495,7 @@ def resolve_path(group_id): resource['pam_settings']['connection']['launch_credentials'] = f'KCM User - {name}' self.connections[id] = resource - def handle_arg(id,name,arg,value,resource,user): - def handle_mapping(mapping, value, dir): - if mapping == 'ignore': - debug(f'Mapping {arg} ignored', self.debug) - return dir - if mapping == 'log': - record = self.logged_records.setdefault(name, {'name': name}) - record[arg] = value - return dir - if mapping is None: - debug(f'Mapping {arg} recognized but not supported', self.debug) - return dir - if '=' in mapping: - mapping, value = mapping.split('=', 1) - keys = mapping.split('.') - set_nested(dir[id], keys, value) - return dir - - if value.startswith('${KEEPER_') and id not in self.dynamic_tokens: - debug('Dynamic token detected',self.debug) - self.dynamic_tokens.append(id) - if name not in self.logged_records: - self.logged_records[name] = {'name':name, 'dynamic_token':True} - else: - self.logged_records[name]['dynamic_token'] = True - elif value and arg.startswith('totp-'): - if 'oneTimeCode' not in user: - user['oneTimeCode'] = { - "totp-algorithm": '', - "totp-digits": "", - "totp-period": "", - "totp-secret": "" - } - user['oneTimeCode'][arg] = value - elif value and arg == 'hostname': - resource['host'] = value - elif value and arg == 'port': - resource['pam_settings']['connection']['port'] = value - elif value and arg in self.mappings['users']: - self.users = handle_mapping(self.mappings['users'][arg],value,self.users) - elif arg in self.mappings['resources']: - self.connections = handle_mapping(self.mappings['resources'][arg],value,self.connections) - else: - display(f'Error: Unknown parameter detected: {arg}. Add it to KCM_mappings.json to resolve this error','bold red') + # Handle args if connection['parameter_name']: @@ -497,8 +504,7 @@ def handle_mapping(mapping, value, dir): if connection['attribute_name']: handle_arg(id,connection['name'],connection['attribute_name'],connection['attribute_value'],self.connections[id],self.users[id]) - - self.user_records = list(user for user in self.users.values()) + self.user_records = list(user for user in self.users.values() if user.get('login',None)) self.resource_records = list(conn for conn in self.connections.values()) # Sanitize totp @@ -511,10 +517,25 @@ def handle_mapping(mapping, value, dir): stripped_secret = ''.join([x for x in secret if x.isnumeric()]) user['otp'] = f'otpauth://totp/{TOTP_ACCOUNT}?secret={stripped_secret}&issuer=&algorithm={alg}&digits={dig}&period={period}' - # Handle SFTP records + usernames = [x['title'] for x in self.user_records] for resource in self.resource_records: + # Replace launch_credentials with autofill_credentials on http + if resource['pam_settings']['connection']['protocol']=='http': + resource['pam_settings']['connection']['autofill_credentials'] = resource['pam_settings']['connection']['launch_credentials'] + del resource['pam_settings']['connection']['launch_credentials'] + else: + # Remove non-existent users and add to logs + if resource['pam_settings']['connection']['launch_credentials'] not in usernames: + if resource['title'] not in self.logged_records: + self.logged_records[resource['title']] = {'name':resource['title']} + self.logged_records[resource['title']]['empty_credentials'] = True + resource['pam_settings']['connection']['launch_credentials'] = None + # Handle empty ports + if not resource['pam_settings']['connection'].get('port',None): + resource['pam_settings']['connection']['port'] = self.mappings['ports'].get(resource['pam_settings']['connection']['protocol'],'') + # Handle SFTP records if 'sftp' in resource['pam_settings']['connection']: - sftp_settings = resource['pam_settings']['connection']['sftp'] + sftp_settings = deepcopy(resource['pam_settings']['connection']['sftp']) # Create resource for SFTP sftp_resource = { 'folder_path':resource['folder_path']+'/SFTP Resources', @@ -556,12 +577,13 @@ def handle_mapping(mapping, value, dir): if self.dynamic_tokens: display(f'- {len(self.dynamic_tokens)} dynamic tokens detected, they will be added to the JSON file.','yellow') - if len(self.logged_records)-len(self.dynamic_tokens)>0: - display(f'- {len(self.logged_records)-len(self.dynamic_tokens)} records logged, they will be added to the JSON file.','yellow') + logged_records_count = len([x for x in self.logged_records if self.logged_records[x].get('parameters',None)]) + if logged_records_count: + display(f'- {logged_records_count} records logged, they will be added to the JSON file.','yellow') logged_records = [] if self.logged_records: - logged_records = (list(record for record in self.logged_records.values())) + logged_records = list(record for record in self.logged_records.values()) shared_folders = [] for folder in self.shared_folders: From 242f85fdeb513696b88afd8cbadb8865cfa82f9e Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Fri, 12 Jun 2026 20:42:58 -0700 Subject: [PATCH 02/18] Thycotic secret ids arg (#2149) * Add secret-ids argument for thycotic import Add secret-ids arg to import command, to use for debugging Thycotic secret IDs * Pass secret-ids arg from import command to thycotic import Pass secret-ids arg from import command to thycotic import so it can be handled in the Thycotic import * Add handling for secret-ids arg in Thycotic import If user sets secret-ids, the import will: - Check if any of those IDs have come up in the lookup and would have been imported. - Import only the secret-ids set This is useful for debugging, because the lookup API may not return all Thycotic secrets - eg if there a security policy on them, but they may still be fetched. Usage: String (comma separated IDs) `import --format thycotic server_name --secret-ids "123, 124,125"` Python List (strings or integers) `secret_ids=[123, 124, 125]` * Correct secret_ids check Fix conditional logic so import continues if no secret ids are specified --------- Co-authored-by: lthievenaz-keeper --- keepercommander/importer/commands.py | 2 ++ keepercommander/importer/imp_exp.py | 3 ++- keepercommander/importer/thycotic/thycotic.py | 24 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/keepercommander/importer/commands.py b/keepercommander/importer/commands.py index 8563dd0c4..25a85f743 100644 --- a/keepercommander/importer/commands.py +++ b/keepercommander/importer/commands.py @@ -79,6 +79,8 @@ def register_command_info(aliases, command_info): help='temp directory used to cache encrypted attachment imports') import_parser.add_argument('--show-skipped', dest='show_skipped', action='store_true', help='Display skipped records') +import_parser.add_argument('--secret-ids', dest='secret_ids', action='store', + help='Comma separated list of secret IDs to fetch (Thycotic)') import_parser.add_argument( 'name', type=str, help='file name (json, csv, keepass, 1password), account name (lastpass), or URL (ManageEngine, Thycotic)' ) diff --git a/keepercommander/importer/imp_exp.py b/keepercommander/importer/imp_exp.py index 0ccec3cf0..99efb826b 100644 --- a/keepercommander/importer/imp_exp.py +++ b/keepercommander/importer/imp_exp.py @@ -715,6 +715,7 @@ def _import(params, file_format, filename, **kwargs): filter_folder = kwargs.get('filter_folder') dry_run = kwargs.get('dry_run') is True show_skipped = kwargs.get('show_skipped') is True + secret_ids = kwargs.get('secret_ids') import_into = kwargs.get('import_into') or '' if import_into: @@ -732,7 +733,7 @@ def _import(params, file_format, filename, **kwargs): filter_folder_lower = filter_folder.lower() if isinstance(filter_folder, str) else '' for x in importer.execute(filename, params=params, users_only=import_users, filter_folder=filter_folder, - old_domain=old_domain, new_domain=new_domain, tmpdir=tmpdir, dry_run=dry_run): + old_domain=old_domain, new_domain=new_domain, tmpdir=tmpdir, secret_ids=secret_ids, dry_run=dry_run): if isinstance(x, ImportRecord): if filter_folder and not importer.support_folder_filter(): if not x.folders: diff --git a/keepercommander/importer/thycotic/thycotic.py b/keepercommander/importer/thycotic/thycotic.py index 120480160..f7409609c 100644 --- a/keepercommander/importer/thycotic/thycotic.py +++ b/keepercommander/importer/thycotic/thycotic.py @@ -341,6 +341,30 @@ def do_import(self, filename, **kwargs): secrets_ids.extend([x['id'] for x in auth.thycotic_search(query)]) else: secrets_ids = [x['id'] for x in auth.thycotic_search(f'/v1/secrets/lookup')] + + # secret_ids arg + debug_ids = kwargs.get('secret_ids') + if debug_ids is not None: + # Convert CLI string input into list + if isinstance(debug_ids,str): + debug_ids = debug_ids.replace(' ','').split(',') + # Handle secret IDs list + if isinstance(debug_ids,list): + # Deduplicate + debug_ids = list(set(debug_ids)) + # Check whether secrets were found in the lookup + stringified_secrets_ids = [str(x) for x in secrets_ids] + stringified_debug_ids = [str(x) for x in debug_ids] + found_secrets = [x for x in stringified_debug_ids if x in stringified_secrets_ids] + logging.info(f'From the specified {len(debug_ids)} secret IDs, {len(found_secrets)} were found in the secret server lookup.') + logging.info(', '.join(found_secrets)) + + # Replace import list with specified IDs + secrets_ids = debug_ids + else: + # Quit if secret IDs != list + logging.warning('Invalid input for secret IDs, exiting.') + return self._send_keep_alive_if_needed(params) print(f'Loading {len(secrets_ids)} Records ', flush=True, end='') From c5307dbdabfc5e3f8555e45b833792aa12086771 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Mon, 15 Jun 2026 15:49:40 +0530 Subject: [PATCH 03/18] Enforce GENERATED_PASSWORD_COMPLEXITY and RESTRICT_RECORD_TYPES on nsf-record-add/update (#2132) --- .../nested_share_folder/record_commands.py | 87 +++++++++- keepercommander/commands/record_edit.py | 2 +- keepercommander/enforcement.py | 38 +++-- unit-tests/test_nested_share_folder.py | 155 ++++++++++++++++++ 4 files changed, 265 insertions(+), 17 deletions(-) diff --git a/keepercommander/commands/nested_share_folder/record_commands.py b/keepercommander/commands/nested_share_folder/record_commands.py index bbcdfe005..31663ac3b 100644 --- a/keepercommander/commands/nested_share_folder/record_commands.py +++ b/keepercommander/commands/nested_share_folder/record_commands.py @@ -16,11 +16,13 @@ (create, update, link/unlink, shortcut management, delete). """ +import json import logging -from typing import List +from typing import Any, Dict, List, Optional from ..base import Command, GroupCommand from ..record_edit import RecordEditMixin, record_fields_description, ParsedFieldValue +from ...enforcement import PasswordComplexityEnforcer, RecordTypeEnforcer from ...error import CommandError from ... import nested_share_folder as _nsf, vault from .helpers import ( @@ -48,6 +50,7 @@ class NestedShareRecordAddCommand(Command, RecordEditMixin): def __init__(self): super().__init__() + RecordEditMixin.__init__(self) def get_parser(self): return nested_share_record_add_parser @@ -64,12 +67,17 @@ def execute(self, params, **kwargs): if not record_type: raise CommandError('nsf-record-add', 'Record type parameter is required.') + RecordTypeEnforcer.enforce(params, record_type, 'nsf-record-add') + + self.warnings.clear() + self._password_policy = PasswordComplexityEnforcer.get_policy(params) + notes = kwargs.get('notes') record_fields, add_attachments = self._parse_fields(kwargs.get('fields', [])) folder_uid = self._resolve_folder(params, kwargs.get('folder_uid')) - self.warnings.clear() data = self._build_record_data(params, record_type, title, notes, record_fields) + self._check_password_policy(params, data, **kwargs) if self.warnings: for w in self.warnings: @@ -165,6 +173,13 @@ def _legacy_to_data(record, title, notes=None): data['notes'] = notes return data + def _check_password_policy(self, params, data, **kwargs): + pw_failures = PasswordComplexityEnforcer.validate_record(params, data) + for failure in pw_failures: + self.on_warning(failure) + if pw_failures and not kwargs.get('force'): + self.on_warning('Use --force to bypass password policy warnings.') + # ══════════════════════════════════════════════════════════════════════════ # nsf-record-update @@ -175,6 +190,7 @@ class NestedShareRecordUpdateCommand(Command, RecordEditMixin): def __init__(self): super().__init__() + RecordEditMixin.__init__(self) def get_parser(self): return nested_share_record_update_parser @@ -190,7 +206,7 @@ def _resolve_field_value(self, parsed): action_params.clear() if self.is_generate_value(raw, action_params): if parsed.type == 'password': - return self.generate_password(action_params) + return self.generate_password(action_params, policy=self._password_policy) if parsed.type in ('oneTimeCode', 'otp'): return self.generate_totp_url() return raw @@ -208,8 +224,12 @@ def execute(self, params, **kwargs): if not record_uids: raise CommandError('nsf-record-update', 'Record UID is required (use -r or --record)') + self.warnings.clear() + self._password_policy = PasswordComplexityEnforcer.get_policy(params) + record_type = kwargs.get('record_type') if record_type and record_type not in ('legacy', 'general'): + RecordTypeEnforcer.enforce(params, record_type, 'nsf-record-update') rt_fields = self.get_record_type_fields(params, record_type) if not rt_fields: raise CommandError('nsf-record-update', f'Record type "{record_type}" cannot be found.') @@ -245,6 +265,20 @@ def execute(self, params, **kwargs): ensure_nested_share_record(params, record_uid, 'nsf-record-update', identifier=identifier) check_record_edit_permission(params, record_uid, 'nsf-record-update') + merged = self._merge_update_data( + params, record_uid, + title=kwargs.get('title'), + record_type=record_type, + fields=fields or None, + notes=kwargs.get('notes'), + ) + self._check_password_policy(params, merged, **kwargs) + if self.warnings: + for w in self.warnings: + logging.warning(w) + if not kwargs.get('force'): + return + self.warnings.clear() result = _nsf.update_record_v3( params=params, record_uid=record_uid, title=kwargs.get('title'), record_type=record_type, @@ -253,6 +287,53 @@ def execute(self, params, **kwargs): check_result(result, 'nsf-record-update') params.sync_data = True + def _check_password_policy(self, params, data, **kwargs): + pw_failures = PasswordComplexityEnforcer.validate_record(params, data) + for failure in pw_failures: + self.on_warning(failure) + if pw_failures and not kwargs.get('force'): + self.on_warning('Use --force to bypass password policy warnings.') + + @staticmethod + def _load_record_data(params, record_uid): # type: (Any, str) -> Optional[Dict] + rec = params.record_cache.get(record_uid) or {} + raw = rec.get('data_unencrypted') + if raw is None: + nsf_data = getattr(params, 'nested_share_record_data', {}).get(record_uid) or {} + raw = nsf_data.get('data_json') + if raw is None: + return None + if isinstance(raw, bytes): + return json.loads(raw.decode('utf-8')) + if isinstance(raw, str): + return json.loads(raw) + if isinstance(raw, dict): + return raw.copy() + return None + + @classmethod + def _merge_update_data(cls, params, record_uid, title=None, record_type=None, + fields=None, notes=None): # type: (...) -> Dict + existing = cls._load_record_data(params, record_uid) + data = existing.copy() if existing else {'fields': []} + if title is not None: + data['title'] = title + if record_type is not None: + data['type'] = record_type + if fields is not None: + by_type = {} + for ef in data.get('fields', []): + by_type.setdefault(ef.get('type'), []).append(ef) + for ft, fv in fields.items(): + fv = fv if isinstance(fv, list) else [fv] + if ft in by_type and by_type[ft]: + by_type[ft][0]['value'] = fv + else: + data.setdefault('fields', []).append({'type': ft, 'value': fv}) + if notes is not None: + data['notes'] = notes + return data + # ══════════════════════════════════════════════════════════════════════════ # nsf-ln diff --git a/keepercommander/commands/record_edit.py b/keepercommander/commands/record_edit.py index 9781bf431..10b62b5f9 100644 --- a/keepercommander/commands/record_edit.py +++ b/keepercommander/commands/record_edit.py @@ -292,7 +292,7 @@ def assign_legacy_fields(self, record, fields): elif parsed_field.type == 'password': action_params.clear() if self.is_generate_value(parsed_field.value, action_params): - record.password = self.generate_password(action_params) + record.password = self.generate_password(action_params, policy=self._password_policy) elif self.is_base64_value(parsed_field.value, action_params): if action_params: record.password = action_params[0] diff --git a/keepercommander/enforcement.py b/keepercommander/enforcement.py index 6d7e802ca..4609568f9 100644 --- a/keepercommander/enforcement.py +++ b/keepercommander/enforcement.py @@ -18,7 +18,7 @@ from typing import Tuple, Optional, List, Dict, Any, Set from . import api, utils, crypto -from .proto import APIRequest_pb2 +from .proto import APIRequest_pb2, record_pb2 from .display import bcolors from .params import KeeperParams from .error import KeeperApiError, CommandError @@ -528,18 +528,30 @@ def get_restricted_record_types(cls, params): # type: (KeeperParams) -> Option cache = getattr(params, 'record_type_cache', None) or {} restricted = set() # type: Set[str] - for rt_id in list(policy.get('std') or []) + list(policy.get('ent') or []): - entry = cache.get(rt_id) - if not entry: - continue - try: - schema = json.loads(entry) if isinstance(entry, str) else entry - except (json.JSONDecodeError, TypeError): - continue - if isinstance(schema, dict): - name = schema.get('$id') - if name: - restricted.add(name) + scope_buckets = ( + ('std', record_pb2.RT_STANDARD), + ('ent', record_pb2.RT_ENTERPRISE), + ) + for bucket, scope in scope_buckets: + for rt_id in policy.get(bucket) or []: + try: + rt_id = int(rt_id) + except (TypeError, ValueError): + continue + # Role policy stores bare recordTypeId; sync_down keys cache by + # recordTypeId + scope * 1_000_000. + scoped_id = rt_id + scope * 1_000_000 + entry = cache.get(scoped_id) or cache.get(rt_id) + if not entry: + continue + try: + schema = json.loads(entry) if isinstance(entry, str) else entry + except (json.JSONDecodeError, TypeError): + continue + if isinstance(schema, dict): + name = schema.get('$id') + if name: + restricted.add(name) return restricted @classmethod diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index 94ca1b549..c5bd7b591 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -6,6 +6,7 @@ - Key utility/parsing functions """ +import json import os import time from unittest import TestCase, mock @@ -283,6 +284,160 @@ def test_add_record(self, mock_create): title='New Record', folder_uid=fuid, force=True, record_type='general', fields=[]) + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.create_record_v3') + def test_add_record_rejects_restricted_record_type(self, mock_create): + from keepercommander.commands.nested_share_folder import NestedShareRecordAddCommand + fuid, fobj = _make_folder() + params = _make_params( + nested_share_folders={fuid: fobj}, + record_type_cache={1: json.dumps({'$id': 'login'})}, + enforcements={ + 'jsons': [{'key': 'restrict_record_types', 'value': '{"std": [1], "ent": []}'}], + }, + ) + cmd = NestedShareRecordAddCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(params, title='Blocked', record_type='login', fields=[], force=True) + self.assertIn('restricted', str(ctx.exception).lower()) + mock_create.assert_not_called() + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.create_record_v3') + def test_add_record_rejects_weak_password_without_force(self, mock_create): + from keepercommander.commands.nested_share_folder import NestedShareRecordAddCommand + fuid, fobj = _make_folder() + params = _make_params( + nested_share_folders={fuid: fobj}, + enforcements={ + 'jsons': [{ + 'key': 'generated_password_complexity', + 'value': json.dumps([{ + 'length': 12, + 'lower-use': True, 'lower-min': 1, + 'upper-use': True, 'upper-min': 1, + 'digit-use': True, 'digit-min': 1, + }]), + }], + }, + ) + cmd = NestedShareRecordAddCommand() + cmd.execute(params, title='Weak', record_type='general', + fields=['password=abc'], force=False) + mock_create.assert_not_called() + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.create_record_v3') + def test_add_record_allows_weak_password_with_force(self, mock_create): + from keepercommander.commands.nested_share_folder import NestedShareRecordAddCommand + mock_create.return_value = { + 'record_uid': utils.generate_uid(), 'status': 'SUCCESS', + 'message': '', 'success': True, 'revision': 1, + } + fuid, fobj = _make_folder() + params = _make_params( + nested_share_folders={fuid: fobj}, + enforcements={ + 'jsons': [{ + 'key': 'generated_password_complexity', + 'value': json.dumps([{ + 'length': 12, + 'lower-use': True, 'lower-min': 1, + 'upper-use': True, 'upper-min': 1, + 'digit-use': True, 'digit-min': 1, + }]), + }], + }, + ) + cmd = NestedShareRecordAddCommand() + cmd.execute(params, title='Weak', record_type='general', + fields=['password=abc'], force=True) + mock_create.assert_called_once() + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.create_record_v3') + def test_add_record_gen_uses_password_policy(self, mock_create): + from keepercommander.commands.nested_share_folder import NestedShareRecordAddCommand + mock_create.return_value = { + 'record_uid': utils.generate_uid(), 'status': 'SUCCESS', + 'message': '', 'success': True, 'revision': 1, + } + fuid, fobj = _make_folder() + params = _make_params( + nested_share_folders={fuid: fobj}, + enforcements={ + 'jsons': [{ + 'key': 'generated_password_complexity', + 'value': json.dumps([{ + 'length': 16, + 'lower-use': True, 'lower-min': 2, + 'upper-use': True, 'upper-min': 2, + 'digit-use': True, 'digit-min': 2, + 'special-use': True, 'special-min': 1, + 'special': '!@#$', + }]), + }], + }, + ) + cmd = NestedShareRecordAddCommand() + cmd.execute(params, title='Generated', record_type='general', + fields=['password=$GEN'], force=False) + mock_create.assert_called_once() + record_data = mock_create.call_args.kwargs['record_data'] + password = next( + v[0] for f in record_data['fields'] + if f.get('type') == 'password' for v in [f.get('value', [])] if v + ) + self.assertGreaterEqual(len(password), 16) + self.assertGreaterEqual(sum(1 for c in password if c.islower()), 2) + self.assertGreaterEqual(sum(1 for c in password if c.isupper()), 2) + self.assertGreaterEqual(sum(1 for c in password if c.isdigit()), 2) + self.assertGreaterEqual(sum(1 for c in password if c in '!@#$'), 1) + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_record_rejects_restricted_record_type(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, robj = _make_record() + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: {'revision': 1, 'data_unencrypted': json.dumps({ + 'type': 'login', 'title': 'Old', 'fields': [], + })}}, + record_type_cache={1: json.dumps({'$id': 'login'})}, + enforcements={ + 'jsons': [{'key': 'restrict_record_types', 'value': '{"std": [1], "ent": []}'}], + }, + ) + cmd = NestedShareRecordUpdateCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(params, record_uids=[ruid], record_type='login', fields=[]) + self.assertIn('restricted', str(ctx.exception).lower()) + mock_update.assert_not_called() + + @patch('keepercommander.commands.nested_share_folder.record_commands._nsf.update_record_v3') + @patch('keepercommander.commands.nested_share_folder.helpers.check_record_edit_permission') + def test_update_record_rejects_weak_password_without_force(self, mock_perm, mock_update): + from keepercommander.commands.nested_share_folder import NestedShareRecordUpdateCommand + ruid, robj = _make_record() + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: {'revision': 1, 'data_unencrypted': json.dumps({ + 'type': 'login', 'title': 'Old', + 'fields': [{'type': 'password', 'value': ['ExistingPass123']}], + })}}, + enforcements={ + 'jsons': [{ + 'key': 'generated_password_complexity', + 'value': json.dumps([{ + 'length': 12, + 'lower-use': True, 'lower-min': 1, + 'upper-use': True, 'upper-min': 1, + 'digit-use': True, 'digit-min': 1, + }]), + }], + }, + ) + cmd = NestedShareRecordUpdateCommand() + cmd.execute(params, record_uids=[ruid], fields=['password=abc'], force=False) + mock_update.assert_not_called() + @patch('keepercommander.nested_share_folder.folder_record_api.add_record_to_folder_v3') def test_add_record_to_folder(self, mock_add): pass From 49399a1129c314fd77c1f5b036ddeb1a4dbde267 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Mon, 15 Jun 2026 21:29:48 -0700 Subject: [PATCH 04/18] KEPM: system collection --- keepercommander/pedm/pedm_shared.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/keepercommander/pedm/pedm_shared.py b/keepercommander/pedm/pedm_shared.py index eed1dad3e..3fecc2045 100644 --- a/keepercommander/pedm/pedm_shared.py +++ b/keepercommander/pedm/pedm_shared.py @@ -48,6 +48,7 @@ class CollectionType(int, enum.Enum): UserName = 10, CustomAppCollection = 102, CustomUserCollection = 103, + SystemAppCollection = 105, CustomMachineCollection = 201, OsVersion = 202, @@ -69,6 +70,8 @@ def collection_type_to_name(collection_type: int) -> str: return 'App Collection' if collection_type == CollectionType.CustomUserCollection: return 'User Collection' + if collection_type == CollectionType.SystemAppCollection: + return 'System App Collection' if collection_type == CollectionType.CustomMachineCollection: return 'Machine Collection' if collection_type == CollectionType.OsVersion: @@ -141,8 +144,12 @@ def get_collection_required_fields(collection_type: Optional[int]) -> Optional[C return CollectionRequiredFields(['Name']) if collection_type == CollectionType.UserName: return CollectionRequiredFields(['Name']) + if collection_type == CollectionType.CustomAppCollection: + return CollectionRequiredFields(['Name']) if collection_type == CollectionType.CustomUserCollection: return CollectionRequiredFields(['Name']) + if collection_type == CollectionType.SystemAppCollection: + return CollectionRequiredFields(['Name']) if collection_type == CollectionType.OsVersion: return CollectionRequiredFields(['Name']) return None From 2a8117771c95cd90d28129ce0c4aa0e1bab0c7c3 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Tue, 16 Jun 2026 14:54:55 +0530 Subject: [PATCH 05/18] KC-1307, KC-1308, KC-1309 & KC-1310: Fix nsf share expiration updates, rename Supershell Drive folder labels, and standardize list record_category Fixed nsf-share-folder and nsf-share-record expiration updates, enforced a one-minute minimum on NSF and classic share commands, standardized list/search record_category to lowercase classic/nested, and renamed Supershell Drive folder labels to Nested Shared Folder (Shared) and (NonShared). --- .../commands/nested_share_folder/helpers.py | 28 ++- .../nested_share_folder/sharing_commands.py | 47 ++-- keepercommander/commands/record.py | 8 +- keepercommander/commands/register.py | 19 +- keepercommander/commands/supershell/app.py | 12 +- .../commands/supershell/screens/help.py | 4 +- .../nested_share_folder/__init__.py | 1 + .../nested_share_folder/folder_api.py | 15 +- .../nested_share_folder/record_api.py | 199 ++++++++++++--- unit-tests/test_command_register.py | 7 + unit-tests/test_nested_share_folder.py | 229 ++++++++++++++++++ 11 files changed, 500 insertions(+), 69 deletions(-) diff --git a/keepercommander/commands/nested_share_folder/helpers.py b/keepercommander/commands/nested_share_folder/helpers.py index c6624b137..5c990d04a 100644 --- a/keepercommander/commands/nested_share_folder/helpers.py +++ b/keepercommander/commands/nested_share_folder/helpers.py @@ -74,6 +74,9 @@ re.IGNORECASE, ) +MIN_SHARE_EXPIRATION_MS = 60_000 +"""Minimum share expiration is one minute (milliseconds).""" + # ═══════════════════════════════════════════════════════════════════════════ # Error-handling patterns (eliminates repetitive try/except boilerplate) @@ -306,6 +309,18 @@ def walk(fuid): # Expiration parsing # ═══════════════════════════════════════════════════════════════════════════ +def validate_share_expiration_timestamp(expiration_ms, cmd_name): + """Reject finite expirations that are less than one minute.""" + if expiration_ms is None or expiration_ms == -1: + return + min_allowed = int(datetime.datetime.now(timezone.utc).timestamp() * 1000) + MIN_SHARE_EXPIRATION_MS + if expiration_ms < min_allowed: + raise CommandError( + cmd_name, + 'Share expiration must be at least 1 minute.', + ) + + def parse_expiration(expire_at, expire_in, cmd_name): """Parse ``--expire-at`` / ``--expire-in`` into a millisecond timestamp. @@ -320,13 +335,15 @@ def parse_expiration(expire_at, expire_in, cmd_name): if expire_at: try: dt = datetime.datetime.fromisoformat(raw.replace('Z', '+00:00')) - return int(dt.timestamp() * 1000) + expiration_ms = int(dt.timestamp() * 1000) except ValueError: raise CommandError( cmd_name, f'Invalid --expire-at format: {raw!r}. ' f'Use ISO datetime, e.g. 2027-01-01T00:00:00Z or "never"', ) + validate_share_expiration_timestamp(expiration_ms, cmd_name) + return expiration_ms m = _EXPIRATION_RE.fullmatch(raw) if not m: @@ -336,6 +353,11 @@ def parse_expiration(expire_at, expire_in, cmd_name): ) amount = int(m.group(1)) unit = m.group(2).lower() + if unit.startswith('mi') and amount < 1: + raise CommandError( + cmd_name, + 'Share expiration must be at least 1 minute.', + ) now = datetime.datetime.now(timezone.utc) delta_map = { 'mi': timedelta(minutes=amount), @@ -345,7 +367,9 @@ def parse_expiration(expire_at, expire_in, cmd_name): 'y': timedelta(days=amount * 365), } delta = next(v for k, v in delta_map.items() if unit.startswith(k)) - return int((now + delta).timestamp() * 1000) + expiration_ms = int((now + delta).timestamp() * 1000) + validate_share_expiration_timestamp(expiration_ms, cmd_name) + return expiration_ms # ═══════════════════════════════════════════════════════════════════════════ diff --git a/keepercommander/commands/nested_share_folder/sharing_commands.py b/keepercommander/commands/nested_share_folder/sharing_commands.py index a8c5a207b..d4029fac5 100644 --- a/keepercommander/commands/nested_share_folder/sharing_commands.py +++ b/keepercommander/commands/nested_share_folder/sharing_commands.py @@ -98,8 +98,23 @@ def _dispatch(params, action, record_uid, email, access_role_type, expiration): params=params, record_uid=record_uid, new_owner_email=email), 'owner') if action == 'grant': - if NestedShareRecordShareCommand._is_already_shared( - params, record_uid, email): + existing = NestedShareRecordShareCommand._get_direct_user_share( + params, record_uid, email) + if existing: + if _nsf.is_record_share_update_noop( + existing, access_role_type, expiration): + logging.info( + "Record '%s' already shared with '%s' at the requested " + "role and expiration; no change needed.", + record_uid, email) + return ({ + 'results': [{ + 'record_uid': record_uid, + 'success': True, + 'message': 'Already has requested access', + }], + 'success': True, + }, 'update') logging.debug( "Record '%s' is already shared with user '%s'; switching to update.", record_uid, email) @@ -115,6 +130,17 @@ def _dispatch(params, action, record_uid, email, access_role_type, expiration): return (_nsf.unshare_record_v3( params=params, record_uid=record_uid, recipient_email=email), 'revoke') + @staticmethod + def _get_direct_user_share(params, record_uid, email): + """Return direct AT_USER share metadata for *email*, or None.""" + try: + access_result = _nsf.get_record_accesses_v3(params, [record_uid]) + return _nsf.find_direct_user_share_access( + access_result, record_uid, email) + except Exception as exc: + logging.debug("Could not fetch record accesses for '%s': %s", record_uid, exc) + return None + @staticmethod def _is_already_shared(params, record_uid, email): """Return True if *email* already has a *direct* non-owner share on *record_uid*. @@ -126,21 +152,8 @@ def _is_already_shared(params, record_uid, email): causes the caller to dispatch ``share_record_v3`` (a fresh direct grant) which correctly overrides the inherited folder permission. """ - try: - access_result = _nsf.get_record_accesses_v3(params, [record_uid]) - for a in access_result.get('record_accesses', []): - if a.get('record_uid') != record_uid or a.get('owner', False): - continue - if a.get('access_type') and a.get('access_type') != 'AT_USER': - continue - if a.get('inherited'): - continue - if a.get('accessor_name', '').casefold() == email.casefold(): - return True - return False - except Exception as exc: - logging.debug("Could not fetch record accesses for '%s': %s", record_uid, exc) - return False + return NestedShareRecordShareCommand._get_direct_user_share( + params, record_uid, email) is not None @staticmethod def _log_results(result, action, email): diff --git a/keepercommander/commands/record.py b/keepercommander/commands/record.py index 7977d0ab5..87639cf0f 100644 --- a/keepercommander/commands/record.py +++ b/keepercommander/commands/record.py @@ -1558,7 +1558,7 @@ def execute(self, params, **kwargs): 'record_type': record.record_type, 'title': record.title, 'description': vault_extensions.get_record_description(record), - 'record_category': 'Nested' if is_nsf else 'Classic', + 'record_category': 'nested' if is_nsf else 'classic', } all_results.append(result_item) else: @@ -1566,7 +1566,7 @@ def execute(self, params, **kwargs): table = [] headers = ['Record UID', 'Type', 'Title', 'Description', 'Record Category'] for record in records: - record_category = 'Nested' if record.record_uid in nsf_records_map else 'Classic' + record_category = 'nested' if record.record_uid in nsf_records_map else 'classic' row = [record.record_uid, record.record_type, record.title, vault_extensions.get_record_description(record), record_category] table.append(row) @@ -1655,7 +1655,7 @@ def execute(self, params, **kwargs): for item in all_results: if item['type'] == 'record': row = [item['type'], item['record_uid'], item['title'], - f"Type: {item['record_type']}, Description: {item['description']}, Record Category: {item.get('record_category', 'Classic')}"] + f"Type: {item['record_type']}, Description: {item['description']}, Record Category: {item.get('record_category', 'classic')}"] elif item['type'] == 'shared_folder': row = [item['type'], item['shared_folder_uid'], item['name'], f"Folder Category: Classic, Can Edit: {item['can_edit']}, Can Share: {item['can_share']}"] @@ -1832,7 +1832,7 @@ def execute(self, params, **kwargs): for record in records: # Determine if record is from Nested Share Folder or Classic is_nested_share = hasattr(params, 'nested_share_records') and record.record_uid in params.nested_share_records - record_category = 'Nested' if is_nested_share else 'Classic' + record_category = 'nested' if is_nested_share else 'classic' row = [record.record_uid, record.record_type, record.title, vault_extensions.get_record_description(record), record.shared, record_category] table.append(row) diff --git a/keepercommander/commands/register.py b/keepercommander/commands/register.py index 858f3e6de..5aac059db 100644 --- a/keepercommander/commands/register.py +++ b/keepercommander/commands/register.py @@ -227,7 +227,7 @@ def register_command_info(aliases, command_info): create_account_parser = argparse.ArgumentParser(prog='create-account', description='Create Keeper Account') create_account_parser.add_argument('email', help='email') -def get_share_expiration(expire_at, expire_in): # (Optional[str], Optional[str]) -> Optional[int] +def get_share_expiration(expire_at, expire_in, cmd_name='share-record'): # (Optional[str], Optional[str], str) -> Optional[int] if not expire_at and not expire_in: return @@ -240,11 +240,20 @@ def get_share_expiration(expire_at, expire_in): # (Optional[str], Optional[s if expire_in == 'never': return -1 td = parse_timeout(expire_in) + if td < datetime.timedelta(minutes=1): + raise CommandError( + cmd_name, + 'Share expiration must be at least 1 minute.', + + ) dt = datetime.datetime.now() + td if dt is None: raise ValueError(f'Incorrect expiration: {expire_at or expire_in}') - return int(dt.timestamp()) + expiration_seconds = int(dt.timestamp()) + from .nested_share_folder.helpers import validate_share_expiration_timestamp + validate_share_expiration_timestamp(expiration_seconds * 1000, cmd_name) + return expiration_seconds class ShareFolderCommand(Command): @@ -310,7 +319,8 @@ def get_share_admin_obj_uids(obj_names, obj_type): share_expiration = None if action == 'grant': - share_expiration = get_share_expiration(kwargs.get('expire_at'), kwargs.get('expire_in')) + share_expiration = get_share_expiration( + kwargs.get('expire_at'), kwargs.get('expire_in'), cmd_name='share-folder') rotate_on_expiration = bool(kwargs.get('rotate_on_expiration')) if rotate_on_expiration: @@ -690,7 +700,8 @@ def prep_request(params, kwargs): # type: (KeeperParams, Dict[str, Any]) -> Un share_expiration = None if action == 'grant': - share_expiration = get_share_expiration(kwargs.get('expire_at'), kwargs.get('expire_in')) + share_expiration = get_share_expiration( + kwargs.get('expire_at'), kwargs.get('expire_in'), cmd_name='share-record') rotate_on_expiration = bool(kwargs.get('rotate_on_expiration')) if rotate_on_expiration: diff --git a/keepercommander/commands/supershell/app.py b/keepercommander/commands/supershell/app.py index 8275f40ab..10e48f3f3 100644 --- a/keepercommander/commands/supershell/app.py +++ b/keepercommander/commands/supershell/app.py @@ -254,8 +254,8 @@ def _get_welcome_screen_content(self) -> str: [bold {t['primary_bright']}]Folder Icons[/bold {t['primary_bright']}] [{t['text_dim']}]•[/{t['text_dim']}] Legacy Personal Folder 🔒 [{t['text_dim']}]•[/{t['text_dim']}] Legacy Shared Folder 📦 - [{t['text_dim']}]•[/{t['text_dim']}] Drive Shared Folder 👥 - [{t['text_dim']}]•[/{t['text_dim']}] Drive NonShared Folder 📁 + [{t['text_dim']}]•[/{t['text_dim']}] Nested Shared Folder (Shared) 👥 + [{t['text_dim']}]•[/{t['text_dim']}] Nested Shared Folder (NonShared) 📁 [{t['text_dim']}]Press [/{t['text_dim']}][{t['primary']}]?[/{t['primary']}][{t['text_dim']}] for full keyboard shortcuts[/{t['text_dim']}]""" @@ -393,8 +393,8 @@ async def on_mount(self): [bold {t['primary_bright']}]Folder Icons[/bold {t['primary_bright']}] [{t['text_dim']}]•[/{t['text_dim']}] Legacy Personal Folder 🔒 [{t['text_dim']}]•[/{t['text_dim']}] Legacy Shared Folder 📦 - [{t['text_dim']}]•[/{t['text_dim']}] Drive Shared Folder 👥 - [{t['text_dim']}]•[/{t['text_dim']}] Drive NonShared Folder 📁 + [{t['text_dim']}]•[/{t['text_dim']}] Nested Shared Folder (Shared) 👥 + [{t['text_dim']}]•[/{t['text_dim']}] Nested Shared Folder (NonShared) 📁 [{t['text_dim']}]Press [/{t['text_dim']}][{t['primary']}]?[/{t['primary']}][{t['text_dim']}] for full keyboard shortcuts[/{t['text_dim']}]""" detail_widget.update(help_content) @@ -984,8 +984,8 @@ def _get_folder_icon(self, folder_node): - Legacy Personal Folder (user_folder) → 🔒 - Legacy Shared Folder (shared_folder) → 📦 - Subfolder in Shared (shared_folder_folder) → 📦 - - Drive Shared Folder (nested_share_folder, shared) → 👥 - - Drive NonShared Folder (nested_share_folder, not shared) → 📁 + - Nested Shared Folder (Shared) (nested_share_folder, shared) → 👥 + - Nested Shared Folder (NonShared) (nested_share_folder, not shared) → 📁 """ from ...subfolder import BaseFolderNode if folder_node is None: diff --git a/keepercommander/commands/supershell/screens/help.py b/keepercommander/commands/supershell/screens/help.py index dcf13f088..a92a39c5f 100644 --- a/keepercommander/commands/supershell/screens/help.py +++ b/keepercommander/commands/supershell/screens/help.py @@ -119,8 +119,8 @@ def compose(self) -> ComposeResult: [green]Folder Icons:[/green] 🔒 Legacy Personal Folder 📦 Legacy Shared Folder - 👥 Drive Shared Folder - 📁 Drive NonShared Folder""", classes="help_column") + 👥 Nested Shared Folder (Shared) + 📁 Nested Shared Folder (NonShared)""", classes="help_column") yield Static("[dim]Press Esc or q to close[/dim]", id="help_footer") def action_dismiss(self): diff --git a/keepercommander/nested_share_folder/__init__.py b/keepercommander/nested_share_folder/__init__.py index a4a6641b6..8436b6ba1 100644 --- a/keepercommander/nested_share_folder/__init__.py +++ b/keepercommander/nested_share_folder/__init__.py @@ -40,6 +40,7 @@ 'create_record_data_v3', 'record_add_v3', 'record_update_v3', 'create_record_v3', 'update_record_v3', 'create_records_batch_v3', 'get_record_details_v3', 'get_record_accesses_v3', + 'find_direct_user_share_access', 'is_record_share_update_noop', 'share_record_v3', 'update_record_share_v3', 'unshare_record_v3', 'batch_update_record_shares_v3', 'batch_create_record_shares_v3', 'batch_unshare_records_v3', diff --git a/keepercommander/nested_share_folder/folder_api.py b/keepercommander/nested_share_folder/folder_api.py index 9c40fe00e..6656f324e 100644 --- a/keepercommander/nested_share_folder/folder_api.py +++ b/keepercommander/nested_share_folder/folder_api.py @@ -364,14 +364,15 @@ def grant_folder_access_v3(params, folder_uid, user_uid, role='viewer', existing = _check_existing_access(params, folder_uid, actual_uid_bytes, target_role_name, access_type_label) if existing is not None: - if existing == target_role_name: + if existing == target_role_name and expiration_timestamp is None: return {'folder_uid': folder_uid, 'user_uid': identifier_label, 'access_type': access_type_label, 'status': 'SUCCESS', 'message': f"{'Team' if as_team else 'User'} already has {role} access", 'success': True, 'action_taken': 'already_had_access'} result = update_folder_access_v3(params, folder_uid, identifier_label, - role=role, as_team=as_team) + role=role, as_team=as_team, + expiration_timestamp=expiration_timestamp) result['action_taken'] = 'updated' return result @@ -382,7 +383,7 @@ def grant_folder_access_v3(params, folder_uid, user_uid, role='viewer', ad.accessRoleType = access_role ad.permissions.CopyFrom(get_folder_permissions_for_role(access_role)) - if expiration_timestamp: + if expiration_timestamp is not None: ad.tlaProperties.expiration = expiration_timestamp if share_folder_key: @@ -430,9 +431,9 @@ def _check_existing_access(params, folder_uid, uid_bytes, target_role_name, def update_folder_access_v3(params, folder_uid, user_uid, role=None, hidden=None, - as_team=False): - if role is None and hidden is None: - raise ValueError("At least one field (role or hidden) required") + expiration_timestamp=None, as_team=False): + if role is None and hidden is None and expiration_timestamp is None: + raise ValueError("At least one field (role, hidden, or expiration) required") resolved = resolve_folder_identifier(params, folder_uid) if not resolved: raise ValueError(f"Folder '{folder_uid}' not found") @@ -453,6 +454,8 @@ def update_folder_access_v3(params, folder_uid, user_uid, role=None, hidden=None ad.permissions.CopyFrom(get_folder_permissions_for_role(resolved_role)) if hidden is not None: ad.hidden = hidden + if expiration_timestamp is not None: + ad.tlaProperties.expiration = expiration_timestamp response = folder_access_update_v3(params, folder_access_updates=[ad]) result = parse_folder_access_result(response, folder_uid, identifier_label, diff --git a/keepercommander/nested_share_folder/record_api.py b/keepercommander/nested_share_folder/record_api.py index db886a78b..f0bcc9e69 100644 --- a/keepercommander/nested_share_folder/record_api.py +++ b/keepercommander/nested_share_folder/record_api.py @@ -9,7 +9,7 @@ from .. import utils, crypto, api from ..params import KeeperParams -from ..proto import record_pb2, folder_pb2, record_endpoints_pb2, record_details_pb2, record_sharing_pb2 +from ..proto import record_pb2, folder_pb2, record_endpoints_pb2, record_details_pb2, record_sharing_pb2, tla_pb2 from ..error import KeeperApiError from ..api import pad_aes_gcm @@ -336,21 +336,84 @@ def get_record_accesses_v3(params, record_uids): 'can_update_access', 'can_delete', 'can_change_ownership', 'can_request_access', 'can_approve_access'): ao[flag] = getattr(d, flag, False) + # Proto3 scalar fields have no presence; only test the submessage. + if d.HasField('tlaProperties'): + ao['tla_expiration'] = d.tlaProperties.expiration result['record_accesses'].append(ao) for fu in rs.forbiddenRecords: result['forbidden_records'].append(utils.base64_url_encode(fu)) return result +def find_direct_user_share_access(access_result, record_uid, email): + """Return the direct AT_USER share row for *email* on *record_uid*, or None.""" + email_cf = email.casefold() + for access in access_result.get('record_accesses', []): + if access.get('record_uid') != record_uid or access.get('owner', False): + continue + if access.get('access_type') and access.get('access_type') != 'AT_USER': + continue + if access.get('inherited'): + continue + if access.get('accessor_name', '').casefold() == email_cf: + return access + return None + + +_SHARE_EXPIRATION_NOOP_TOLERANCE_MS = 60_000 + + +def is_record_share_update_noop(existing, access_role_type, expiration_timestamp): + """True when an update would leave role and expiration unchanged.""" + if existing.get('access_role_type') != access_role_type: + return False + existing_exp = existing.get('tla_expiration') + if expiration_timestamp is None: + return True + if expiration_timestamp == -1: + return not existing_exp or existing_exp <= 0 + if expiration_timestamp > 0: + if not existing_exp or existing_exp <= 0: + return False + return abs(existing_exp - expiration_timestamp) <= _SHARE_EXPIRATION_NOOP_TOLERANCE_MS + return False + + # ══════════════════════════════════════════════════════════════════════════ # Record sharing (Strategy: share / update / revoke) # ══════════════════════════════════════════════════════════════════════════ +def _apply_share_expiration_tla(tla_props, expiration_timestamp): + """Set expiration / notification fields on a TLAProperties proto.""" + if expiration_timestamp is None: + return + tla_props.expiration = expiration_timestamp + if expiration_timestamp > 0: + tla_props.timerNotificationType = tla_pb2.NOTIFY_OWNER + + +def _build_revoke_permission(params, record_uid, recipient_email): + """Build a minimal Permissions proto for revokeSharingPermissions.""" + _, _, uid_bytes, _ = get_user_public_key(params, recipient_email) + if not uid_bytes: + raise ValueError(f"User {recipient_email} not found") + + uid_b = utils.base64_url_decode(record_uid) + perm = record_sharing_pb2.Permissions() + perm.recipientUid = uid_bytes + perm.recordUid = uid_b + perm.rules.accessTypeUid = uid_bytes + perm.rules.accessType = folder_pb2.AT_USER + perm.rules.recordUid = uid_b + return perm + + def _build_share_permissions(params, record_uid, recipient_email, access_role_type, - expiration_timestamp, include_role): + expiration_timestamp, include_role, skip_sync=False): """Build a Permissions protobuf for share/update — single source of truth.""" - from .. import sync_down as sd - sd.sync_down(params) + if not skip_sync: + from .. import sync_down as sd + sd.sync_down(params) rec = get_record_from_cache(params, record_uid) if not rec: @@ -381,16 +444,25 @@ def _build_share_permissions(params, record_uid, recipient_email, access_role_ty perm.rules.owner = False if include_role and access_role_type is not None: perm.rules.accessRoleType = access_role_type - if expiration_timestamp: - perm.rules.tlaProperties.expiration = expiration_timestamp + _apply_share_expiration_tla(perm.rules.tlaProperties, expiration_timestamp) return perm -def share_record_v3(params, record_uid, recipient_email, access_role_type, - expiration_timestamp=None): +def _send_revoke_share_request(params, record_uid, recipient_email): + perm = _build_revoke_permission(params, record_uid, recipient_email) + rq = record_sharing_pb2.Request() + rq.revokeSharingPermissions.append(perm) + rs = api.communicate_rest(params, rq, 'vault/records/v3/share', + rs_type=record_sharing_pb2.Response) + results = [parse_sharing_status(s) for s in rs.revokedSharingStatus] + return {'results': results, 'success': all(r['success'] for r in results)} + + +def _send_create_share_request(params, record_uid, recipient_email, access_role_type, + expiration_timestamp, skip_sync=False): perm = _build_share_permissions(params, record_uid, recipient_email, access_role_type, expiration_timestamp, - include_role=True) + include_role=True, skip_sync=skip_sync) rq = record_sharing_pb2.Request() rq.createSharingPermissions.append(perm) rs = api.communicate_rest(params, rq, 'vault/records/v3/share', @@ -399,46 +471,78 @@ def share_record_v3(params, record_uid, recipient_email, access_role_type, return {'results': results, 'success': all(r['success'] for r in results)} -def update_record_share_v3(params, record_uid, recipient_email, - access_role_type=None, expiration_timestamp=None): +def share_record_v3(params, record_uid, recipient_email, access_role_type, + expiration_timestamp=None): perm = _build_share_permissions(params, record_uid, recipient_email, access_role_type, expiration_timestamp, include_role=True) rq = record_sharing_pb2.Request() - rq.updateSharingPermissions.append(perm) + rq.createSharingPermissions.append(perm) rs = api.communicate_rest(params, rq, 'vault/records/v3/share', rs_type=record_sharing_pb2.Response) - results = [parse_sharing_status(s) for s in rs.updatedSharingStatus] + results = [parse_sharing_status(s) for s in rs.createdSharingStatus] return {'results': results, 'success': all(r['success'] for r in results)} -def unshare_record_v3(params, record_uid, recipient_email): +def update_record_share_v3(params, record_uid, recipient_email, + access_role_type=None, expiration_timestamp=None): + """Update an existing direct share. + + Role changes use ``updateSharingPermissions``. Positive expirations use + revoke then create (two requests) because the server only INSERTs + ``record_access_expiration`` on create — sending ``tlaProperties`` on + update fails when the share already exists. + """ from .. import sync_down as sd sd.sync_down(params) rec = get_record_from_cache(params, record_uid) if not rec: raise ValueError(f"Record {record_uid} not found in cache") - _, _, uid_bytes, _ = get_user_public_key(params, recipient_email) - if not uid_bytes: - raise ValueError(f"User {recipient_email} not found") - uid_b = utils.base64_url_decode(record_uid) - perm = record_sharing_pb2.Permissions() - perm.recipientUid = uid_bytes - perm.recordUid = uid_b - perm.rules.accessTypeUid = uid_bytes - perm.rules.accessType = folder_pb2.AT_USER - perm.rules.recordUid = uid_b + if expiration_timestamp is not None and expiration_timestamp > 0: + if access_role_type is None: + raise ValueError( + 'access_role_type is required when setting share expiration') + # Expiration must be applied via create, not update. The server + # rejects revoke+create for the same (recipient, record) in one + # request, so run them sequentially (one sync_down above only). + logger.info( + "Revoking existing share with '%s' before re-granting with expiration...", + recipient_email) + revoke_result = _send_revoke_share_request(params, record_uid, recipient_email) + if not revoke_result.get('success'): + return revoke_result + logger.info( + "Re-granting share to '%s' with updated role and expiration...", + recipient_email) + return _send_create_share_request( + params, record_uid, recipient_email, access_role_type, + expiration_timestamp, skip_sync=True) + perm = _build_share_permissions(params, record_uid, recipient_email, + access_role_type, expiration_timestamp, + include_role=True, skip_sync=True) rq = record_sharing_pb2.Request() - rq.revokeSharingPermissions.append(perm) + rq.updateSharingPermissions.append(perm) rs = api.communicate_rest(params, rq, 'vault/records/v3/share', rs_type=record_sharing_pb2.Response) - results = [parse_sharing_status(s) for s in rs.revokedSharingStatus] + results = [parse_sharing_status(s) for s in rs.updatedSharingStatus] return {'results': results, 'success': all(r['success'] for r in results)} +def unshare_record_v3(params, record_uid, recipient_email, skip_sync=False): + if not skip_sync: + from .. import sync_down as sd + sd.sync_down(params) + + rec = get_record_from_cache(params, record_uid) + if not rec: + raise ValueError(f"Record {record_uid} not found in cache") + + return _send_revoke_share_request(params, record_uid, recipient_email) + + # ══════════════════════════════════════════════════════════════════════════ # Batch record sharing (bulk update / revoke in a single REST call per chunk) # ══════════════════════════════════════════════════════════════════════════ @@ -461,6 +565,45 @@ def batch_update_record_shares_v3(params, updates, expiration_timestamp=None, ch sd.sync_down(params) outcomes = [] + recreate = expiration_timestamp is not None and expiration_timestamp > 0 + if recreate: + for i in range(0, len(updates), chunk_size): + chunk = updates[i:i + chunk_size] + rq = record_sharing_pb2.Request() + built = [] + for u in chunk: + try: + rq.revokeSharingPermissions.append( + _build_revoke_permission(params, u['record_uid'], u['email'])) + built.append(u) + except Exception as exc: + outcomes.append((u, {'success': False, 'skipped': True, + 'message': str(exc)})) + if not built: + continue + try: + rs = api.communicate_rest(params, rq, 'vault/records/v3/share', + rs_type=record_sharing_pb2.Response) + statuses = [parse_sharing_status(x) for x in rs.revokedSharingStatus] + if statuses: + revoked = {s['record_uid'] for s in statuses if s['success']} + else: + revoked = {u['record_uid'] for u in built} + for u in built: + if u['record_uid'] not in revoked: + outcomes.append((u, {'success': False, + 'message': 'Revoke failed'})) + except Exception as exc: + for u in built: + outcomes.append((u, {'success': False, 'message': str(exc)})) + failed_uids = {u['record_uid'] for u, r in outcomes if not r.get('success')} + create_updates = [u for u in updates if u['record_uid'] not in failed_uids] + if create_updates: + create_outcomes = batch_create_record_shares_v3( + params, create_updates, expiration_timestamp, chunk_size) + outcomes.extend(create_outcomes) + return outcomes + for i in range(0, len(updates), chunk_size): chunk = updates[i:i + chunk_size] rq = record_sharing_pb2.Request() @@ -470,7 +613,7 @@ def batch_update_record_shares_v3(params, updates, expiration_timestamp=None, ch perm = _build_share_permissions( params, u['record_uid'], u['email'], u['access_role_type'], expiration_timestamp, - include_role=True, + include_role=True, skip_sync=True, ) rq.updateSharingPermissions.append(perm) built.append(u) @@ -526,7 +669,7 @@ def batch_create_record_shares_v3(params, creates, expiration_timestamp=None, ch perm = _build_share_permissions( params, c['record_uid'], c['email'], c['access_role_type'], expiration_timestamp, - include_role=True, + include_role=True, skip_sync=True, ) rq.createSharingPermissions.append(perm) built.append(c) diff --git a/unit-tests/test_command_register.py b/unit-tests/test_command_register.py index 2da3c9b00..3074af5a7 100644 --- a/unit-tests/test_command_register.py +++ b/unit-tests/test_command_register.py @@ -201,6 +201,13 @@ def test_share_record_update_expiration_without_roe(self): finally: TestRegister.record_share_rq_rs = original + def test_get_share_expiration_rejects_sub_minute(self): + with self.assertRaises(CommandError) as ctx: + register.get_share_expiration(None, '0mi', cmd_name='share-record') + self.assertIn('at least 1 minute', str(ctx.exception)) + with self.assertRaises(CommandError): + register.get_share_expiration('2020-01-01T00:00:00', None, cmd_name='share-record') + def test_share_record_update_clears_expiration_with_never(self): """--expire-at never on an existing share must clear the timer (expiration = -1).""" params = get_synced_params() diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index c5bd7b591..a9e784ae5 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -69,6 +69,15 @@ def _make_record(record_uid=None, title='Test Record'): } +def _make_sharing_status(record_uid, recipient_uid_bytes=None): + from keepercommander.proto import record_sharing_pb2 + status = record_sharing_pb2.Status() + status.recordUid = utils.base64_url_decode(record_uid) + status.recipientUid = recipient_uid_bytes or utils.base64_url_decode(utils.generate_uid()) + status.status = record_sharing_pb2.SUCCESS + return status + + class TestCommandHelpers(TestCase): def test_parse_expiration_none(self): @@ -99,6 +108,14 @@ def test_parse_expiration_invalid(self): with self.assertRaises(CommandError): parse_expiration(None, 'invalid', 'test') + def test_parse_expiration_rejects_sub_minute(self): + from keepercommander.commands.nested_share_folder.helpers import parse_expiration + with self.assertRaises(CommandError) as ctx: + parse_expiration(None, '0mi', 'test') + self.assertIn('at least 1 minute', str(ctx.exception)) + with self.assertRaises(CommandError): + parse_expiration('2020-01-01T00:00:00Z', None, 'test') + def test_infer_role(self): from keepercommander.commands.nested_share_folder.helpers import infer_role self.assertEqual(infer_role({'can_change_ownership': True}), 'full-manager') @@ -783,6 +800,218 @@ def test_grant_folder_access_sends_invite_when_no_active_share( mock_handle_invite.assert_called_once_with(params, email, True) mock_access_update.assert_not_called() + @patch('keepercommander.nested_share_folder.folder_api.parse_folder_access_result') + @patch('keepercommander.nested_share_folder.folder_api.folder_access_update_v3') + @patch('keepercommander.nested_share_folder.folder_api._resolve_accessor') + @patch('keepercommander.nested_share_folder.folder_api.resolve_folder_identifier') + def test_update_folder_access_v3_sets_expiration( + self, mock_resolve_folder, mock_resolve_accessor, + mock_access_update, mock_parse_result): + from keepercommander.nested_share_folder.folder_api import update_folder_access_v3 + + fuid, _ = _make_folder() + email = 'user@example.com' + uid_bytes = utils.base64_url_decode(utils.generate_uid()) + mock_resolve_folder.return_value = fuid + mock_resolve_accessor.return_value = (uid_bytes, email, 1) + mock_parse_result.return_value = {'success': True} + mock_access_update.return_value = Mock() + + expiration = 1_700_000_000_000 + update_folder_access_v3( + _make_params(), fuid, email, expiration_timestamp=expiration) + + update_call = mock_access_update.call_args + ad = update_call.kwargs['folder_access_updates'][0] + self.assertEqual(ad.tlaProperties.expiration, expiration) + + @patch('keepercommander.nested_share_folder.folder_api.update_folder_access_v3') + @patch('keepercommander.nested_share_folder.folder_api._check_existing_access') + @patch('keepercommander.nested_share_folder.folder_api.get_user_public_key') + @patch('keepercommander.nested_share_folder.folder_api.resolve_folder_identifier') + def test_grant_folder_access_update_passes_expiration( + self, mock_resolve_folder, mock_get_public_key, + mock_existing, mock_update): + from keepercommander.nested_share_folder.folder_api import grant_folder_access_v3 + + fuid, fobj = _make_folder() + email = 'user@example.com' + uid_bytes = utils.base64_url_decode(utils.generate_uid()) + mock_resolve_folder.return_value = fuid + mock_get_public_key.return_value = (Mock(), False, uid_bytes, False) + mock_existing.return_value = 'viewer' + mock_update.return_value = {'success': True} + + expiration = 1_800_000_000_000 + grant_folder_access_v3( + _make_params(nested_share_folders={fuid: fobj}), + fuid, email, role='content-manager', expiration_timestamp=expiration) + + mock_update.assert_called_once_with( + mock.ANY, fuid, email, role='content-manager', as_team=False, + expiration_timestamp=expiration) + + @patch('keepercommander.nested_share_folder.folder_api.update_folder_access_v3') + @patch('keepercommander.nested_share_folder.folder_api._check_existing_access') + @patch('keepercommander.nested_share_folder.folder_api.get_user_public_key') + @patch('keepercommander.nested_share_folder.folder_api.resolve_folder_identifier') + def test_grant_folder_access_same_role_updates_expiration( + self, mock_resolve_folder, mock_get_public_key, + mock_existing, mock_update): + from keepercommander.nested_share_folder.folder_api import grant_folder_access_v3 + + fuid, fobj = _make_folder() + email = 'user@example.com' + uid_bytes = utils.base64_url_decode(utils.generate_uid()) + mock_resolve_folder.return_value = fuid + mock_get_public_key.return_value = (Mock(), False, uid_bytes, False) + mock_existing.return_value = 'viewer' + mock_update.return_value = {'success': True} + + expiration = 1_900_000_000_000 + grant_folder_access_v3( + _make_params(nested_share_folders={fuid: fobj}), + fuid, email, role='viewer', expiration_timestamp=expiration) + + mock_update.assert_called_once_with( + mock.ANY, fuid, email, role='viewer', as_team=False, + expiration_timestamp=expiration) + + +class TestNestedShareFolderRecordApi(TestCase): + + def setUp(self): + from keepercommander.nested_share_folder import record_api # noqa: F401 + mock.patch('keepercommander.sync_down.sync_down').start() + + def tearDown(self): + mock.patch.stopall() + + @patch('keepercommander.nested_share_folder.record_api.api.communicate_rest') + @patch('keepercommander.nested_share_folder.record_api.encrypt_for_recipient') + @patch('keepercommander.nested_share_folder.record_api.get_user_public_key') + @patch('keepercommander.nested_share_folder.record_api.get_record_from_cache') + def test_update_record_share_v3_sets_expiration_and_notification( + self, mock_get_record, mock_get_public_key, + mock_encrypt, mock_communicate): + from keepercommander.nested_share_folder.record_api import update_record_share_v3 + from keepercommander.proto import tla_pb2 + + ruid, robj = _make_record() + email = 'user@example.com' + uid_bytes = utils.base64_url_decode(utils.generate_uid()) + mock_get_record.return_value = robj + mock_get_public_key.return_value = (Mock(), False, uid_bytes, False) + mock_encrypt.return_value = b'enc-key' + mock_response = Mock() + mock_response.updatedSharingStatus = [] + mock_communicate.return_value = mock_response + + update_record_share_v3( + _make_params(nested_share_records={ruid: robj}), + ruid, email, access_role_type=1, expiration_timestamp=-1) + + rq = mock_communicate.call_args[0][1] + perm = rq.updateSharingPermissions[0] + self.assertEqual(perm.rules.tlaProperties.expiration, -1) + + @patch('keepercommander.nested_share_folder.record_api.api.communicate_rest') + @patch('keepercommander.nested_share_folder.record_api.encrypt_for_recipient') + @patch('keepercommander.nested_share_folder.record_api.get_user_public_key') + @patch('keepercommander.nested_share_folder.record_api.get_record_from_cache') + def test_update_record_share_v3_recreate_when_setting_expiration( + self, mock_get_record, mock_get_public_key, + mock_encrypt, mock_communicate): + from keepercommander.nested_share_folder.record_api import update_record_share_v3 + from keepercommander.proto import tla_pb2 + + ruid, robj = _make_record() + email = 'user@example.com' + uid_bytes = utils.base64_url_decode(utils.generate_uid()) + mock_get_record.return_value = robj + mock_get_public_key.return_value = (Mock(), False, uid_bytes, False) + mock_encrypt.return_value = b'enc-key' + mock_response = Mock() + mock_response.revokedSharingStatus = [_make_sharing_status(ruid, uid_bytes)] + mock_response.createdSharingStatus = [_make_sharing_status(ruid, uid_bytes)] + mock_communicate.side_effect = [mock_response, mock_response] + + expiration = 1_900_000_000_000 + update_record_share_v3( + _make_params(nested_share_records={ruid: robj}), + ruid, email, access_role_type=1, expiration_timestamp=expiration) + + self.assertEqual(mock_communicate.call_count, 2) + revoke_rq = mock_communicate.call_args_list[0][0][1] + create_rq = mock_communicate.call_args_list[1][0][1] + self.assertEqual(len(revoke_rq.revokeSharingPermissions), 1) + self.assertEqual(len(revoke_rq.createSharingPermissions), 0) + self.assertEqual(len(create_rq.createSharingPermissions), 1) + self.assertEqual(len(create_rq.updateSharingPermissions), 0) + perm = create_rq.createSharingPermissions[0] + self.assertEqual(perm.rules.tlaProperties.expiration, expiration) + self.assertEqual(perm.rules.tlaProperties.timerNotificationType, tla_pb2.NOTIFY_OWNER) + + @patch('keepercommander.sync_down.sync_down') + @patch('keepercommander.nested_share_folder.record_api.api.communicate_rest') + @patch('keepercommander.nested_share_folder.record_api.encrypt_for_recipient') + @patch('keepercommander.nested_share_folder.record_api.get_user_public_key') + @patch('keepercommander.nested_share_folder.record_api.get_record_from_cache') + def test_update_record_share_v3_recreate_syncs_once( + self, mock_get_record, mock_get_public_key, + mock_encrypt, mock_communicate, mock_sync_down): + from keepercommander.nested_share_folder import record_api as ra + + ruid, robj = _make_record() + email = 'user@example.com' + uid_bytes = utils.base64_url_decode(utils.generate_uid()) + mock_get_record.return_value = robj + mock_get_public_key.return_value = (Mock(), False, uid_bytes, False) + mock_encrypt.return_value = b'enc-key' + mock_response = Mock() + mock_response.revokedSharingStatus = [_make_sharing_status(ruid, uid_bytes)] + mock_response.createdSharingStatus = [_make_sharing_status(ruid, uid_bytes)] + mock_communicate.side_effect = [mock_response, mock_response] + + ra.update_record_share_v3( + _make_params(nested_share_records={ruid: robj}), + ruid, email, access_role_type=1, expiration_timestamp=1_900_000_000_000) + + self.assertEqual(mock_sync_down.call_count, 1) + + def test_is_record_share_update_noop(self): + from keepercommander.nested_share_folder.record_api import is_record_share_update_noop + + existing = {'access_role_type': 2, 'tla_expiration': 1_900_000_000_000} + self.assertTrue(is_record_share_update_noop(existing, 2, None)) + self.assertTrue(is_record_share_update_noop( + existing, 2, 1_900_000_000_500)) + self.assertFalse(is_record_share_update_noop(existing, 4, None)) + self.assertFalse(is_record_share_update_noop( + existing, 2, 1_900_001_000_000)) + self.assertTrue(is_record_share_update_noop( + {'access_role_type': 2}, 2, -1)) + + @patch('keepercommander.nested_share_folder.record_api.api.communicate_rest') + def test_get_record_accesses_v3_reads_tla_expiration(self, mock_communicate): + from keepercommander.nested_share_folder.record_api import get_record_accesses_v3 + from keepercommander.proto import record_details_pb2, folder_pb2, tla_pb2 + + ruid = utils.generate_uid() + rs = record_details_pb2.RecordAccessResponse() + ra = rs.recordAccesses.add() + ra.data.recordUid = utils.base64_url_decode(ruid) + ra.data.accessTypeUid = b'\x01' * 16 + ra.data.accessType = folder_pb2.AT_USER + ra.data.accessRoleType = folder_pb2.VIEWER + ra.data.tlaProperties.expiration = 1_783_667_017_211 + ra.data.tlaProperties.timerNotificationType = tla_pb2.NOTIFY_OWNER + ra.accessorInfo.name = 'user@example.com' + mock_communicate.return_value = rs + + result = get_record_accesses_v3(_make_params(), [ruid]) + self.assertEqual(result['record_accesses'][0]['tla_expiration'], 1_783_667_017_211) + class TestNestedShareFolderDisplayCommands(TestCase): From 8eff5c52de29fd1345b042bd12ac2fa4425cbc52 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Tue, 16 Jun 2026 11:35:35 +0530 Subject: [PATCH 06/18] Fix SQL injection in MSSQL password rotation (#2151) * Fix SQL injection in MSSQL password rotation and reject unsafe --password input * allow / and . in login regex --- keepercommander/plugins/commands.py | 38 ++++++++++++++ keepercommander/plugins/mssql/mssql.py | 18 ++++++- keepercommander/plugins/plugin_manager.py | 50 +++++++++++++++++-- .../service/util/verified_command.py | 31 +----------- tests/test_kc1163_record_field_labels.py | 23 +++++++++ 5 files changed, 126 insertions(+), 34 deletions(-) diff --git a/keepercommander/plugins/commands.py b/keepercommander/plugins/commands.py index a1ee7ec34..93b7a1f77 100644 --- a/keepercommander/plugins/commands.py +++ b/keepercommander/plugins/commands.py @@ -62,6 +62,42 @@ def register_command_info(aliases, command_info): rotate_parser.error = raise_parse_exception rotate_parser.exit = suppress_exit +UNSAFE_ROTATION_PASSWORD_PATTERN = re.compile(r"""[';"\\]|--""") +_UNSAFE_ROTATION_PASSWORD_LABELS = { + "'": "single quote (')", + '"': 'double quote (")', + ';': 'semicolon (;)', + '\\': 'backslash (\\)', + '--': 'double hyphen (--)', +} + + +def validate_user_supplied_rotation_password(new_password): + # type: (str) -> bool + matches = UNSAFE_ROTATION_PASSWORD_PATTERN.findall(new_password) + if not matches: + return True + + labels = [] + seen = set() + for match in matches: + if match in seen: + continue + seen.add(match) + labels.append(_UNSAFE_ROTATION_PASSWORD_LABELS.get(match, repr(match))) + + if len(labels) == 1: + logging.error( + 'Password contains character unsafe for database rotation: %s', + labels[0], + ) + else: + logging.error( + 'Password contains characters unsafe for database rotation: %s', + ', '.join(labels), + ) + return False + def adjust_password(password): # type: (str) -> str if not password: @@ -178,6 +214,8 @@ def rotate_password(params, record_uid, rotate_name=None, plugin_name=None, host if not length: length = plugin_kwargs.get('length') new_password = get_new_password(plugin, rules, length) + elif not validate_user_supplied_rotation_password(new_password): + return False if plugin_kwargs.get('password') == new_password: logging.warning('Rotation aborted because the old and new passwords are the same.') diff --git a/keepercommander/plugins/mssql/mssql.py b/keepercommander/plugins/mssql/mssql.py index 99ac18f19..e53e54e04 100644 --- a/keepercommander/plugins/mssql/mssql.py +++ b/keepercommander/plugins/mssql/mssql.py @@ -10,6 +10,8 @@ # Contact: ops@keepersecurity.com # import logging +import re + import pymssql """Commander Plugin for Microsoft SQL Server @@ -17,6 +19,13 @@ pip3 install pymssql """ +_LOGIN_NAME_PATTERN = re.compile(r'^[a-zA-Z_@#.][a-zA-Z0-9_@#.$\\-]*$') + +def _quoted_login_name(login): + if not login or not _LOGIN_NAME_PATTERN.match(login): + return None + return f'[{login.replace("]", "]]")}]' + class Rotator: def __init__(self, login, password, host=None, port=1433, db=None, **kwargs): @@ -47,6 +56,11 @@ def rotate(self, record, new_password, revert=False): old_password = self.password user = self.login + quoted_user = _quoted_login_name(user) + if not quoted_user: + logging.error('Invalid SQL Server login name for rotation: %s', user) + return False + kwargs = {'user': user, 'password': old_password} if self.host: kwargs['server'] = self.host @@ -60,8 +74,8 @@ def rotate(self, record, new_password, revert=False): with connection.cursor() as cursor: host = 'default host' if self.host is None else f'"{self.host}"' logging.debug(f'Connected to {host}') - sql = f"ALTER LOGIN {user} WITH PASSWORD = '{new_password}';" - cursor.execute(sql) + sql = f"ALTER LOGIN {quoted_user} WITH PASSWORD = %s" + cursor.execute(sql, (new_password,)) # connection is not autocommit by default. So you must commit to save your changes. connection.commit() result = True diff --git a/keepercommander/plugins/plugin_manager.py b/keepercommander/plugins/plugin_manager.py index 2fdedaed2..318010ee4 100644 --- a/keepercommander/plugins/plugin_manager.py +++ b/keepercommander/plugins/plugin_manager.py @@ -13,6 +13,7 @@ from urllib.parse import urlparse from . import noop +from ..vault import PasswordRecord, TypedField, TypedRecord CONVERT_KWARG_TO_INT = ('port', 'length') @@ -156,6 +157,51 @@ def get_custom_cmdr_fields(record): } +def get_rotation_record_kwargs(record): + """Extract login/password/url/host for rotation plugins from a vault record.""" + plugin_kwargs = {} + + if isinstance(record, PasswordRecord): + if record.login: + plugin_kwargs['login'] = record.login + if record.password: + plugin_kwargs['password'] = record.password + if record.link: + plugin_kwargs['url'] = record.link + elif isinstance(record, TypedRecord): + login_field = record.get_typed_field('login') + if login_field: + login = login_field.get_default_value(str) + if login: + plugin_kwargs['login'] = login + password_field = record.get_typed_field('password') + if password_field: + password = password_field.get_default_value(str) + if password: + plugin_kwargs['password'] = password + url_field = record.get_typed_field('url') + if url_field: + url = url_field.get_default_value(str) + if url: + plugin_kwargs['url'] = url + host_field = record.get_typed_field('host') + if host_field: + host_value = host_field.get_default_value(dict) + if host_value: + host = TypedField.export_host_field(host_value) + if host: + plugin_kwargs['host'] = host + + for key, value in record.enumerate_fields(): + if not value or key not in ('(login)', '(password)', '(url)', '(host)'): + continue + name = key[1:-1] + if name not in plugin_kwargs: + plugin_kwargs[name] = value + + return plugin_kwargs + + def get_plugin(record, rotate_name, plugin_name=None, host=None, port=None): """Load plugin based on given record and alt identifier @@ -171,9 +217,7 @@ def get_plugin(record, rotate_name, plugin_name=None, host=None, port=None): plugin_kwargs = {} plugin_kwargs.update(cmdr_kwargs) - plugin_kwargs.update({ - k[1:-1]: v for k, v in record.enumerate_fields() if k in ('(login)', '(password)', '(url)', '(host)') and v - }) + plugin_kwargs.update(get_rotation_record_kwargs(record)) if 'host' in plugin_kwargs: server = plugin_kwargs['host'] h, sep, p = server.partition(':') diff --git a/keepercommander/service/util/verified_command.py b/keepercommander/service/util/verified_command.py index 72c9c97ae..e622da0e9 100644 --- a/keepercommander/service/util/verified_command.py +++ b/keepercommander/service/util/verified_command.py @@ -56,18 +56,7 @@ def validate_mkdir_command(command): if missing_params: return f"Missing required parameters: {' and '.join(missing_params)}" return None - - # Legacy methods for backward compatibility - @staticmethod - def is_append_command(command): - """Legacy method - returns True if command is invalid.""" - return Verifycommand.validate_append_command(command) is not None - - @staticmethod - def is_mkdir_command(command): - """Legacy method - returns True if command is invalid.""" - return Verifycommand.validate_mkdir_command(command) is not None - + @staticmethod def validate_transform_folder_command(command): """ @@ -96,20 +85,4 @@ def validate_transform_folder_command(command): if missing_params: return f"Missing required parameters: {' and '.join(missing_params)}" - return None - - # Legacy methods for backward compatibility - @staticmethod - def is_append_command(command): - """Legacy method - returns True if command is invalid.""" - return Verifycommand.validate_append_command(command) is not None - - @staticmethod - def is_mkdir_command(command): - """Legacy method - returns True if command is invalid.""" - return Verifycommand.validate_mkdir_command(command) is not None - - @staticmethod - def is_transform_folder_command(command): - """Legacy method - returns True if command is invalid.""" - return Verifycommand.validate_transform_folder_command(command) is not None \ No newline at end of file + return None \ No newline at end of file diff --git a/tests/test_kc1163_record_field_labels.py b/tests/test_kc1163_record_field_labels.py index 0b30107d2..352f6470b 100644 --- a/tests/test_kc1163_record_field_labels.py +++ b/tests/test_kc1163_record_field_labels.py @@ -144,5 +144,28 @@ def test_old_code_produced_blank_labels(self): "Expected old code to produce blank labels (regression check)") +class TestPluginManagerLabeledFields(unittest.TestCase): + """Rotation plugins must read login/password from labeled typed fields (KC-1163).""" + + def test_get_rotation_record_kwargs_reads_labeled_login_password(self): + from keepercommander.plugins import plugin_manager + + record = vault.TypedRecord() + record.type_name = 'login' + record.fields.append(vault.TypedField.new_field('login', ['sa'], 'login')) + record.fields.append(vault.TypedField.new_field('password', ['Str0ng!Pass'], 'password')) + record.custom.append(vault.TypedField.new_field('text', ['mssql'], 'cmdr:plugin')) + record.custom.append(vault.TypedField.new_field('text', ['127.0.0.1'], 'cmdr:host')) + record.custom.append(vault.TypedField.new_field('text', ['1433'], 'cmdr:port')) + + plugin_name, plugin_kwargs, plugin = plugin_manager.get_plugin(record, rotate_name=None) + self.assertEqual(plugin_name, 'mssql') + self.assertEqual(plugin_kwargs['login'], 'sa') + self.assertEqual(plugin_kwargs['password'], 'Str0ng!Pass') + self.assertEqual(plugin_kwargs['host'], '127.0.0.1') + self.assertEqual(plugin_kwargs['port'], 1433) + self.assertIsNotNone(plugin) + + if __name__ == '__main__': unittest.main() From a2e3ef838ac63624f9df6ea5530f7e58b7331995 Mon Sep 17 00:00:00 2001 From: idimov-keeper <78815270+idimov-keeper@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:15:28 -0500 Subject: [PATCH 07/18] Fix IAM user link permission-check rotation request shape. (#2159) KeeperApp and krouter require configurationUid on set_record_rotation; include PAM config UID, matching revision for existing rotation rows, and an explicit empty resourceUid so IAM semantics are not overridden by stale cache data. Co-authored-by: Cursor --- .../tunnel/port_forward/TunnelGraph.py | 10 ++++ unit-tests/pam/test_dag_layer_b_migration.py | 51 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/keepercommander/commands/tunnel/port_forward/TunnelGraph.py b/keepercommander/commands/tunnel/port_forward/TunnelGraph.py index 7ac683d1c..4fb530dc7 100644 --- a/keepercommander/commands/tunnel/port_forward/TunnelGraph.py +++ b/keepercommander/commands/tunnel/port_forward/TunnelGraph.py @@ -379,8 +379,18 @@ def _permission_check_iam_user_link(self, user_uid): server enforces edit-access on the pamUser record at the call boundary, so the per-flag check that other Layer-B endpoints do is not needed here.""" from ...pam.router_helper import router_set_record_rotation_information + current_record_rotation = self.params.record_rotation_cache.get(user_uid) + revision = ( + current_record_rotation.get('revision', 0) + if current_record_rotation else 0 + ) + # IAM link: resourceUid must stay empty so krouter sets isIAM=true + # (resourceUid.isEmpty && noop=False). Never copy resource_uid from cache. rq = router_pb2.RouterRecordRotationRequest( recordUid=url_safe_str_to_bytes(user_uid), + configurationUid=url_safe_str_to_bytes(self.record.record_uid), + revision=revision, + resourceUid=b'', noop=False, ) router_set_record_rotation_information(self.params, rq) diff --git a/unit-tests/pam/test_dag_layer_b_migration.py b/unit-tests/pam/test_dag_layer_b_migration.py index e77c4ee9e..b7be169d1 100644 --- a/unit-tests/pam/test_dag_layer_b_migration.py +++ b/unit-tests/pam/test_dag_layer_b_migration.py @@ -22,6 +22,7 @@ from unittest.mock import MagicMock, patch import pytest +from keeper_secrets_manager_core.utils import url_safe_str_to_bytes sys.path.insert(0, os.path.dirname(__file__)) @@ -697,6 +698,7 @@ def _build_tg(): tg = TunnelDAG.__new__(TunnelDAG) tg.params = MagicMock() + tg.params.record_rotation_cache = {} tg.record = MagicMock() tg.record.record_uid = CONFIG_UID_STR tg.linking_dag = MagicMock() @@ -723,6 +725,7 @@ def _capture(params, rq, *args, **kwargs): assert isinstance(rq, router_pb2.RouterRecordRotationRequest) # recordUid is the pamUser record (the target of the permission check) assert len(rq.recordUid) == 16 + assert rq.configurationUid == url_safe_str_to_bytes(CONFIG_UID_STR) # noop=False is what triggers the is_iam_user write server-side assert rq.noop is False # NO resourceUid, NO saasConfiguration — these would change the semantics @@ -731,6 +734,52 @@ def _capture(params, rq, *args, **kwargs): # After permission check succeeds, the legacy link_user mutation still runs link_user_mock.assert_called_once() + def test_link_user_to_config_passes_revision_from_rotation_cache(self): + """When pamUser already has rotation metadata, KeeperApp requires matching revision.""" + tg, _ = self._build_tg() + tg.params.record_rotation_cache = { + USER_UID_STR: {'revision': 3, 'configuration_uid': CONFIG_UID_STR}, + } + captured = {} + + def _capture(params, rq, *args, **kwargs): + captured['rq'] = rq + + with patch('keepercommander.commands.pam.router_helper.router_set_record_rotation_information', + side_effect=_capture), \ + patch.object(tg, 'link_user'): + tg.link_user_to_config(USER_UID_STR) + + rq = captured.get('rq') + assert rq is not None + assert rq.revision == 3 + assert rq.resourceUid == b'' + + def test_link_user_to_config_clears_stale_resource_uid_from_rotation_cache(self): + """IAM permission-check must not send cached resource_uid (non-IAM semantics).""" + tg, _ = self._build_tg() + tg.params.record_rotation_cache = { + USER_UID_STR: { + 'revision': 2, + 'configuration_uid': CONFIG_UID_STR, + 'resource_uid': 'AAAAAAAAAAAAAAAAAAAAAA', + }, + } + captured = {} + + def _capture(params, rq, *args, **kwargs): + captured['rq'] = rq + + with patch('keepercommander.commands.pam.router_helper.router_set_record_rotation_information', + side_effect=_capture), \ + patch.object(tg, 'link_user'): + tg.link_user_to_config(USER_UID_STR) + + rq = captured.get('rq') + assert rq is not None + assert rq.revision == 2 + assert rq.resourceUid == b'' + def test_link_user_to_config_no_fallback_on_permission_denial(self): """If set_record_rotation fails (permission denied), DO NOT fall back to the legacy DAG write - propagate so the unauthorized link is never @@ -770,6 +819,8 @@ def _capture(params, rq, *args, **kwargs): rq = captured.get('rq') assert rq is not None assert isinstance(rq, router_pb2.RouterRecordRotationRequest) + assert rq.configurationUid == url_safe_str_to_bytes(CONFIG_UID_STR) + assert rq.resourceUid == b'' assert rq.noop is False def test_link_user_to_config_with_options_iam_user_not_true_skips_set_rotation(self): From 63de90e85cce6390129e61a890245c8cbde1e420 Mon Sep 17 00:00:00 2001 From: idimov-keeper <78815270+idimov-keeper@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:59:10 -0500 Subject: [PATCH 08/18] Add --online filter to pam gateway list command (#2158) * Add --online filter to pam gateway list with gateway totals. Co-authored-by: Cursor * Add -o short option for pam gateway list --online. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- keepercommander/commands/discoveryrotation.py | 27 ++++++++ unit-tests/pam/test_pam_rotation.py | 62 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index d8e5b3b57..d8797f141 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -1399,6 +1399,8 @@ class PAMGatewayListCommand(Command): help='Verbose output') parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], default='table', help='Output format (table, json)') + parser.add_argument('--online', '-o', required=False, default=False, dest='online_only', action='store_true', + help='Show only online gateways') def get_parser(self): return PAMGatewayListCommand.parser @@ -1408,6 +1410,7 @@ def execute(self, params, **kwargs): is_force = kwargs.get('is_force') is_verbose = kwargs.get('is_verbose') format_type = kwargs.get('format', 'table') + online_only = kwargs.get('online_only', False) is_router_down = False krouter_url = router_helper.get_router_url(params) @@ -1478,6 +1481,8 @@ def execute(self, params, **kwargs): connected_controllers_dict[controller.controllerUid] = [] connected_controllers_dict[controller.controllerUid].append(controller) + gateway_counts = {'online': 0, 'offline': 0, 'total': len(enterprise_controllers_all)} + # Process each gateway and handle multiple instances for c in enterprise_controllers_all: gateway_uid_bytes = c.controllerUid @@ -1485,6 +1490,17 @@ def execute(self, params, **kwargs): connected_instances = connected_controllers_dict.get(gateway_uid_bytes, []) + is_online = not is_router_down and len(connected_instances) > 0 + if is_router_down: + pass + elif is_online: + gateway_counts['online'] += 1 + else: + gateway_counts['offline'] += 1 + + if online_only and not is_online: + continue + ksm_app_uid_str = utils.base64_url_encode(c.applicationUid) ksm_app = KSMCommand.get_app_record(params, ksm_app_uid_str) @@ -1714,6 +1730,9 @@ def execute(self, params, **kwargs): else: result = {"gateways": gateways_data} + if online_only: + result["gateway_counts"] = gateway_counts + return json.dumps(result, indent=2) else: # Separate rows into groups: each parent with its instances @@ -1746,6 +1765,14 @@ def execute(self, params, **kwargs): dump_report_data(table, headers, fmt='table', filename="", row_number=False, column_width=None) + if online_only: + print( + f"\n{bcolors.BOLD}Gateways: " + f"{bcolors.OKGREEN}Online: {gateway_counts['online']}{bcolors.ENDC}, " + f"{bcolors.FAIL}Offline: {gateway_counts['offline']}{bcolors.ENDC}, " + f"Total: {gateway_counts['total']}{bcolors.ENDC}\n" + ) + class PAMConfigurationListCommand(Command): parser = argparse.ArgumentParser(prog='pam config list') diff --git a/unit-tests/pam/test_pam_rotation.py b/unit-tests/pam/test_pam_rotation.py index f7fce197a..09b96d6db 100644 --- a/unit-tests/pam/test_pam_rotation.py +++ b/unit-tests/pam/test_pam_rotation.py @@ -416,6 +416,13 @@ def test_parser(self): self.assertTrue(args.is_verbose) self.assertTrue(args.is_force) + def test_parser_online(self): + args = self.parser.parse_args(['--online']) + self.assertTrue(args.online_only) + + args = self.parser.parse_args(['-o']) + self.assertTrue(args.online_only) + @patch('keepercommander.commands.discoveryrotation.router_get_connected_gateways') @patch('keepercommander.commands.discoveryrotation.router_helper.get_router_url') @patch('keepercommander.commands.discoveryrotation.gateway_helper.get_all_gateways') @@ -488,6 +495,61 @@ def test_execute_router_down(self, mock_dump_report_data, mock_get_all_gateways, self.assertTrue(mock_get_all_gateways.called) self.assertTrue(mock_get_router_url.called) + @patch('keepercommander.commands.discoveryrotation.print') + @patch('keepercommander.commands.discoveryrotation.router_get_connected_gateways') + @patch('keepercommander.commands.discoveryrotation.router_helper.get_router_url') + @patch('keepercommander.commands.discoveryrotation.gateway_helper.get_all_gateways') + @patch('keepercommander.commands.discoveryrotation.KSMCommand.get_app_record') + @patch('keepercommander.commands.discoveryrotation.dump_report_data') + def test_execute_online_only(self, mock_dump_report_data, mock_get_app_record, mock_get_all_gateways, + mock_get_router_url, mock_router_get_connected_gateways, mock_print): + mock_params = create_mock_params() + + online_uid = utils.base64_url_decode('controller_uid') + offline_uid = utils.base64_url_decode('offline_uid') + + mock_router_get_connected_gateways.return_value.controllers = [ + MagicMock(controllerUid=online_uid, version='1.0.0') + ] + + mock_get_all_gateways.return_value = [ + MagicMock( + applicationUid=utils.base64_url_decode('app_uid'), + controllerUid=online_uid, + controllerName='Online Gateway', + deviceName='Device 1', + deviceToken='Token 1', + created=int(datetime.now().timestamp() * 1000), + lastModified=int(datetime.now().timestamp() * 1000), + nodeId='Node 1' + ), + MagicMock( + applicationUid=utils.base64_url_decode('app_uid2'), + controllerUid=offline_uid, + controllerName='Offline Gateway', + deviceName='Device 2', + deviceToken='Token 2', + created=int(datetime.now().timestamp() * 1000), + lastModified=int(datetime.now().timestamp() * 1000), + nodeId='Node 2' + ) + ] + + mock_get_app_record.return_value = { + 'data_unencrypted': json.dumps({'title': 'App Title'}) + } + + kwargs = {'is_force': True, 'online_only': True} + self.command.execute(mock_params, **kwargs) + + self.assertTrue(mock_dump_report_data.called) + table = mock_dump_report_data.call_args[0][0] + self.assertEqual(len(table), 1) + self.assertIn('Online Gateway', table[0][1]) + + totals_printed = any('Online: 1' in str(call.args[0]) for call in mock_print.call_args_list) + self.assertTrue(totals_printed) + @patch('keepercommander.commands.discoveryrotation.router_get_connected_gateways') @patch('keepercommander.commands.discoveryrotation.router_helper.get_router_url') @patch('keepercommander.commands.discoveryrotation.gateway_helper.get_all_gateways') From 2a94cbbf3881447c295257149a02f54d4f8e0be2 Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Wed, 17 Jun 2026 18:59:45 +0100 Subject: [PATCH 09/18] Add secret-ids arg in Thycotic import (#2150) * Add secret-ids argument for thycotic import Add secret-ids arg to import command, to use for debugging Thycotic secret IDs * Pass secret-ids arg from import command to thycotic import Pass secret-ids arg from import command to thycotic import so it can be handled in the Thycotic import * Add handling for secret-ids arg in Thycotic import If user sets secret-ids, the import will: - Check if any of those IDs have come up in the lookup and would have been imported. - Import only the secret-ids set This is useful for debugging, because the lookup API may not return all Thycotic secrets - eg if there a security policy on them, but they may still be fetched. Usage: String (comma separated IDs) `import --format thycotic server_name --secret-ids "123, 124,125"` Python List (strings or integers) `secret_ids=[123, 124, 125]` --- keepercommander/importer/thycotic/thycotic.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/keepercommander/importer/thycotic/thycotic.py b/keepercommander/importer/thycotic/thycotic.py index f7409609c..c5359dad8 100644 --- a/keepercommander/importer/thycotic/thycotic.py +++ b/keepercommander/importer/thycotic/thycotic.py @@ -366,6 +366,30 @@ def do_import(self, filename, **kwargs): logging.warning('Invalid input for secret IDs, exiting.') return + # secret_ids arg + debug_ids = kwargs.get('secret_ids') + if debug_ids is not None: + # Convert CLI string input into list + if isinstance(debug_ids,str): + debug_ids = debug_ids.replace(' ','').split(',') + # Handle secret IDs list + if isinstance(debug_ids,list): + # Deduplicate + debug_ids = list(set(debug_ids)) + # Check whether secrets were found in the lookup + stringified_secrets_ids = [str(x) for x in secrets_ids] + stringified_debug_ids = [str(x) for x in debug_ids] + found_secrets = [x for x in stringified_debug_ids if x in stringified_secrets_ids] + logging.info(f'From the specified {len(debug_ids)} secret IDs, {len(found_secrets)} were found in the secret server lookup.') + logging.info(', '.join(found_secrets)) + + # Replace import list with specified IDs + secrets_ids = debug_ids + else: + # Quit if secret IDs != list + logging.warning('Invalid input for secret IDs, exiting.') + return + self._send_keep_alive_if_needed(params) print(f'Loading {len(secrets_ids)} Records ', flush=True, end='') secrets = [] From dc889d038ee94ca24493d259395ac15b2f5c6227 Mon Sep 17 00:00:00 2001 From: idimov-keeper <78815270+idimov-keeper@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:09:53 -0500 Subject: [PATCH 10/18] Add pam connection ai command for KeeperAI settings on PAM resources. (#2160) * Add pam connection ai command for KeeperAI settings on PAM resources. Implements show, set/unset, and remove with sparse DAG merges, configure_resource meta bootstrap, GSE_DELETION removal, and CLI warnings for duplicate or mirrored options. Co-authored-by: Cursor * Extend pam connection ai to pamRemoteBrowser records. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../commands/pam_import/keeper_ai_settings.py | 530 +++++++++++++++++- .../commands/tunnel_and_connections.py | 239 ++++++++ unit-tests/pam/test_dag_layer_b_migration.py | 29 +- unit-tests/pam/test_pam_connection_ai.py | 288 ++++++++++ 4 files changed, 1057 insertions(+), 29 deletions(-) create mode 100644 unit-tests/pam/test_pam_connection_ai.py diff --git a/keepercommander/commands/pam_import/keeper_ai_settings.py b/keepercommander/commands/pam_import/keeper_ai_settings.py index 7d4056c12..9a1bc1df2 100644 --- a/keepercommander/commands/pam_import/keeper_ai_settings.py +++ b/keepercommander/commands/pam_import/keeper_ai_settings.py @@ -21,11 +21,64 @@ from ... import vault from ...display import bcolors from ...proto import pam_pb2 +from ... import utils from ..pam._layer_b import should_fallback_on_layer_b_error, is_layer_b_feature_disabled from ..tunnel.port_forward.tunnel_helpers import get_config_uid, get_keeper_tokens from ...keeper_dag.crypto import encrypt_aes from keeper_secrets_manager_core.utils import url_safe_str_to_bytes +AI_RISK_LEVELS = ('critical', 'high', 'medium', 'low') +AI_SETTINGS_VERSION = 'v1.0.0' + + +def empty_keeper_ai_settings_dict() -> Dict[str, Any]: + """Vault-compatible default KeeperAI settings (``emptyKeeperAISettings`` in dag-pam-link.ts).""" + empty_allow_deny = {'allow': [], 'deny': []} + return { + 'version': AI_SETTINGS_VERSION, + 'riskLevels': { + 'critical': {'aiSessionTerminate': False, 'tags': dict(empty_allow_deny)}, + 'high': {'aiSessionTerminate': False, 'tags': dict(empty_allow_deny)}, + 'medium': {'aiSessionTerminate': False, 'tags': dict(empty_allow_deny)}, + 'low': {'aiSessionTerminate': False, 'tags': {'allow': []}}, + }, + } + + +def is_default_keeper_ai_settings(settings: Optional[Dict[str, Any]]) -> bool: + """True when settings are absent or match the vault empty/default template.""" + if not settings: + return True + risk_levels = settings.get('riskLevels') + if not isinstance(risk_levels, dict) or not risk_levels: + return True + for level, level_data in risk_levels.items(): + if not isinstance(level_data, dict): + return False + if level_data.get('aiSessionTerminate', False): + return False + tags = level_data.get('tags') + if not isinstance(tags, dict): + continue + if tags.get('allow'): + return False + if level != 'low' and tags.get('deny'): + return False + return True + + +def _find_highest_path_edge(vertex, head_uid: str, dag_path: str): + """Return the highest-version edge for a self-loop DATA path (any edge type).""" + best = None + best_version = -1 + for edge in vertex.edges or []: + if not edge or edge.head_uid != head_uid or edge.path != dag_path: + continue + if edge.version > best_version: + best_version = edge.version + best = edge + return best + def list_resource_data_edges( params: KeeperParams, @@ -129,7 +182,8 @@ def get_resource_settings( params: KeeperParams, resource_uid: str, dag_path: str, - config_uid: Optional[str] = None + config_uid: Optional[str] = None, + quiet_if_missing_vertex: bool = False, ) -> Optional[Dict[str, Any]]: """ Generic function to retrieve settings from a DAG DATA edge with the specified path for a resource. @@ -143,9 +197,11 @@ def get_resource_settings( Args: params: KeeperParams instance - resource_uid: UID of the PAM resource (pamMachine, pamDatabase, etc.) + resource_uid: UID of the PAM resource (pamMachine, pamDatabase, pamDirectory, pamRemoteBrowser) dag_path: Path of the DATA edge (e.g., 'ai_settings', 'jit_settings') config_uid: Optional PAM config UID. If not provided, will be looked up. + quiet_if_missing_vertex: Log at debug instead of warning when the resource + vertex is absent (expected before first link to a PAM Configuration). Returns: Dictionary containing settings if found, None otherwise. @@ -209,21 +265,17 @@ def get_resource_settings( # Get the resource vertex resource_vertex = linking_dag.get_vertex_by_uid(resource_uid) if not resource_vertex: - logging.warning(f"Resource vertex {resource_uid} not found in DAG") + log = logging.debug if quiet_if_missing_vertex else logging.warning + log(f"Resource vertex {resource_uid} not found in DAG") return None - # Find the DATA edge with the specified path (get highest version, regardless of active status) - settings_edge = None - highest_version = -1 - for edge in resource_vertex.edges: - if edge and (edge.edge_type == EdgeType.DATA and - edge.path == dag_path): - if edge.version > highest_version: - highest_version = edge.version - settings_edge = edge - - if not settings_edge: - logging.debug(f"No '{dag_path}' DATA edge found for resource {resource_uid}") + # Highest-version edge for this path; DELETION means "no settings" (vault GSE_DELETION). + settings_edge = _find_highest_path_edge(resource_vertex, resource_uid, dag_path) + if settings_edge is None or settings_edge.edge_type == EdgeType.DELETION: + logging.debug(f"No active '{dag_path}' DATA edge for resource {resource_uid}") + return None + if settings_edge.edge_type != EdgeType.DATA: + logging.debug(f"Latest '{dag_path}' edge is not DATA for resource {resource_uid}") return None # Get the content from the edge @@ -340,7 +392,7 @@ def get_resource_jit_settings( Args: params: KeeperParams instance - resource_uid: UID of the PAM resource (pamMachine, pamDatabase, etc.) + resource_uid: UID of the PAM resource (pamMachine, pamDatabase, pamDirectory, pamRemoteBrowser) config_uid: Optional PAM config UID. If not provided, will be looked up. Returns: @@ -352,7 +404,8 @@ def get_resource_jit_settings( def get_resource_keeper_ai_settings( params: KeeperParams, resource_uid: str, - config_uid: Optional[str] = None + config_uid: Optional[str] = None, + quiet_if_missing_vertex: bool = False, ) -> Optional[Dict[str, Any]]: """ Retrieve KeeperAI settings from the DAG DATA edge with path 'ai_settings' for a resource. @@ -366,7 +419,7 @@ def get_resource_keeper_ai_settings( Args: params: KeeperParams instance - resource_uid: UID of the PAM resource (pamMachine, pamDatabase, etc.) + resource_uid: UID of the PAM resource (pamMachine, pamDatabase, pamDirectory, pamRemoteBrowser) config_uid: Optional PAM config UID. If not provided, will be looked up. Returns: @@ -393,7 +446,10 @@ def get_resource_keeper_ai_settings( } Returns None if settings not found or error occurred. """ - return get_resource_settings(params, resource_uid, 'ai_settings', config_uid) + return get_resource_settings( + params, resource_uid, 'ai_settings', config_uid, + quiet_if_missing_vertex=quiet_if_missing_vertex, + ) def set_resource_keeper_ai_settings( @@ -418,7 +474,7 @@ def set_resource_keeper_ai_settings( Args: params: KeeperParams instance - resource_uid: UID of the PAM resource (pamMachine, pamDatabase, etc.) + resource_uid: UID of the PAM resource (pamMachine, pamDatabase, pamDirectory, pamRemoteBrowser) settings: Dictionary containing KeeperAI settings to save config_uid: Optional PAM config UID. If not provided, will be looked up. @@ -431,6 +487,10 @@ def set_resource_keeper_ai_settings( return False record_key, resolved_config_uid = common + if not settings: + logging.debug(f"KeeperAI settings empty for {resource_uid}, skipping save") + return False + encrypted_content = encrypt_aes(json.dumps(settings).encode(), record_key) # krouter's configure_resource only writes a settings edge when it loads the @@ -438,11 +498,15 @@ def set_resource_keeper_ai_settings( # carry meta/jit/connection (UserRest.kt). A keeperAiSettings-only request # leaves loopEdges null and the ai_settings write is silently dropped. The Web # Vault avoids this by always sending meta alongside the AI settings, so mirror - # that: include the resource's current meta in the same request. - meta_bytes = None - current_meta = get_resource_settings(params, resource_uid, 'meta', resolved_config_uid) - if isinstance(current_meta, dict): - meta_bytes = json.dumps(current_meta).encode() + # that: include the resource's current meta in the same request. When the + # resource is not in the DAG yet (first link), bootstrap v1 meta instead. + from ..tunnel.port_forward.TunnelGraph import build_resource_meta_v1 + + current_meta = get_resource_settings( + params, resource_uid, 'meta', resolved_config_uid, quiet_if_missing_vertex=True) + if not isinstance(current_meta, dict): + current_meta = build_resource_meta_v1({}, False) + meta_bytes = json.dumps(current_meta).encode() # Primary: Layer-B configure_resource (permission-checked). from ..pam.router_helper import router_configure_resource, get_router_url @@ -453,9 +517,8 @@ def set_resource_keeper_ai_settings( recordUid=url_safe_str_to_bytes(resource_uid), networkUid=url_safe_str_to_bytes(resolved_config_uid), keeperAiSettings=encrypted_content, + meta=meta_bytes, ) - if meta_bytes is not None: - rq.meta = meta_bytes try: router_configure_resource(params, rq) logging.debug(f"Saved KeeperAI settings via configure_resource for {resource_uid}") @@ -579,6 +642,73 @@ def _set_resource_keeper_ai_settings_legacy( return False +def _delete_resource_data_edge_legacy( + params: KeeperParams, + resource_uid: str, + config_uid: str, + record_key: bytes, + dag_path: str, +) -> Optional[bool]: + """Delete a path-scoped self-loop DATA edge via GSE_DELETION (vault ``createDeletionEvent``). + + Returns: + True if a DELETION edge was written, + None if the path was already absent, + False on error. + """ + try: + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + + dag_record = PasswordRecord() + dag_record.record_uid = config_uid + dag_record.record_key = record_key + + conn = Connection( + params=params, + encrypted_transmission_key=encrypted_transmission_key, + encrypted_session_token=encrypted_session_token, + transmission_key=transmission_key, + use_read_protobuf=False, + use_write_protobuf=False, + ) + + linking_dag = DAG( + conn=conn, + record=dag_record, + graph_id=PamGraphId.PAM.value, + decrypt=True, + ) + linking_dag.load() + + resource_vertex = linking_dag.get_vertex_by_uid(resource_uid) + if not resource_vertex: + logging.debug(f"No resource vertex {resource_uid} in DAG; '{dag_path}' already absent") + return None + + highest = _find_highest_path_edge(resource_vertex, resource_uid, dag_path) + if highest is None or highest.edge_type == EdgeType.DELETION: + logging.debug(f"'{dag_path}' already deleted for resource {resource_uid}") + return None + + for edge in resource_vertex.edges or []: + if (edge and edge.edge_type == EdgeType.DATA and edge.path == dag_path + and edge.head_uid == resource_uid and edge.active): + edge.active = False + + resource_vertex.belongs_to( + resource_vertex, + EdgeType.DELETION, + path=dag_path, + ) + linking_dag.save() + + logging.debug(f"Deleted '{dag_path}' via DELETION edge for resource {resource_uid}") + return True + except Exception as e: + logging.error(f"Error deleting '{dag_path}' for {resource_uid}: {e}", exc_info=True) + return False + + def set_resource_jit_settings( params: KeeperParams, resource_uid: str, @@ -824,6 +954,350 @@ def refresh_link_to_config_to_latest( return False +def _get_audit_user_id(params: KeeperParams) -> str: + if getattr(params, 'account_uid_bytes', None): + return utils.base64_url_encode(params.account_uid_bytes) + return getattr(params, 'user', '') or '' + + +def _make_tag_entry(tag: str, action: str, user_id: str) -> Dict[str, Any]: + return { + 'tag': tag, + 'auditLog': [{ + 'date': utils.current_milli_time(), + 'userId': user_id, + 'action': action, + }], + } + + +def _parse_tag_name(tag_item: Any) -> str: + if isinstance(tag_item, dict): + return str(tag_item.get('tag', '')).strip() + return str(tag_item).strip() + + +def _get_tag_list(level_data: Dict[str, Any], list_name: str) -> List[Dict[str, Any]]: + tags = level_data.setdefault('tags', {}) + entries = tags.get(list_name) + if not isinstance(entries, list): + entries = [] + tags[list_name] = entries + return entries + + +def parse_ai_setting_spec(spec: str) -> tuple: + """ + Parse CLI spec ``LEVEL``, ``LEVEL.SETTING``, or ``LEVEL.SETTING=VALUE``. + + Returns (level, setting_or_none, value_or_none). ``value_or_none`` is None when + ``=`` is absent (unset-without-value forms). + """ + if not spec or not str(spec).strip(): + raise ValueError('empty AI setting spec') + + text = str(spec).strip() + level_part, sep, value_part = text.partition('=') + if sep and value_part == '': + raise ValueError( + f'invalid AI setting spec (missing value): {spec}. ' + f'To remove a setting, use --unset|-u (e.g. -u high.terminate or -u high.allow=chmod).' + ) + + if '.' in level_part: + level_name, setting_name = level_part.split('.', 1) + else: + level_name, setting_name = level_part, None + + level_name = level_name.strip().lower() + if level_name not in AI_RISK_LEVELS: + raise ValueError(f'invalid risk level "{level_name}" (expected: {", ".join(AI_RISK_LEVELS)})') + + if setting_name is not None: + setting_name = setting_name.strip().lower() + if setting_name not in ('terminate', 'allow', 'deny'): + raise ValueError(f'invalid setting "{setting_name}" (expected: terminate, allow, deny)') + if level_name == 'low' and setting_name == 'deny': + raise ValueError('deny is not supported for the low risk level') + + value = value_part if sep else None + return level_name, setting_name, value + + +def dedupe_ai_cli_option_specs( + specs: Optional[List[str]], + option_label: str, +) -> tuple: + """Return unique specs in first-seen order and warnings for duplicate CLI options. + + ``option_label`` is shown in warnings (e.g. ``--set/-s``). + """ + if not specs: + return [], [] + + order: List[str] = [] + counts: Dict[str, int] = {} + for spec in specs: + counts[spec] = counts.get(spec, 0) + 1 + if spec not in order: + order.append(spec) + + warnings = [ + f'duplicate {option_label} ignored: {spec} ({counts[spec]}x)' + for spec in order + if counts[spec] > 1 + ] + return order, warnings + + +def _is_full_ai_setting_spec(spec: str) -> bool: + """True when spec is ``LEVEL.SETTING=VALUE`` (value present after ``=``).""" + _, setting, value = parse_ai_setting_spec(spec) + return setting is not None and value is not None + + +def _reconcile_set_unset_specs( + unset_specs: Optional[List[str]], + set_specs: Optional[List[str]], +) -> tuple: + """Drop ``--unset`` specs mirrored by a ``--set`` on the same ``LEVEL.SETTING=VALUE``. + + Only applies when both sides use the full ``level.setting=value`` form. Partial + unsets (e.g. ``-u high.allow`` or ``-u high.terminate``) are not reconciled. + """ + unset_list = list(unset_specs or []) + set_list = list(set_specs or []) + warnings: List[str] = [] + if not unset_list or not set_list: + return unset_list, set_list, warnings + + parsed_sets = [parse_ai_setting_spec(spec) for spec in set_list] + drop_unset_specs = set() + + for u_spec in unset_list: + if not _is_full_ai_setting_spec(u_spec): + continue + u_level, u_setting, _u_value = parse_ai_setting_spec(u_spec) + + for set_spec, (s_level, s_setting, _s_value) in zip(set_list, parsed_sets): + if u_level != s_level or u_setting != s_setting: + continue + if not _is_full_ai_setting_spec(set_spec): + continue + drop_unset_specs.add(u_spec) + if u_spec == set_spec: + warnings.append(f'--set/-s overrides --unset/-u: {u_spec}') + else: + warnings.append( + f'--set/-s overrides --unset/-u: {u_spec} (mirrors -s {set_spec})' + ) + break + + return [spec for spec in unset_list if spec not in drop_unset_specs], set_list, warnings + + +_LOW_TERMINATE_WARNING = 'risk level low.terminate always defaults to false.' + + +def _parse_terminate_value(value: str) -> bool: + normalized = str(value).strip().casefold() + if normalized == 'true': + return True + if normalized == 'false': + return False + raise ValueError(f'invalid terminate value "{value}" (expected true or false)') + + +def _existing_terminate_value(risk_levels: Dict[str, Any], level: str) -> Optional[bool]: + level_data = risk_levels.get(level) + if not isinstance(level_data, dict): + return None + value = level_data.get('aiSessionTerminate') + if value is None: + return None + return bool(value) + + +def _ensure_ai_settings_dict(existing: Optional[Dict[str, Any]]) -> Dict[str, Any]: + settings = dict(existing) if isinstance(existing, dict) else {} + settings['version'] = settings.get('version') or AI_SETTINGS_VERSION + risk_levels = settings.get('riskLevels') + if not isinstance(risk_levels, dict): + risk_levels = {} + settings['riskLevels'] = risk_levels + return settings + + +def _ensure_level_dict(risk_levels: Dict[str, Any], level: str) -> Dict[str, Any]: + level_data = risk_levels.get(level) + if not isinstance(level_data, dict): + level_data = {} + risk_levels[level] = level_data + return level_data + + +def _prune_level(level_data: Dict[str, Any]) -> bool: + tags = level_data.get('tags') + if isinstance(tags, dict): + for key in list(tags.keys()): + entries = tags.get(key) + if not entries: + tags.pop(key, None) + if not tags: + level_data.pop('tags', None) + return not level_data + + +def _level_is_only_default_low(level_data: Dict[str, Any]) -> bool: + if not isinstance(level_data, dict): + return True + tags = level_data.get('tags') + has_tags = isinstance(tags, dict) and any(tags.get(k) for k in tags) + if has_tags: + return False + terminate = level_data.get('aiSessionTerminate') + return terminate is None or terminate is False + + +def _normalize_ai_settings_for_save(settings: Dict[str, Any]) -> Dict[str, Any]: + risk_levels = settings.get('riskLevels') + if not isinstance(risk_levels, dict): + return {} + + for level in list(risk_levels.keys()): + if level not in AI_RISK_LEVELS: + risk_levels.pop(level, None) + continue + if _prune_level(risk_levels[level]): + risk_levels.pop(level, None) + + low_data = risk_levels.get('low') + if isinstance(low_data, dict): + low_data['aiSessionTerminate'] = False + tags = low_data.get('tags') + if isinstance(tags, dict): + tags.pop('deny', None) + if not tags: + low_data.pop('tags', None) + if _prune_level(low_data) or _level_is_only_default_low(low_data): + risk_levels.pop('low', None) + + if not risk_levels: + return {} + + return { + 'version': settings.get('version', AI_SETTINGS_VERSION), + 'riskLevels': risk_levels, + } + + +def apply_ai_setting_changes( + existing: Optional[Dict[str, Any]], + set_specs: Optional[List[str]], + unset_specs: Optional[List[str]], + params: KeeperParams, +) -> tuple: + """Merge CLI --set/--unset operations into KeeperAI DAG settings. + + Returns ``(settings_dict, warnings)`` where ``warnings`` is a list of user-facing + messages (e.g. silent conversions applied during save). + """ + settings = _ensure_ai_settings_dict(existing) + risk_levels = settings['riskLevels'] + user_id = _get_audit_user_id(params) + warnings: List[str] = [] + + unset_specs, set_specs, reconcile_warnings = _reconcile_set_unset_specs(unset_specs, set_specs) + warnings.extend(reconcile_warnings) + + for spec in unset_specs or []: + level, setting, value = parse_ai_setting_spec(spec) + if setting is None: + risk_levels.pop(level, None) + continue + + level_data = risk_levels.get(level) + if not isinstance(level_data, dict): + continue + + if setting == 'terminate': + level_data.pop('aiSessionTerminate', None) + else: + tags = level_data.get('tags') + if not isinstance(tags, dict): + continue + entries = tags.get(setting) + if not isinstance(entries, list): + continue + if value is None: + tags.pop(setting, None) + else: + tags[setting] = [e for e in entries if _parse_tag_name(e) != value] + if not tags.get(setting): + tags.pop(setting, None) + if not tags: + level_data.pop('tags', None) + + if _prune_level(level_data): + risk_levels.pop(level, None) + + for spec in set_specs or []: + level, setting, value = parse_ai_setting_spec(spec) + if setting is None: + raise ValueError(f'--set requires LEVEL.SETTING=VALUE (got "{spec}")') + if value is None: + raise ValueError(f'--set requires a value (got "{spec}")') + + level_data = _ensure_level_dict(risk_levels, level) + if setting == 'terminate': + terminate_value = _parse_terminate_value(value) + effective_value = False if level == 'low' else terminate_value + if level == 'low' and terminate_value: + if _LOW_TERMINATE_WARNING not in warnings: + warnings.append(_LOW_TERMINATE_WARNING) + if _existing_terminate_value(risk_levels, level) == effective_value: + continue + level_data['aiSessionTerminate'] = effective_value + continue + + tag_value = value.strip() + if not tag_value: + raise ValueError(f'--set tag value cannot be empty (got "{spec}")') + + entries = _get_tag_list(level_data, setting) + if not any(_parse_tag_name(e) == tag_value for e in entries): + action = 'added_to_allow' if setting == 'allow' else 'added_to_deny' + entries.append(_make_tag_entry(tag_value, action, user_id)) + + return _normalize_ai_settings_for_save(settings), warnings + + +def remove_resource_keeper_ai_settings( + params: KeeperParams, + resource_uid: str, + config_uid: Optional[str] = None +) -> Optional[bool]: + """Remove the ``ai_settings`` DATA edge (GSE_DELETION), restoring pre-AI DAG state. + + Web Vault treats a missing ``ai_settings`` edge as ``emptyKeeperAISettings`` in + memory. Writing ``{}`` or the empty template leaves a DATA edge and can break WV; + ``configure_resource`` does not clear ``keeperAiSettings`` when omitted — use graph-sync + DELETION like ``DagOperations.createDeletionEvent`` + ``dagPamLinkAddData``. + + Returns: + True if a DELETION edge was written, + None if ``ai_settings`` was already absent, + False on error. + """ + common = _resolve_resource_settings_inputs(params, resource_uid, {}, config_uid) + if common is None: + return False + record_key, resolved_config_uid = common + + return _delete_resource_data_edge_legacy( + params, resource_uid, resolved_config_uid, record_key, 'ai_settings') + + def print_keeper_ai_settings(params: KeeperParams, resource_uid: str, config_uid: Optional[str] = None): """ Print KeeperAI settings in a human-readable format. @@ -835,7 +1309,7 @@ def print_keeper_ai_settings(params: KeeperParams, resource_uid: str, config_uid """ settings = get_resource_keeper_ai_settings(params, resource_uid, config_uid) - if not settings: + if is_default_keeper_ai_settings(settings): print(f"{bcolors.WARNING}No KeeperAI settings found for resource {resource_uid}{bcolors.ENDC}") return diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 0314248b6..aaa857b64 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -108,6 +108,7 @@ def __init__(self): # self.register_command('stop', PAMConnectionStopCommand(), 'Stop Connection', 'x') self.register_command('edit', PAMConnectionEditCommand(), 'Edit Connection settings', 'e') self.register_command('jit', PAMConnectionJitCommand(), 'View/update JIT settings', 'j') + self.register_command('ai', PAMConnectionAiCommand(), 'View/update KeeperAI settings', 'a') self.default_verb = 'edit' @@ -3281,6 +3282,244 @@ def _do_set(self, params, kwargs, record_uid, config_uid): print(f'{bcolors.OKGREEN}JIT settings saved successfully.{bcolors.ENDC}') +_PAM_CONNECTION_AI_RESOURCE_TYPES = frozenset({ + 'pamMachine', + 'pamDatabase', + 'pamDirectory', + 'pamRemoteBrowser', +}) +_PAM_CONNECTION_AI_RESOURCE_TYPES_LABEL = ( + 'pamMachine, pamDatabase, pamDirectory, or pamRemoteBrowser' +) + + +class PAMConnectionAiCommand(Command): + parser = argparse.ArgumentParser(prog='pam connection ai') + parser.add_argument('record', type=str, action='store', + help=f'Record UID, path, or title ({_PAM_CONNECTION_AI_RESOURCE_TYPES_LABEL})') + parser.add_argument('--configuration', '-c', type=str, dest='configuration', action='store', default=None, + help='PAM Configuration UID or title (required when record is not linked yet or is linked to 2+ configs)') + parser.add_argument('--set', '-s', dest='set_values', action='append', default=None, + metavar='LEVEL.SETTING=VALUE', + help='Set a KeeperAI value (e.g. -s low.terminate=false -s low.allow=chmod -s "high.deny=kill -9")') + parser.add_argument('--unset', '-u', dest='unset_values', action='append', default=None, + metavar='LEVEL[.SETTING[=VALUE]]', + help='Unset KeeperAI values (e.g. -u low removes the level; -u low.allow removes all allow tags; ' + '-u low.allow=chmod removes one tag)') + parser.add_argument('--remove', dest='remove', action='store_true', default=False, + help='Remove all KeeperAI settings (ai_settings path only)') + parser.add_argument('--show', dest='show', action='store_true', default=False, + help='Show current KeeperAI settings') + + def get_parser(self): + return PAMConnectionAiCommand.parser + + def execute(self, params, **kwargs): + record_name = kwargs.get('record') + configuration = kwargs.get('configuration') + remove_flag = kwargs.get('remove', False) + show_flag = kwargs.get('show', False) + set_values = kwargs.get('set_values') or [] + unset_values = kwargs.get('unset_values') or [] + change_options_provided = bool(set_values or unset_values) + + if remove_flag and show_flag: + raise CommandError('', f'{bcolors.FAIL}--remove cannot be used with --show.{bcolors.ENDC}') + if remove_flag and change_options_provided: + raise CommandError('', f'{bcolors.FAIL}--remove cannot be used with any other option.{bcolors.ENDC}') + if show_flag and change_options_provided: + raise CommandError('', f'{bcolors.FAIL}--show cannot be used with --remove or any KeeperAI option.{bcolors.ENDC}') + if not remove_flag and not show_flag and not change_options_provided: + raise CommandError('', f'{bcolors.FAIL}Provide at least one --set/-s, --unset/-u, --remove, or --show.{bcolors.ENDC}') + + record = RecordMixin.resolve_single_record(params, record_name) + if record is None: + matches = [] + for uid in params.record_cache: + rec = vault.KeeperRecord.load(params, uid) + if rec and rec.record_type in _PAM_CONNECTION_AI_RESOURCE_TYPES \ + and rec.title.casefold() == record_name.casefold(): + matches.append(rec) + if len(matches) == 0: + raise CommandError('', f'{bcolors.FAIL}Record "{record_name}" not found.{bcolors.ENDC}') + if len(matches) > 1: + raise CommandError('', + f'{bcolors.FAIL}Multiple records match title "{record_name}"; use UID or path.{bcolors.ENDC}') + record = matches[0] + + if not isinstance(record, vault.TypedRecord) or \ + record.record_type not in _PAM_CONNECTION_AI_RESOURCE_TYPES: + raise CommandError('', + f'{bcolors.FAIL}KeeperAI settings are only supported on ' + f'{_PAM_CONNECTION_AI_RESOURCE_TYPES_LABEL} records.{bcolors.ENDC}') + + record_uid = record.record_uid + config_uid = self._resolve_config_uid( + params, record_uid, configuration, allow_unlinked_without_config=show_flag) + + if show_flag: + self._do_show(params, record, record_uid, config_uid) + elif remove_flag: + self._do_remove(params, record_uid, config_uid) + else: + self._do_apply(params, set_values, unset_values, record_uid, config_uid) + + def _resolve_config_uid(self, params, record_uid, configuration, allow_unlinked_without_config=False): + encrypted_session_token, encrypted_transmission_key, _ = get_keeper_tokens(params) + config_leafs = get_dag_leafs(params, encrypted_session_token, encrypted_transmission_key, record_uid) + config_uids = [leaf.get('value') for leaf in (config_leafs or []) if leaf.get('value')] + + if len(config_uids) == 0: + if not configuration: + if allow_unlinked_without_config: + return None + raise CommandError('', + f'{bcolors.FAIL}Record is not linked to a PAM Configuration; ' + f'specify --configuration|-c.{bcolors.ENDC}') + config_uid = self._resolve_configuration_record(params, configuration, linked_config_uids=None) + return config_uid + + if len(config_uids) == 1: + config_uid = config_uids[0] + if configuration: + specified_uid = self._resolve_configuration_record(params, configuration, linked_config_uids=config_uids) + if specified_uid != config_uid: + raise CommandError('', + f'{bcolors.FAIL}PAM Configuration "{configuration}" is not linked to this record.{bcolors.ENDC}') + return config_uid + + if not configuration: + raise CommandError('', + f'{bcolors.FAIL}Record is linked to multiple PAM Configurations; ' + f'specify --configuration|-c.{bcolors.ENDC}') + return self._resolve_configuration_record(params, configuration, linked_config_uids=config_uids) + + @staticmethod + def _resolve_configuration_record(params, configuration, linked_config_uids): + config_rec = RecordMixin.resolve_single_record(params, configuration) + if config_rec is None: + for uid in params.record_cache: + if params.record_cache[uid].get('version', 0) == 6: + r = vault.KeeperRecord.load(params, uid) + if r and r.title.casefold() == configuration.casefold(): + config_rec = r + break + if config_rec is None: + raise CommandError('', + f'{bcolors.FAIL}PAM Configuration "{configuration}" not found.{bcolors.ENDC}') + config_uid = config_rec.record_uid + if linked_config_uids is not None and config_uid not in linked_config_uids: + raise CommandError('', + f'{bcolors.FAIL}PAM Configuration "{configuration}" is not linked to this record.{bcolors.ENDC}') + return config_uid + + def _do_show(self, params, record, record_uid, config_uid): + from .pam_import.keeper_ai_settings import ( + get_resource_keeper_ai_settings, + is_default_keeper_ai_settings, + ) + + print(f'\nKeeperAI Settings for {bcolors.OKBLUE}{record.title}{bcolors.ENDC} ({record_uid}):') + if config_uid is None: + print(f' {bcolors.WARNING}No KeeperAI settings configured ' + f'(record is not linked to a PAM Configuration).{bcolors.ENDC}\n') + return + + settings = get_resource_keeper_ai_settings(params, record_uid, config_uid) + if is_default_keeper_ai_settings(settings): + print(f' {bcolors.WARNING}No KeeperAI settings configured.{bcolors.ENDC}\n') + return + + print(f' Version: {settings.get("version", "unknown")}') + risk_levels = settings.get('riskLevels', {}) + for level in ('critical', 'high', 'medium', 'low'): + level_data = risk_levels.get(level) + if not level_data: + continue + terminate = level_data.get('aiSessionTerminate', False) + tags = level_data.get('tags', {}) if isinstance(level_data.get('tags'), dict) else {} + allow_tags = tags.get('allow', []) + deny_tags = tags.get('deny', []) if level != 'low' else [] + level_color = { + 'critical': bcolors.FAIL, + 'high': bcolors.WARNING, + 'medium': bcolors.OKBLUE, + 'low': bcolors.OKGREEN, + }.get(level, bcolors.ENDC) + print(f'\n {level_color}{level.upper()}{bcolors.ENDC}:') + print(f' Terminate Session: {terminate}') + if allow_tags: + print(' Allow:') + for tag_item in allow_tags: + tag_name = tag_item.get('tag', '') if isinstance(tag_item, dict) else str(tag_item) + print(f' - {tag_name}') + if deny_tags: + print(' Deny:') + for tag_item in deny_tags: + tag_name = tag_item.get('tag', '') if isinstance(tag_item, dict) else str(tag_item) + print(f' - {tag_name}') + print() + + def _do_remove(self, params, record_uid, config_uid): + from .pam_import.keeper_ai_settings import remove_resource_keeper_ai_settings + + result = remove_resource_keeper_ai_settings(params, record_uid, config_uid) + if result is True: + print(f'{bcolors.OKGREEN}KeeperAI settings removed successfully.{bcolors.ENDC}') + elif result is None: + print(f'{bcolors.WARNING}No KeeperAI settings configured.{bcolors.ENDC}') + else: + raise CommandError('', f'{bcolors.FAIL}Failed to remove KeeperAI settings.{bcolors.ENDC}') + + def _do_apply(self, params, set_values, unset_values, record_uid, config_uid): + from .pam_import.keeper_ai_settings import ( + apply_ai_setting_changes, + dedupe_ai_cli_option_specs, + get_resource_keeper_ai_settings, + is_default_keeper_ai_settings, + refresh_link_to_config_to_latest, + refresh_meta_to_latest, + remove_resource_keeper_ai_settings, + set_resource_keeper_ai_settings, + ) + + set_values, set_dup_warnings = dedupe_ai_cli_option_specs(set_values, '--set/-s') + unset_values, unset_dup_warnings = dedupe_ai_cli_option_specs(unset_values, '--unset/-u') + for warning in set_dup_warnings + unset_dup_warnings: + print(f'{bcolors.WARNING}Warning: {warning}{bcolors.ENDC}') + + existing = get_resource_keeper_ai_settings( + params, record_uid, config_uid, quiet_if_missing_vertex=True) + try: + ai_dict, warnings = apply_ai_setting_changes(existing, set_values, unset_values, params) + except ValueError as err: + raise CommandError('', f'{bcolors.FAIL}{err}{bcolors.ENDC}') from err + + for warning in warnings: + print(f'{bcolors.WARNING}Warning: {warning}{bcolors.ENDC}') + + if not ai_dict: + # e.g. -s low.terminate=true|false when nothing is configured: defaults to {}. + if is_default_keeper_ai_settings(existing): + return + result = remove_resource_keeper_ai_settings(params, record_uid, config_uid) + if result is False: + raise CommandError('', f'{bcolors.FAIL}Failed to remove KeeperAI settings.{bcolors.ENDC}') + if result is None: + print(f'{bcolors.WARNING}No KeeperAI settings configured.{bcolors.ENDC}') + else: + print(f'{bcolors.OKGREEN}KeeperAI settings removed successfully.{bcolors.ENDC}') + return + + ok = set_resource_keeper_ai_settings(params, record_uid, ai_dict, config_uid) + if not ok: + raise CommandError('', f'{bcolors.FAIL}Failed to save KeeperAI settings.{bcolors.ENDC}') + + refresh_meta_to_latest(params, record_uid, config_uid) + refresh_link_to_config_to_latest(params, record_uid, config_uid) + print(f'{bcolors.OKGREEN}KeeperAI settings saved successfully.{bcolors.ENDC}') + + class PAMRbiEditCommand(Command): choices = ['on', 'off', 'default'] parser = argparse.ArgumentParser(prog='pam rbi edit') diff --git a/unit-tests/pam/test_dag_layer_b_migration.py b/unit-tests/pam/test_dag_layer_b_migration.py index b7be169d1..3ba9090c7 100644 --- a/unit-tests/pam/test_dag_layer_b_migration.py +++ b/unit-tests/pam/test_dag_layer_b_migration.py @@ -96,6 +96,11 @@ def _capture(params, rq): assert rq.keeperAiSettings == b'CIPHER_BYTES' # Critical: must NOT be set on jitSettings field assert rq.jitSettings == b'' + assert rq.meta == json.dumps({ + 'version': 1, + 'allowedSettings': {}, + 'rotateOnTermination': False, + }).encode() def test_happy_path_bundles_current_meta_so_krouter_persists_ai_edge(self): """Regression: krouter's configure_resource only writes a settings edge @@ -125,8 +130,30 @@ def _capture(params, rq): # the ai_settings edge. Without it the write is a silent no-op. assert rq.meta == json.dumps(meta_dict).encode() # meta is read from the resource's current 'meta' DATA edge. + assert meta_mock.call_args.kwargs.get('quiet_if_missing_vertex') is True + + def test_bootstraps_default_meta_when_resource_not_in_dag_yet(self): + captured = {} + + def _capture(params, rq): + captured['rq'] = rq + return None + + with _patch_inputs(), \ + patch.object(ai_mod, 'encrypt_aes', return_value=b'CIPHER_BYTES'), \ + patch.object(ai_mod, 'get_resource_settings', return_value=None) as meta_mock, \ + patch('keepercommander.commands.pam.router_helper.router_configure_resource', side_effect=_capture): + ok = ai_mod.set_resource_keeper_ai_settings( + _mock_params(), RESOURCE_UID_STR, {'riskLevels': {'high': {}}}, config_uid=CONFIG_UID_STR + ) + assert ok is True + rq = captured['rq'] + assert rq.meta == json.dumps({ + 'version': 1, + 'allowedSettings': {}, + 'rotateOnTermination': False, + }).encode() meta_mock.assert_called_once() - assert meta_mock.call_args.args[2] == 'meta' def test_permission_denied_with_fallback_enabled_calls_legacy(self): legacy_called = {'count': 0} diff --git a/unit-tests/pam/test_pam_connection_ai.py b/unit-tests/pam/test_pam_connection_ai.py new file mode 100644 index 000000000..ea4ad03d1 --- /dev/null +++ b/unit-tests/pam/test_pam_connection_ai.py @@ -0,0 +1,288 @@ +import pytest +from unittest.mock import patch + +from keepercommander.commands.pam_import.keeper_ai_settings import ( + apply_ai_setting_changes, + dedupe_ai_cli_option_specs, + empty_keeper_ai_settings_dict, + is_default_keeper_ai_settings, + parse_ai_setting_spec, +) + + +class _Params: + account_uid_bytes = b'\x01\x02\x03' + user = 'user@example.com' + + +def test_dedupe_ai_cli_option_specs_reports_counts(): + specs, warnings = dedupe_ai_cli_option_specs( + ['critical.allow=chmod', 'high.allow=wget', 'critical.allow=chmod', 'critical.allow=chmod'], + '--set/-s', + ) + assert specs == ['critical.allow=chmod', 'high.allow=wget'] + assert warnings == ['duplicate --set/-s ignored: critical.allow=chmod (3x)'] + + +def test_dedupe_ai_cli_option_specs_no_warnings_when_unique(): + specs, warnings = dedupe_ai_cli_option_specs(['low'], '--unset/-u') + assert specs == ['low'] + assert warnings == [] + + +def test_is_default_keeper_ai_settings(): + assert is_default_keeper_ai_settings(None) + assert is_default_keeper_ai_settings({}) + assert is_default_keeper_ai_settings(empty_keeper_ai_settings_dict()) + assert not is_default_keeper_ai_settings({ + 'version': 'v1.0.0', + 'riskLevels': {'high': {'aiSessionTerminate': True}}, + }) + + +def test_remove_resource_keeper_ai_settings_emits_deletion(): + params = _Params() + captured = {} + + def _capture(_params, _resource_uid, _config_uid, _record_key, dag_path): + captured['dag_path'] = dag_path + return True + + from keepercommander.commands.pam_import import keeper_ai_settings as ai_mod + with patch.object(ai_mod, '_resolve_resource_settings_inputs', return_value=(b'key', 'config')), \ + patch.object(ai_mod, '_delete_resource_data_edge_legacy', side_effect=_capture): + result = ai_mod.remove_resource_keeper_ai_settings(params, 'resource', 'config') + assert result is True + assert captured['dag_path'] == 'ai_settings' + + +def test_remove_resource_keeper_ai_settings_already_absent(): + params = _Params() + + from keepercommander.commands.pam_import import keeper_ai_settings as ai_mod + with patch.object(ai_mod, '_resolve_resource_settings_inputs', return_value=(b'key', 'config')), \ + patch.object(ai_mod, '_delete_resource_data_edge_legacy', return_value=None): + result = ai_mod.remove_resource_keeper_ai_settings(params, 'resource', 'config') + assert result is None + + +def test_parse_ai_setting_spec_set(): + assert parse_ai_setting_spec('low.terminate=false') == ('low', 'terminate', 'false') + assert parse_ai_setting_spec('high.allow=chmod') == ('high', 'allow', 'chmod') + assert parse_ai_setting_spec('critical.deny=kill -9') == ('critical', 'deny', 'kill -9') + + +def test_parse_ai_setting_spec_unset(): + assert parse_ai_setting_spec('low') == ('low', None, None) + assert parse_ai_setting_spec('medium.allow') == ('medium', 'allow', None) + assert parse_ai_setting_spec('high.deny=wget') == ('high', 'deny', 'wget') + + +def test_parse_ai_setting_spec_rejects_low_deny(): + with pytest.raises(ValueError, match='deny is not supported'): + parse_ai_setting_spec('low.deny=bash') + + +def test_parse_ai_setting_spec_missing_value_suggests_unset(): + with pytest.raises(ValueError, match='--unset\\|-u'): + parse_ai_setting_spec('high.terminate=') + + +def test_apply_ai_setting_changes_merge_without_default_low_stub(): + params = _Params() + result, warnings = apply_ai_setting_changes(None, ['high.allow=chmod'], None, params) + + assert warnings == [] + assert result['version'] == 'v1.0.0' + assert result['riskLevels']['high']['tags']['allow'][0]['tag'] == 'chmod' + assert 'low' not in result['riskLevels'] + + +def test_apply_ai_setting_changes_tag_upsert_no_duplicate(): + params = _Params() + existing = { + 'version': 'v1.0.0', + 'riskLevels': { + 'high': { + 'tags': {'allow': [{'tag': 'chmod', 'auditLog': [{'action': 'added_to_allow'}]}]}, + }, + }, + } + + result, warnings = apply_ai_setting_changes(existing, ['high.allow=chmod'], None, params) + + assert warnings == [] + assert len(result['riskLevels']['high']['tags']['allow']) == 1 + + +def test_apply_ai_setting_changes_unset_level_and_tag(): + params = _Params() + existing = { + 'version': 'v1.0.0', + 'riskLevels': { + 'critical': { + 'aiSessionTerminate': True, + 'tags': {'allow': [{'tag': 'mount', 'auditLog': []}]}, + }, + 'low': { + 'aiSessionTerminate': False, + 'tags': {'allow': [{'tag': 'chmod', 'auditLog': []}, {'tag': 'wget', 'auditLog': []}]}, + }, + }, + } + + result, warnings = apply_ai_setting_changes(existing, None, ['critical', 'low.allow=chmod'], params) + + assert warnings == [] + assert 'critical' not in result['riskLevels'] + low_allow = [t['tag'] for t in result['riskLevels']['low']['tags']['allow']] + assert low_allow == ['wget'] + assert result['riskLevels']['low']['aiSessionTerminate'] is False + + +def test_apply_ai_setting_changes_set_terminate_rejects_invalid_value(): + params = _Params() + with pytest.raises(ValueError, match='invalid terminate value "truez"'): + apply_ai_setting_changes(None, ['high.terminate=truez'], None, params) + + +def test_apply_ai_setting_changes_set_terminate_accepts_true_false_only(): + params = _Params() + result_true, _ = apply_ai_setting_changes(None, ['high.terminate=true'], None, params) + assert result_true['riskLevels']['high']['aiSessionTerminate'] is True + + result_false, _ = apply_ai_setting_changes(result_true, ['medium.terminate=FALSE'], None, params) + assert result_false['riskLevels']['medium']['aiSessionTerminate'] is False + + +def test_apply_ai_setting_changes_set_terminate(): + params = _Params() + result, warnings = apply_ai_setting_changes(None, ['medium.terminate=true'], None, params) + assert warnings == [] + assert result['riskLevels']['medium']['aiSessionTerminate'] is True + + +def test_apply_ai_setting_changes_low_terminate_true_warns_and_stays_false(): + params = _Params() + result, warnings = apply_ai_setting_changes(None, ['low.terminate=true'], None, params) + + assert warnings == ['risk level low.terminate always defaults to false.'] + assert result == {} + + +def test_apply_ai_setting_changes_low_terminate_false_is_noop_when_empty(): + params = _Params() + result, warnings = apply_ai_setting_changes(None, ['low.terminate=false'], None, params) + + assert warnings == [] + assert result == {} + assert is_default_keeper_ai_settings(None) + + +def test_apply_ai_setting_changes_paired_unset_set_tag_preserves_audit_log(): + params = _Params() + original_audit = [{'date': 1, 'userId': 'u1', 'action': 'added_to_allow'}] + existing = { + 'version': 'v1.0.0', + 'riskLevels': { + 'high': { + 'tags': {'allow': [{'tag': 'chmod', 'auditLog': original_audit}]}, + }, + }, + } + + result, warnings = apply_ai_setting_changes( + existing, + ['high.allow=chmod'], + ['high.allow=chmod'], + params, + ) + + assert warnings == ['--set/-s overrides --unset/-u: high.allow=chmod'] + entry = result['riskLevels']['high']['tags']['allow'][0] + assert entry['tag'] == 'chmod' + assert entry['auditLog'] == original_audit + + +def test_apply_ai_setting_changes_paired_unset_set_terminate_noop_when_already_true(): + params = _Params() + existing = { + 'version': 'v1.0.0', + 'riskLevels': { + 'high': {'aiSessionTerminate': True}, + }, + } + + result, warnings = apply_ai_setting_changes( + existing, + ['high.terminate=true'], + ['high.terminate'], + params, + ) + + assert warnings == [] + assert result['riskLevels']['high']['aiSessionTerminate'] is True + + +def test_apply_ai_setting_changes_mirrored_unset_set_different_tag_values_warns(): + params = _Params() + result, warnings = apply_ai_setting_changes( + None, + ['critical.allow=chmod'], + ['critical.allow=chmo'], + params, + ) + + assert warnings == [ + '--set/-s overrides --unset/-u: critical.allow=chmo (mirrors -s critical.allow=chmod)', + ] + assert result['riskLevels']['critical']['tags']['allow'][0]['tag'] == 'chmod' + + +def test_apply_ai_setting_changes_partial_unset_not_mirrored_with_set(): + params = _Params() + existing = { + 'version': 'v1.0.0', + 'riskLevels': { + 'critical': { + 'tags': {'allow': [{'tag': 'wget', 'auditLog': []}]}, + }, + }, + } + result, warnings = apply_ai_setting_changes( + existing, + ['critical.allow=chmod'], + ['critical.allow'], + params, + ) + + assert warnings == [] + tags = [t['tag'] for t in result['riskLevels']['critical']['tags']['allow']] + assert tags == ['chmod'] + + +def test_apply_ai_setting_changes_unset_all_leaves_empty(): + params = _Params() + existing = { + 'version': 'v1.0.0', + 'riskLevels': { + 'low': { + 'aiSessionTerminate': False, + 'tags': {'allow': [{'tag': 'chmod', 'auditLog': []}]}, + }, + }, + } + + result, warnings = apply_ai_setting_changes(existing, None, ['low'], params) + + assert warnings == [] + assert result == {} + + +def test_pam_connection_ai_resource_types_include_rbi(): + from keepercommander.commands.tunnel_and_connections import _PAM_CONNECTION_AI_RESOURCE_TYPES + + assert 'pamRemoteBrowser' in _PAM_CONNECTION_AI_RESOURCE_TYPES + assert 'pamMachine' in _PAM_CONNECTION_AI_RESOURCE_TYPES + assert 'pamDatabase' in _PAM_CONNECTION_AI_RESOURCE_TYPES + assert 'pamDirectory' in _PAM_CONNECTION_AI_RESOURCE_TYPES From c2e4c66efaeae00fe7c9a01fb53cece539942c5b Mon Sep 17 00:00:00 2001 From: John Walstra Date: Thu, 18 Jun 2026 15:13:28 -0500 Subject: [PATCH 11/18] DR-1280 Fix SaaS profile for `pam rotation edit` command. --- keepercommander/commands/discoveryrotation.py | 169 +++++++++++++++++- keepercommander/commands/pam_debug/info.py | 5 + keepercommander/commands/pam_saas/remove.py | 6 +- keepercommander/commands/pam_saas/set.py | 68 +++++-- .../tunnel/port_forward/TunnelGraph.py | 62 ++++++- 5 files changed, 284 insertions(+), 26 deletions(-) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index d8797f141..ffc22f5e0 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -18,6 +18,7 @@ import time from datetime import datetime from urllib.parse import urlparse, urlunparse +from typing import Optional, List import requests from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -43,7 +44,7 @@ from .pam.router_helper import router_send_action_to_gateway, print_router_response, \ router_get_connected_gateways, router_set_record_rotation_information, router_get_rotation_schedules, \ - get_router_url + get_router_url, RouterResponseError from .record_edit import RecordEditMixin from .helpers.timeout import parse_timeout from .email_commands import find_email_config_record, load_email_config_from_record, update_oauth_tokens_in_record @@ -93,6 +94,7 @@ PAMUniversalSyncConfigCommand, PAMUniversalSyncRunCommand ) +from ..discovery_common.types import UserAcl, UserAclRotationSettings # These characters are based on the Vault PAM_DEFAULT_SPECIAL_CHAR = '''!@#$%^?();',.=+[]<>{}-_/\\*&:"`~|''' @@ -398,6 +400,8 @@ class PAMCreateRecordRotationCommand(Command): parser.add_argument('--force', '-f', dest='force', action='store_true', help='Do not ask for confirmation') parser.add_argument('--config', '-c', required=False, dest='config', action='store', help='UID or path of the configuration record.') + parser.add_argument('--gateway', '-g', required=False, dest='gateway', action='store', + help='UID or name of the gateway') parser.add_argument('--iam-aad-config', '-iac', dest='iam_aad_config_uid', action='store', help='UID of a PAM Configuration. Used for an IAM or Azure AD user in place of --resource.') parser.add_argument('--rotation-profile', '-rp', dest='rotation_profile', action='store', @@ -481,6 +485,158 @@ def config_resource(_dag, target_record, target_config_uid, silent=None): if not silent: _dag.print_tunneling_config(target_record.record_uid, config_uid=target_config_uid) + def config_saas_user(_dag, target_record, saas_config_uid: str): + + saas_config_record = vault.KeeperRecord.load(params, saas_config_uid) # type: Optional[TypedRecord] + if saas_config_record is None: + raise CommandError('', 'The SaaS configuration record does not exists.') + + if saas_config_record.record_type not in ["login", "saasConfiguration"]: + raise CommandError('', + f"The SaaS configuration record is not a SaaS configuration record: " + f"{saas_config_record.record_type}") + + plugin_name_field = next((x for x in saas_config_record.custom if x.label == "SaaS Type"), None) + if plugin_name_field is None: + raise CommandError('', + f"The SaaS configuration record is missing the custom field " + "label 'SaaS Type'") + + current_record_rotation = params.record_rotation_cache.get(target_record.record_uid) + schedule_only = kwargs.get('schedule_only') + + # Handle schedule-only operations first to avoid unnecessary resource validation + if schedule_only: + if kwargs.get('folder_name') and ( + not current_record_rotation or current_record_rotation.get('disabled')): + skipped_records.append([target_record.record_uid, target_record.title, + 'Rotation not enabled', 'Skipped']) + return + if not current_record_rotation: + skipped_records.append([target_record.record_uid, target_record.title, + 'No rotation info', 'Skipped']) + return + + else: + # This looks like it is user to remove the user from another PAM graph. + # To prevent two graphs from using the same record. + if _dag and not _dag.linking_dag.has_graph: + _dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, config_uid, + transmission_key=transmission_key) + if not _dag or not _dag.linking_dag.has_graph: + _dag.edit_tunneling_config(rotation=True) + old_dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, + target_record.record_uid, transmission_key=transmission_key) + if old_dag.linking_dag.has_graph and old_dag.record.record_uid != config_uid: + old_dag.remove_from_dag(target_record.record_uid) + + # 1. PAM Configuration UID + record_config_uid = _dag.record.record_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( + [target_record.record_uid, target_record.title, 'PAM Configuration was deleted', + 'Specify a configuration UID parameter [--config]']) + return + if not isinstance(pc, vault.TypedRecord) or pc.version != 6: + skipped_records.append( + [target_record.record_uid, target_record.title, 'PAM Configuration is invalid', + 'Specify a configuration UID parameter [--config]']) + return + record_pam_config = pc + else: + skipped_records.append( + [target_record.record_uid, target_record.title, 'No current PAM Configuration', + 'Specify a configuration UID parameter [--config]']) + return + + # 2. Schedule + record_schedule_data = schedule_data + if record_schedule_data is None: + if current_record_rotation and not schedule_config: + 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, + target_record.record_key) + else: + pwd_complexity_rule_list_encrypted = b'' + + disabled = False + # 5. Enable rotation + if kwargs.get('enable'): + _dag.set_resource_allowed(config_uid, rotation=True, is_config=True) + elif kwargs.get('disable'): + _dag.set_resource_allowed(config_uid, rotation=False, is_config=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, + target_record.record_key) + c = json.loads(decrypted_complexity.decode()) + complexity = f"{c.get('length', 0)}," \ + f"{c.get('caps', 0)}," \ + f"{c.get('lowercase', 0)}," \ + f"{c.get('digits', 0)}," \ + f"{c.get('special', 0)}," \ + f"{c.get('specialChars', PAM_DEFAULT_SPECIAL_CHAR)}" + except: + pass + valid_records.append( + [target_record.record_uid, target_record.title, not disabled, record_config_uid, None, + schedule, complexity]) + + # 6. Construct Request object for SaaS + rq = router_pb2.RouterRecordRotationRequest() + if current_record_rotation: + logging.debug("has current record gas rotation settings") + rq.revision = current_record_rotation.get('revision', 1) + else: + logging.debug("record has not rotation settings") + rq.recordUid = utils.base64_url_decode(target_record.record_uid) + rq.configurationUid = utils.base64_url_decode(record_config_uid) + rq.schedule = json.dumps(record_schedule_data) if record_schedule_data else '' + rq.pwdComplexity = pwd_complexity_rule_list_encrypted + rq.disabled = disabled + rq.noop = True + rq.resourceUid = b'' + r_requests.append(rq) + + if not _dag.link_saas_user(user_uid=target_record.record_uid, + saas_config_record=saas_config_record, + pam_config_record_type=pam_config.record_type): + raise CommandError('',"Could not connect SaaS user in the graph.") + + params.sync_data = True + def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): current_record_rotation = params.record_rotation_cache.get(target_record.record_uid) schedule_only = kwargs.get('schedule_only') @@ -1191,15 +1347,14 @@ def add_folders(sub_folder): # type: (BaseFolderNode) -> None raise CommandError('', 'General rotation profile requires --resource to be specified.') config_user(tmp_dag, _record, resource_uid, config_uid, silent=kwargs.get('silent')) elif rotation_profile == 'saas': + saas_config_uid = kwargs.get("saas_config_uid") # type: Optional[str] if saas_config_uid is None: raise CommandError('', 'SaaS rotation profile requires ' '--saas-config-uid to be specified.') - saas_command = PAMActionSaasSetCommand() - saas_command.execute(params, - user_uid=_record.record_uid, - pam_config_uid=config_uid, - config_record_uid=saas_config_uid) + + config_saas_user(_dag=tmp_dag, target_record=_record, saas_config_uid=saas_config_uid) + params.sync_data = True # NB! --folder=UID without --iam-aad-config, or --schedule-only converts to General rotation elif iam_aad_config_uid: @@ -1235,6 +1390,8 @@ def add_folders(sub_folder): # type: (BaseFolderNode) -> None except KeeperApiError as kae: logging.warning('Record "%s": Set rotation error "%s": %s', record_uid, kae.result_code, kae.message) + except RouterResponseError as rre: + logging.warning('Record "%s": Set rotation error: %s', record_uid, rre) params.sync_data = True diff --git a/keepercommander/commands/pam_debug/info.py b/keepercommander/commands/pam_debug/info.py index 09d9bcfa6..b1f2b648a 100644 --- a/keepercommander/commands/pam_debug/info.py +++ b/keepercommander/commands/pam_debug/info.py @@ -185,6 +185,11 @@ def _print_field(f): else: print(f" . Is {self._bl('Remote user')}") + if acl_content.is_iam_user: + print(f" . Is an IAM User (user belonging to configuration)") + else: + print(f" . Is NOT an IAM User (user belonging to configuration)") + if acl_content.rotation_settings is None: print(f"{bcolors.FAIL} . There are no rotation settings!{bcolors.ENDC}") else: diff --git a/keepercommander/commands/pam_saas/remove.py b/keepercommander/commands/pam_saas/remove.py index f4a8fb7d6..f763468c6 100644 --- a/keepercommander/commands/pam_saas/remove.py +++ b/keepercommander/commands/pam_saas/remove.py @@ -16,10 +16,14 @@ class PAMActionSaasRemoveCommand(PAMGatewayActionDiscoverCommandBase): parser = argparse.ArgumentParser(prog='pam action saas remove') + parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', + help='Gateway name of UID.') + parser.add_argument('--configuration-uid', required=False, dest='configuration_uid', + action='store', help='PAM configuration UID, if gateway has multiple.') parser.add_argument('--user-uid', '-u', required=True, dest='user_uid', action='store', help='The UID of the User record') parser.add_argument('--resource-uid', '-r', required=False, dest='resource_uid', action='store', - help='The UID of the Resource record, if needed.') + help='The UID of the Resource record, if needed. DEPRECATED') def get_parser(self): return PAMActionSaasRemoveCommand.parser diff --git a/keepercommander/commands/pam_saas/set.py b/keepercommander/commands/pam_saas/set.py index be9976924..bb3b97dc6 100644 --- a/keepercommander/commands/pam_saas/set.py +++ b/keepercommander/commands/pam_saas/set.py @@ -1,11 +1,12 @@ from __future__ import annotations import argparse -from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext +from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg +from ...display import bcolors from ... import vault from . import get_plugins_map from ...discovery_common.record_link import RecordLink -from ...discovery_common.constants import PAM_USER -from ...discovery_common.types import UserAclRotationSettings +from ...discovery_common.constants import PAM_USER, PAM_AWS_CONFIGURATION +from ...discovery_common.types import UserAclRotationSettings, UserAcl from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: @@ -16,9 +17,13 @@ class PAMActionSaasSetCommand(PAMGatewayActionDiscoverCommandBase): parser = argparse.ArgumentParser(prog='pam action saas set') + parser.add_argument('--gateway', '-g', required=False, dest='gateway', action='store', + help='Gateway name of UID.') + parser.add_argument('--configuration-uid', required=False, dest='configuration_uid', + action='store', help='PAM configuration UID, if gateway has multiple.') parser.add_argument('--user-uid', '-u', required=True, dest='user_uid', action='store', help='The UID of the User record') - parser.add_argument('--config-record-uid', '-c', required=True, dest='config_record_uid', + parser.add_argument('--config-record-uid', '-sc', required=True, dest='config_record_uid', action='store', help='The UID of the record that has SaaS configuration') def get_parser(self): @@ -26,9 +31,10 @@ def get_parser(self): def execute(self, params: KeeperParams, **kwargs): - user_uid = kwargs.get("user_uid") # type: str - resource_uid = kwargs.get("resource_uid") # type: str - config_record_uid = kwargs.get("config_record_uid") # type: str + user_uid = kwargs.get("user_uid") # type: Optional[str] + config_record_uid = kwargs.get("config_record_uid") # type: Optional[str] + gateway = kwargs.get("gateway") # type: Optional[str] + configuration_uid = kwargs.get('configuration_uid') # type Optional[str] print("") @@ -43,12 +49,36 @@ def execute(self, params: KeeperParams, **kwargs): print(self._f("The user record is not a PAM User.")) return - record_rotation = params.record_rotation_cache.get(user_record.record_uid) - if record_rotation is not None: - configuration_uid = record_rotation.get("configuration_uid") - else: - print(self._f("The user record does not have any rotation settings.")) - return + # If the configration UID is not set, then try to get it from the rotation settings. + # This might be blank due to rotation setting being stored on the graph and not in protobuf. + # If rotation settings are blank, require the gateway UID to be set. + if configuration_uid is None: + + record_rotation = params.record_rotation_cache.get(user_record.record_uid) + if record_rotation is not None: + configuration_uid = record_rotation.get("configuration_uid") + else: + # If the configuration UID is not passed in, and we cannot get it from the protobug rotation settings, + # get it from the gateway. + # This requires the user used the --gateway params. + + if gateway is None: + print(self._f("Cannot find the PAM configuration for the user record. " + "Please add the --gateway and/or --configuration-uid to the command parameters.")) + return + + try: + gateway_context = GatewayContext.from_gateway(params=params, + gateway=gateway) + if gateway_context is None: + print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") + return + + configuration_uid = gateway_context.configuration_uid + + except MultiConfigurationException as err: + multi_conf_msg(gateway, err) + return if configuration_uid is None: print(self._f("The user record does not have the configuration record set in the rotation settings.")) @@ -114,11 +144,13 @@ def execute(self, params: KeeperParams, **kwargs): record_link = RecordLink(record=gateway_context.configuration, params=params, fail_on_corrupt=False) acl = record_link.get_acl(user_uid, gateway_context.configuration_uid) if acl is None: - if resource_uid is not None: - print(self._f("There is no relationship between the user and the resource record.")) - else: - print(self._f("There is no relationship between the user and the configuration record.")) - return + acl = UserAcl.default() + + # TODO: Un-hack this, dislike hardcoding this stuff. + # HACK - If the plugin is the AWS Access Key, the user is an IAM user if the configuration if AWS. + # So it does belong to the configuration. + if plugin_name == "AWS Access Key" and config_record.record_type == PAM_AWS_CONFIGURATION: + acl.belongs_to = True if acl.rotation_settings is None: acl.rotation_settings = UserAclRotationSettings() diff --git a/keepercommander/commands/tunnel/port_forward/TunnelGraph.py b/keepercommander/commands/tunnel/port_forward/TunnelGraph.py index 4fb530dc7..ac89cb1d0 100644 --- a/keepercommander/commands/tunnel/port_forward/TunnelGraph.py +++ b/keepercommander/commands/tunnel/port_forward/TunnelGraph.py @@ -5,8 +5,9 @@ from ....keeper_dag.connection.commander import Connection from ....keeper_dag.types import RefType, PamGraphId from ....keeper_dag.vertex import DAGVertex +from ....discovery_common.types import UserAclRotationSettings, UserAcl from ....display import bcolors -from ....vault import PasswordRecord +from ....vault import PasswordRecord, TypedRecord from ....proto import pam_pb2, router_pb2 from ...pam._layer_b import should_fallback_on_layer_b_error from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -569,6 +570,65 @@ def link_user(self, user_uid, source_vertex: DAGVertex, is_admin=None, belongs_t user_vertex.belongs_to(source_vertex, EdgeType.ACL, content=content) self.linking_dag.save() + def link_saas_user(self, user_uid: str, saas_config_record: TypedRecord, pam_config_record_type: str) -> bool: + + logging.debug("linking saas user") + + if not self.linking_dag.has_graph: + logging.error("linking graph is empty") + return False + + configuration_vertex = self.linking_dag.get_root + if configuration_vertex is None: + logging.error("cannot find configuration vertex,") + return False + + user_vertex = self.linking_dag.get_vertex(user_uid) + if user_vertex is None: + logging.debug("creating vertex for user") + user_vertex = self.linking_dag.add_vertex(uid=user_uid, vertex_type=RefType.PAM_USER) + + acl_edge = user_vertex.get_edge(vertex=configuration_vertex, edge_type=EdgeType.ACL) + if acl_edge is not None: + logging.debug("have an existing ACL edge between the user and configuration") + acl = acl_edge.content_as_object(UserAcl) + else: + logging.debug("do NOT have an ACL edge between the user and configuration") + acl = UserAcl.default() + + plugin_field = saas_config_record.get_typed_field('text', 'SaaS Type') + if plugin_field is None: + logging.error("cannot get the plugin name from the SaaS configuration") + return False + + plugin_name = plugin_field.value[0] + + if acl is not None and acl.rotation_settings is None: + acl.rotation_settings = UserAclRotationSettings() + + logging.debug(f"plugin name is {plugin_name}") + logging.debug(f"pam configuration record type is {pam_config_record_type}") + + if plugin_name == "AWS Access Key" and pam_config_record_type == "pamAwsConfiguration": + logging.debug("pam configuration is AWS, the user belongs to the configuration") + acl.belongs_to = True + else: + acl.belongs_to = False + + acl.rotation_settings.noop = True + acl.is_iam_user = False + acl.is_admin = False + acl.rotation_settings.saas_record_uid_list = [saas_config_record.record_uid] + + user_vertex.belongs_to(vertex=configuration_vertex, + edge_type=EdgeType.ACL, + content=acl, + is_encrypted=False) + + self.linking_dag.save() + + return True + def get_all_admins(self): if not self.linking_dag.has_graph: return [] From 731a00d22b1876fb54264d7c19274e7d3b84a983 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Fri, 19 Jun 2026 09:37:29 -0700 Subject: [PATCH 12/18] Transfer enterprise to another MSP. KC-1024 (#2027) --- keepercommander/api.py | 17 +- keepercommander/commands/base.py | 4 +- keepercommander/commands/mc_transfer.py | 372 +++++++++++++++++++++++ keepercommander/proto/MCTransfer_pb2.py | 51 ++++ keepercommander/proto/MCTransfer_pb2.pyi | 96 ++++++ 5 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 keepercommander/commands/mc_transfer.py create mode 100644 keepercommander/proto/MCTransfer_pb2.py create mode 100644 keepercommander/proto/MCTransfer_pb2.pyi diff --git a/keepercommander/api.py b/keepercommander/api.py index 1b1f22a58..7710f9201 100644 --- a/keepercommander/api.py +++ b/keepercommander/api.py @@ -838,6 +838,8 @@ def communicate_rest(params, request, endpoint, *, rs_type=None, payload_version return rs elif isinstance(rs, dict): kae = KeeperApiError(rs['error'], rs['message']) + kae.additional_info = rs.get('additional_info') + if kae.result_code == 'session_token_expired': params.session_token = None raise kae @@ -1507,7 +1509,20 @@ def login_and_get_mc_params_login_v3(params: KeeperParams, mc_id): mc_params.session_token = loginv3.CommonHelperMethods.bytes_to_url_safe_str(resp.encryptedSessionToken) mc_params.msp_tree_key = params.enterprise['unencrypted_tree_key'] - tree_key = crypto.decrypt_aes_v2(utils.base64_url_decode(resp.encryptedTreeKey), mc_params.msp_tree_key) + encrypted_tree_key = utils.base64_url_decode(resp.encryptedTreeKey) + key_type = resp.keyTypeId + if key_type == 1 or len(encrypted_tree_key) > 200: # RSA + private_key_bytes = utils.base64_url_decode(params.enterprise['keys']['rsa_encrypted_private_key']) + private_key_bytes = crypto.decrypt_aes_v2(private_key_bytes, mc_params.msp_tree_key) + private_key = crypto.load_rsa_private_key(private_key_bytes) + tree_key = crypto.decrypt_rsa(encrypted_tree_key, private_key) + elif key_type == 4 or len(encrypted_tree_key) == 125: # EC + private_key_bytes = utils.base64_url_decode(params.enterprise['keys']['ecc_encrypted_private_key']) + private_key_bytes = crypto.decrypt_aes_v2(private_key_bytes, mc_params.msp_tree_key) + private_key = crypto.load_ec_private_key(private_key_bytes) + tree_key = crypto.decrypt_ec(encrypted_tree_key, private_key) + else: + tree_key = crypto.decrypt_aes_v2(encrypted_tree_key, mc_params.msp_tree_key) sync_down(mc_params) query_enterprise(mc_params, True, tree_key=tree_key) diff --git a/keepercommander/commands/base.py b/keepercommander/commands/base.py index ae81457dc..cba03f2dc 100644 --- a/keepercommander/commands/base.py +++ b/keepercommander/commands/base.py @@ -207,10 +207,12 @@ def register_enterprise_commands(commands, aliases, command_info): commands['risk-management'] = RiskManagementReportCommand() command_info['risk-management'] = 'Risk Management Reports' aliases['rmd'] = 'risk-management' - from . import device_management device_management.register_enterprise_commands(commands) device_management.register_enterprise_command_info(aliases, command_info) + from . import mc_transfer + commands['mc-transfer'] = mc_transfer.McTransferCommand() + command_info['mc-transfer'] = 'Managed Company Transfer' from . import sso_cloud sso_cloud.register_commands(commands) diff --git a/keepercommander/commands/mc_transfer.py b/keepercommander/commands/mc_transfer.py new file mode 100644 index 000000000..41b47af77 --- /dev/null +++ b/keepercommander/commands/mc_transfer.py @@ -0,0 +1,372 @@ +import argparse +import enum +import logging +from typing import Optional, Tuple, List, Set, Any + +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey + +from . import base, enterprise_common +from .helpers import report_utils +from .. import api, error, crypto, utils +from ..proto import MCTransfer_pb2, breachwatch_pb2, record_pb2 + + +class McTransferCommand(base.GroupCommand): + def __init__(self): + super().__init__() + self.register_command('join-msp', McTransferJoinMspCommand()) + self.register_command('leave-msp', McTransferLeaveMspCommand()) + self.register_command('accept-mc', McTransferAcceptMcCommand()) + self.register_command('cancel', McTransferCancelCommand()) + self.register_command('status', McTransferStatusCommand()) + self.register_command('perform', McTransferPerformCommand()) + +mc_transfer_target_parser = argparse.ArgumentParser(add_help=False) +mc_transfer_target_parser.add_argument('--target-name', dest='target_name', help='Target enterprise name') +mc_transfer_target_parser.add_argument('--target-email', dest='target_email', help='Target administrator email') + +mc_transfer_join_msp_parser = argparse.ArgumentParser( + prog='mc-transfer join-msp', description='Initializes Regular/MC/MSP transfer to MSP', parents=[mc_transfer_target_parser]) + +mc_transfer_leave_msp_parser = argparse.ArgumentParser( + prog='mc-transfer leave-msp', description='Initializes MC leaving MSP', parents=[mc_transfer_target_parser]) + +mc_transfer_accept_mc_parser = argparse.ArgumentParser( + prog='mc-transfer accept-mc', description='MSP accepts Regular/MC/MSP transfer', parents=[mc_transfer_target_parser]) + +mc_transfer_status_parser = argparse.ArgumentParser( + prog='mc-transfer status', description='Checks MC transfer status', parents=[mc_transfer_target_parser]) + +mc_transfer_cancel_parser = argparse.ArgumentParser( + prog='mc-transfer cancel', description='Cancels MC transfer', parents=[mc_transfer_target_parser]) + +mc_transfer_perform_parser = argparse.ArgumentParser( + prog='mc-transfer perform', description='Completes MC transfer', parents=[mc_transfer_target_parser]) + + +class EnterpriseType(enum.Enum): + Unknown = 0 + Trial = 1 + Regular = 2 + MSP = 3 + MC = 4 + Distributor = 5 + + +class McTransferMixin: + @staticmethod + def transfer_status_to_text(status: MCTransfer_pb2.MCTransferStatus) -> str: + if status == MCTransfer_pb2.MCTransferStatus.STATUS_INVALID: + return 'Invalid' + if status == MCTransfer_pb2.MCTransferStatus.STATUS_REQUESTED: + return 'Requested' + if status == MCTransfer_pb2.MCTransferStatus.STATUS_ACCEPTED: + return 'Accepted' + if status == MCTransfer_pb2.MCTransferStatus.STATUS_PENDING_APPROVAL: + return 'Pending' + if status == MCTransfer_pb2.MCTransferStatus.STATUS_APPROVED: + return 'Approved' + if status == MCTransfer_pb2.MCTransferStatus.STATUS_DENIED: + return 'Denied' + if status == MCTransfer_pb2.MCTransferStatus.STATUS_READY: + return 'Ready' + return 'Unsupported' + + @staticmethod + def get_transfer_parameters(command_name: str, **kwargs) -> Tuple[str, str]: + enterprise_name = kwargs.get('target_name') # type: Optional[str] + if not enterprise_name: + raise error.CommandError(command_name, 'Target enterprise name cannot be empty') + + enterprise_email = kwargs.get('target_email') # type: Optional[str] + if not enterprise_email: + raise error.CommandError(command_name, 'Target enterprise admin email cannot be empty') + return enterprise_name, enterprise_email + + @staticmethod + def get_enterprise_license(params) -> EnterpriseType: + licenses = params.enterprise.get('licenses', []) + if isinstance(licenses, list) and licenses: + lic = licenses[0] + if isinstance(lic, dict): + product_type_id = lic.get('product_type_id', 0) + if product_type_id in (3, 5): + return EnterpriseType.Regular + elif product_type_id in (9, 10): + distributor = lic.get('distributor', False) + return EnterpriseType.Distributor if distributor else EnterpriseType.MSP + elif product_type_id in (11, 12): + return EnterpriseType.MSP + elif product_type_id == 8: + return EnterpriseType.MC + # We ignore trial enterprises + # if product_type_id in (5, 10, 12): + # return EnterpriseType.Trial + return EnterpriseType.Unknown + + @staticmethod + def selected_managed_companies(params) -> Set[int]: + CHECKBOX_EMPTY = "[ ]" + CHECKBOX_X = "[x]" + + managed_companies = params.enterprise.get('managed_companies') + selected_mc: Set[int] = set() + if isinstance(managed_companies, list) and managed_companies: + headers = ['', 'MC ID', 'MC Name', 'Seats'] + mcs: List[List[Any]] = [] + for mc in managed_companies: + seats = mc.get('number_of_seats', 0) + if seats > 2000000: + seats = '*' + mcs.append([mc.get('mc_enterprise_id'), mc.get('mc_enterprise_name'), seats]) + mcs.sort(key=lambda x: x[0]) + while True: + table = [] + for mc in mcs: + selected = mc[0] in selected_mc + row = [CHECKBOX_X if selected else CHECKBOX_EMPTY] + row.extend(mc) + table.append(row) + base.dump_report_data(table, headers) + answer = input('Select managed companies to transfer ([a]ll, [n]one, [d]one or list of MC IDs): ') + answer = answer.lower() + if answer in ['a', 'all']: + selected_mc.clear() + selected_mc.update([x[0] for x in mcs]) + elif answer in ['n', 'none']: + selected_mc.clear() + elif answer in ['d', 'done']: + break + else: + answer = answer.replace(',', ' ') + mc_ids = answer.split() + for mc_id in mc_ids: + try: + id_mc = int(mc_id) + mc = next((x for x in mcs if x[0] == id_mc), None) + if mc: + selected_mc.add(id_mc) + else: + logging.info(f'"{mc_id}" is not a valid Managed Company ID') + except Exception as e: + logging.info(f'"{mc_id}" is not a valid Managed Company ID: {e}') + if len(selected_mc) == len(managed_companies): + answer = base.user_choice('Do you want to transfer MSP company as well?', 'yn', 'n') + if answer.lower() == 'y': + selected_mc.add(params.enterprise_id) + + return selected_mc + +class McTransferJoinMspCommand(enterprise_common.EnterpriseCommand, McTransferMixin): + def get_parser(self): + return mc_transfer_join_msp_parser + + def execute(self, params, **kwargs): + enterprise_name, enterprise_email = self.get_transfer_parameters('mc-transfer join-msp', **kwargs) + enterprise_type = self.get_enterprise_license(params) + if enterprise_type not in (EnterpriseType.Regular, EnterpriseType.MSP, EnterpriseType.MC, EnterpriseType.Distributor): + raise error.CommandError('mc-transfer join-msp', 'Command is available to Regular, MSP, and MC enterprises') + + rq = MCTransfer_pb2.MCTransferRequest() + rq.enterpriseName = enterprise_name + rq.enterpriseContactEmail = enterprise_email + if enterprise_type == EnterpriseType.MSP: + selected_mcs = self.selected_managed_companies(params) + if len(selected_mcs) > 0: + for mc_id in selected_mcs: + mct = MCTransfer_pb2.MCTransferTreeKey() + mct.enterpriseId = mc_id + rq.mcTransferTreeKeys.append(mct) + else: + raise error.CommandError('mc-transfer join-msp', 'No Managed Companies to transfer selected') + + try: + api.communicate_rest(params, rq, 'enterprise/mc_transfer_join_msp') + except error.KeeperApiError as kae: + raise error.CommandError('mc-transfer join-msp', kae.message) + + +class McTransferStatusCommand(enterprise_common.EnterpriseCommand, McTransferMixin): + def get_parser(self): + return mc_transfer_status_parser + + def execute(self, params, **kwargs): + enterprise_name = kwargs.get('target_name') + enterprise_email = kwargs.get('target_email') + + enterprise_type = self.get_enterprise_license(params) + if enterprise_type not in (EnterpriseType.Regular, EnterpriseType.MSP, EnterpriseType.MC, EnterpriseType.Distributor): + raise error.CommandError('mc-transfer status', 'Command is available to Regular, MSP, and MC enterprises') + + rq = MCTransfer_pb2.MCTransferRequest() + if isinstance(enterprise_name, str): + rq.enterpriseName = enterprise_name + if isinstance(enterprise_email, str): + rq.enterpriseContactEmail = enterprise_email + transfer: Optional[MCTransfer_pb2.MCTransferState] + transfer = api.communicate_rest(params, rq, 'enterprise/mc_transfer_status', rs_type=MCTransfer_pb2.MCTransferState) + if transfer: + headers = ['from_enterprise_name', 'from_admin_email', 'target_enterprise_name', 'target_admin_email', 'status', 'comments'] + status_text = self.transfer_status_to_text(transfer.transferStatus) + row: List[Any] = [transfer.movingEnterpriseName, transfer.movingEnterpriseAdminEmail, transfer.receivingEnterpriseName, + transfer.receivingEnterpriseAdminEmail, status_text, transfer.comments] + if transfer.mcTransferEnterprises: + headers.append('managed_companies') + row.append([f'{x.enterpriseId:>8} : {x.enterpriseName}' for x in transfer.mcTransferEnterprises]) + + headers = [report_utils.field_to_title(x) for x in headers] + table = [[x[0], x[1]] for x in zip(headers, row)] + headers = [report_utils.field_to_title(x) for x in ['property', 'value']] + return base.dump_report_data(table, headers) + else: + raise error.CommandError('mc-transfer status', 'MC Transfer status is empty') + + +class McTransferCancelCommand(enterprise_common.EnterpriseCommand, McTransferMixin): + def get_parser(self): + return mc_transfer_cancel_parser + + def execute(self, params, **kwargs): + enterprise_name, enterprise_email = self.get_transfer_parameters('mc-transfer cancel', **kwargs) + enterprise_type = self.get_enterprise_license(params) + if enterprise_type not in (EnterpriseType.MSP, EnterpriseType.MC, EnterpriseType.Distributor): + raise error.CommandError('mc-transfer cancel', 'Command is available to MSP and MC enterprises') + + rq = MCTransfer_pb2.MCTransferRequest() + rq.enterpriseName = enterprise_name + rq.enterpriseContactEmail = enterprise_email + try: + api.communicate_rest(params, rq, 'enterprise/mc_transfer_cancel') + except error.KeeperApiError as kae: + raise error.CommandError('mc-transfer cancel', kae.message) + + +class McTransferLeaveMspCommand(enterprise_common.EnterpriseCommand, McTransferMixin): + def get_parser(self): + return mc_transfer_leave_msp_parser + + def execute(self, params, **kwargs): + enterprise_type = self.get_enterprise_license(params) + if enterprise_type != EnterpriseType.MC: + raise error.CommandError('mc-transfer leave-msp', 'Command is available to Managed Companies (MC)') + try: + api.communicate_rest(params, None, 'enterprise/mc_transfer_leave_msp') + except error.KeeperApiError as kae: + raise error.CommandError('mc-transfer leave-msp', kae.message) + + +class McTransferAcceptMcCommand(enterprise_common.EnterpriseCommand, McTransferMixin): + def get_parser(self): + return mc_transfer_accept_mc_parser + + def execute(self, params, **kwargs): + enterprise_name, enterprise_email = self.get_transfer_parameters('mc-transfer accept-mc', **kwargs) + enterprise_type = self.get_enterprise_license(params) + if enterprise_type not in (EnterpriseType.MSP, EnterpriseType.Distributor): + raise error.CommandError('mc-transfer accept-mc', 'Command is available to MSP') + + try: + rq = MCTransfer_pb2.MCTransferRequest() + rq.enterpriseName = enterprise_name + rq.enterpriseContactEmail = enterprise_email + api.communicate_rest(params, rq, 'enterprise/mc_transfer_accept_mc') + except error.KeeperApiError as kae: + raise error.CommandError('mc-transfer accept-mc', kae.message) + + +class McTransferPerformCommand(enterprise_common.EnterpriseCommand, McTransferMixin): + def get_parser(self): + return mc_transfer_perform_parser + + def execute(self, params, **kwargs): + enterprise_name, enterprise_email = self.get_transfer_parameters('mc-transfer perform', **kwargs) + enterprise_type = self.get_enterprise_license(params) + if enterprise_type not in (EnterpriseType.MSP, EnterpriseType.MC, EnterpriseType.Regular): + raise error.CommandError('mc-transfer perform', 'Command is available to Regular, MSP, and MC enterprises') + + rq = MCTransfer_pb2.MCTransferRequest() + rq.enterpriseName = enterprise_name + rq.enterpriseContactEmail = enterprise_email + transfer: Optional[MCTransfer_pb2.MCTransferState] + transfer = api.communicate_rest(params, rq, 'enterprise/mc_transfer_status', rs_type=MCTransfer_pb2.MCTransferState) + if not transfer: + raise error.CommandError('mc-transfer perform', 'Response cannot be empty') + if transfer.transferStatus != MCTransfer_pb2.MCTransferStatus.STATUS_APPROVED: + raise error.CommandError('mc-transfer perform', 'The transfer has not been approved') + + rq = MCTransfer_pb2.MCTransferRequest() + rq.enterpriseName = enterprise_name + rq.enterpriseContactEmail = enterprise_email + + if transfer.receivingEnterpriseName: + public_key_rs: Optional[breachwatch_pb2.EnterprisePublicKeyResponse] + public_key_rs = api.communicate_rest(params, rq, 'enterprise/mc_transfer_get_public_key', rs_type=breachwatch_pb2.EnterprisePublicKeyResponse) + if not public_key_rs: + raise error.CommandError('mc-transfer perform', 'Failed to get transfer key') + + rsa_key: Optional[RSAPublicKey] = None + ec_key: Optional[EllipticCurvePublicKey] = None + if len(public_key_rs.enterpriseECCPublicKey): + ec_key = crypto.load_ec_public_key(public_key_rs.enterpriseECCPublicKey) + elif len(public_key_rs.enterprisePublicKey) > 0: + rsa_key = crypto.load_rsa_public_key(public_key_rs.enterprisePublicKey) + else: + raise error.CommandError('mc-transfer perform', 'Failed to get transfer key') + + enterprise_tree_key = params.enterprise['unencrypted_tree_key'] + transfer_self = False + if len(transfer.mcTransferEnterprises) > 0: + managed_companies = params.enterprise.get('managed_companies') or [] + has_failed_mc = False + for mct in transfer.mcTransferEnterprises: + id_mc = mct.enterpriseId + if id_mc == params.enterprise_id: + transfer_self = True + else: + mc = next((x for x in managed_companies if x.get('mc_enterprise_id') == id_mc), None) + if mc: + encrypted_tree_key = utils.base64_url_decode(mc['tree_key']) + if enterprise_tree_key: + try: + tree_key = crypto.decrypt_aes_v2(encrypted_tree_key, enterprise_tree_key) + key = MCTransfer_pb2.MCTransferTreeKey() + key.enterpriseId = id_mc + if ec_key: + key.treeKeyTypeId = record_pb2.ENCRYPTED_BY_PUBLIC_KEY_ECC + key.treeKey = crypto.encrypt_ec(tree_key, ec_key) + elif rsa_key: + key.treeKeyTypeId = record_pb2.ENCRYPTED_BY_PUBLIC_KEY + key.treeKey = crypto.encrypt_rsa(tree_key, rsa_key) + else: + raise Exception('Decryption key not found') + rq.mcTransferTreeKeys.append(key) + except Exception as e: + logging.info(f'"{id_mc}" decrypt key error: {e}') + has_failed_mc = True + if has_failed_mc: + transfer_self = False + elif transfer.movingEnterpriseId == params.enterprise_id: + transfer_self = True + + if transfer_self: + try: + key = MCTransfer_pb2.MCTransferTreeKey() + key.enterpriseId = params.enterprise_id + if ec_key: + key.treeKeyTypeId = record_pb2.ENCRYPTED_BY_PUBLIC_KEY_ECC + key.treeKey = crypto.encrypt_ec(enterprise_tree_key, ec_key) + elif rsa_key: + key.treeKeyTypeId = record_pb2.ENCRYPTED_BY_PUBLIC_KEY + key.treeKey = crypto.encrypt_rsa(enterprise_tree_key, rsa_key) + else: + raise Exception('Decryption key not found') + rq.mcTransferTreeKeys.append(key) + except Exception as e: + logging.warning(f'Failed to encrypt enterprise key: ID: {transfer.movingEnterpriseId}, Name: {transfer.movingEnterpriseName}, Error: {e}') + if len(rq.mcTransferTreeKeys) == 0: + raise error.CommandError('mc-transfer perform', 'No enterprise to transfer') + try: + api.communicate_rest(params, rq, 'enterprise/mc_transfer_perform') + except error.KeeperApiError as kae: + raise error.CommandError('mc-transfer perform', kae.message) diff --git a/keepercommander/proto/MCTransfer_pb2.py b/keepercommander/proto/MCTransfer_pb2.py new file mode 100644 index 000000000..7b884a5c9 --- /dev/null +++ b/keepercommander/proto/MCTransfer_pb2.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: MCTransfer.proto +# Protobuf Python Version: 5.29.5 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 5, + 29, + 5, + '', + 'MCTransfer.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10MCTransfer.proto\x12\nMCTransfer\"\x86\x01\n\x11MCTransferRequest\x12\x16\n\x0e\x65nterpriseName\x18\x01 \x01(\t\x12\x1e\n\x16\x65nterpriseContactEmail\x18\x02 \x01(\t\x12\x39\n\x12mcTransferTreeKeys\x18\x03 \x03(\x0b\x32\x1d.MCTransfer.MCTransferTreeKey\"Q\n\x11MCTransferTreeKey\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x0f\n\x07treeKey\x18\x02 \x01(\x0c\x12\x15\n\rtreeKeyTypeId\x18\x03 \x01(\x05\"[\n\x1cMCTransferApproveDenyRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x14\n\x0ctransferDate\x18\x03 \x01(\x03\"U\n\x1dMCTransferApproveDenyResponse\x12\x34\n\x0fmcTransferState\x18\x01 \x01(\x0b\x32\x1b.MCTransfer.MCTransferState\"O\n\x16MCTransferListResponse\x12\x35\n\x10mcTransferStates\x18\x01 \x03(\x0b\x32\x1b.MCTransfer.MCTransferState\"D\n\x14MCTransferEnterprise\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x16\n\x0e\x65nterpriseName\x18\x02 \x01(\t\"\xd6\x02\n\x0fMCTransferState\x12\x1a\n\x12movingEnterpriseId\x18\x01 \x01(\x05\x12\x1c\n\x14movingEnterpriseName\x18\x02 \x01(\t\x12\"\n\x1amovingEnterpriseAdminEmail\x18\x03 \x01(\t\x12\x1f\n\x17receivingEnterpriseName\x18\x04 \x01(\t\x12%\n\x1dreceivingEnterpriseAdminEmail\x18\x05 \x01(\t\x12\x34\n\x0etransferStatus\x18\x06 \x01(\x0e\x32\x1c.MCTransfer.MCTransferStatus\x12\x10\n\x08\x63omments\x18\x07 \x01(\t\x12\x14\n\x0ctransferDate\x18\x08 \x01(\x03\x12?\n\x15mcTransferEnterprises\x18\t \x03(\x0b\x32 .MCTransfer.MCTransferEnterprise*\xa8\x01\n\x10MCTransferStatus\x12\x12\n\x0eSTATUS_INVALID\x10\x00\x12\x14\n\x10STATUS_REQUESTED\x10\x01\x12\x13\n\x0fSTATUS_ACCEPTED\x10\x02\x12\x1b\n\x17STATUS_PENDING_APPROVAL\x10\x03\x12\x13\n\x0fSTATUS_APPROVED\x10\x04\x12\x11\n\rSTATUS_DENIED\x10\x05\x12\x10\n\x0cSTATUS_READY\x10\x06\x42&\n\x18\x63om.keepersecurity.protoB\nMCTransferb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'MCTransfer_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\nMCTransfer' + _globals['_MCTRANSFERSTATUS']._serialized_start=929 + _globals['_MCTRANSFERSTATUS']._serialized_end=1097 + _globals['_MCTRANSFERREQUEST']._serialized_start=33 + _globals['_MCTRANSFERREQUEST']._serialized_end=167 + _globals['_MCTRANSFERTREEKEY']._serialized_start=169 + _globals['_MCTRANSFERTREEKEY']._serialized_end=250 + _globals['_MCTRANSFERAPPROVEDENYREQUEST']._serialized_start=252 + _globals['_MCTRANSFERAPPROVEDENYREQUEST']._serialized_end=343 + _globals['_MCTRANSFERAPPROVEDENYRESPONSE']._serialized_start=345 + _globals['_MCTRANSFERAPPROVEDENYRESPONSE']._serialized_end=430 + _globals['_MCTRANSFERLISTRESPONSE']._serialized_start=432 + _globals['_MCTRANSFERLISTRESPONSE']._serialized_end=511 + _globals['_MCTRANSFERENTERPRISE']._serialized_start=513 + _globals['_MCTRANSFERENTERPRISE']._serialized_end=581 + _globals['_MCTRANSFERSTATE']._serialized_start=584 + _globals['_MCTRANSFERSTATE']._serialized_end=926 +# @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/MCTransfer_pb2.pyi b/keepercommander/proto/MCTransfer_pb2.pyi new file mode 100644 index 000000000..8fddb62ab --- /dev/null +++ b/keepercommander/proto/MCTransfer_pb2.pyi @@ -0,0 +1,96 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class MCTransferStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + STATUS_INVALID: _ClassVar[MCTransferStatus] + STATUS_REQUESTED: _ClassVar[MCTransferStatus] + STATUS_ACCEPTED: _ClassVar[MCTransferStatus] + STATUS_PENDING_APPROVAL: _ClassVar[MCTransferStatus] + STATUS_APPROVED: _ClassVar[MCTransferStatus] + STATUS_DENIED: _ClassVar[MCTransferStatus] + STATUS_READY: _ClassVar[MCTransferStatus] +STATUS_INVALID: MCTransferStatus +STATUS_REQUESTED: MCTransferStatus +STATUS_ACCEPTED: MCTransferStatus +STATUS_PENDING_APPROVAL: MCTransferStatus +STATUS_APPROVED: MCTransferStatus +STATUS_DENIED: MCTransferStatus +STATUS_READY: MCTransferStatus + +class MCTransferRequest(_message.Message): + __slots__ = ("enterpriseName", "enterpriseContactEmail", "mcTransferTreeKeys") + ENTERPRISENAME_FIELD_NUMBER: _ClassVar[int] + ENTERPRISECONTACTEMAIL_FIELD_NUMBER: _ClassVar[int] + MCTRANSFERTREEKEYS_FIELD_NUMBER: _ClassVar[int] + enterpriseName: str + enterpriseContactEmail: str + mcTransferTreeKeys: _containers.RepeatedCompositeFieldContainer[MCTransferTreeKey] + def __init__(self, enterpriseName: _Optional[str] = ..., enterpriseContactEmail: _Optional[str] = ..., mcTransferTreeKeys: _Optional[_Iterable[_Union[MCTransferTreeKey, _Mapping]]] = ...) -> None: ... + +class MCTransferTreeKey(_message.Message): + __slots__ = ("enterpriseId", "treeKey", "treeKeyTypeId") + ENTERPRISEID_FIELD_NUMBER: _ClassVar[int] + TREEKEY_FIELD_NUMBER: _ClassVar[int] + TREEKEYTYPEID_FIELD_NUMBER: _ClassVar[int] + enterpriseId: int + treeKey: bytes + treeKeyTypeId: int + def __init__(self, enterpriseId: _Optional[int] = ..., treeKey: _Optional[bytes] = ..., treeKeyTypeId: _Optional[int] = ...) -> None: ... + +class MCTransferApproveDenyRequest(_message.Message): + __slots__ = ("enterpriseId", "message", "transferDate") + ENTERPRISEID_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + TRANSFERDATE_FIELD_NUMBER: _ClassVar[int] + enterpriseId: int + message: str + transferDate: int + def __init__(self, enterpriseId: _Optional[int] = ..., message: _Optional[str] = ..., transferDate: _Optional[int] = ...) -> None: ... + +class MCTransferApproveDenyResponse(_message.Message): + __slots__ = ("mcTransferState",) + MCTRANSFERSTATE_FIELD_NUMBER: _ClassVar[int] + mcTransferState: MCTransferState + def __init__(self, mcTransferState: _Optional[_Union[MCTransferState, _Mapping]] = ...) -> None: ... + +class MCTransferListResponse(_message.Message): + __slots__ = ("mcTransferStates",) + MCTRANSFERSTATES_FIELD_NUMBER: _ClassVar[int] + mcTransferStates: _containers.RepeatedCompositeFieldContainer[MCTransferState] + def __init__(self, mcTransferStates: _Optional[_Iterable[_Union[MCTransferState, _Mapping]]] = ...) -> None: ... + +class MCTransferEnterprise(_message.Message): + __slots__ = ("enterpriseId", "enterpriseName") + ENTERPRISEID_FIELD_NUMBER: _ClassVar[int] + ENTERPRISENAME_FIELD_NUMBER: _ClassVar[int] + enterpriseId: int + enterpriseName: str + def __init__(self, enterpriseId: _Optional[int] = ..., enterpriseName: _Optional[str] = ...) -> None: ... + +class MCTransferState(_message.Message): + __slots__ = ("movingEnterpriseId", "movingEnterpriseName", "movingEnterpriseAdminEmail", "receivingEnterpriseName", "receivingEnterpriseAdminEmail", "transferStatus", "comments", "transferDate", "mcTransferEnterprises") + MOVINGENTERPRISEID_FIELD_NUMBER: _ClassVar[int] + MOVINGENTERPRISENAME_FIELD_NUMBER: _ClassVar[int] + MOVINGENTERPRISEADMINEMAIL_FIELD_NUMBER: _ClassVar[int] + RECEIVINGENTERPRISENAME_FIELD_NUMBER: _ClassVar[int] + RECEIVINGENTERPRISEADMINEMAIL_FIELD_NUMBER: _ClassVar[int] + TRANSFERSTATUS_FIELD_NUMBER: _ClassVar[int] + COMMENTS_FIELD_NUMBER: _ClassVar[int] + TRANSFERDATE_FIELD_NUMBER: _ClassVar[int] + MCTRANSFERENTERPRISES_FIELD_NUMBER: _ClassVar[int] + movingEnterpriseId: int + movingEnterpriseName: str + movingEnterpriseAdminEmail: str + receivingEnterpriseName: str + receivingEnterpriseAdminEmail: str + transferStatus: MCTransferStatus + comments: str + transferDate: int + mcTransferEnterprises: _containers.RepeatedCompositeFieldContainer[MCTransferEnterprise] + def __init__(self, movingEnterpriseId: _Optional[int] = ..., movingEnterpriseName: _Optional[str] = ..., movingEnterpriseAdminEmail: _Optional[str] = ..., receivingEnterpriseName: _Optional[str] = ..., receivingEnterpriseAdminEmail: _Optional[str] = ..., transferStatus: _Optional[_Union[MCTransferStatus, str]] = ..., comments: _Optional[str] = ..., transferDate: _Optional[int] = ..., mcTransferEnterprises: _Optional[_Iterable[_Union[MCTransferEnterprise, _Mapping]]] = ...) -> None: ... From 0fe763e2bf71028ee83a220a9c1bd6b1f3f30eb8 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Sat, 20 Jun 2026 19:02:37 -0700 Subject: [PATCH 13/18] Release 18.0.9 --- keepercommander/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index c54d16492..ccec78834 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: commander@keepersecurity.com # -__version__ = '18.0.8' +__version__ = '18.0.9' From d6e1ca1b7b57c35bbaa680e05ed13fe3a8549dae Mon Sep 17 00:00:00 2001 From: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:49:43 -0500 Subject: [PATCH 14/18] Add AI enable toggles to pam connection ai and fix default reset. Adds --enabled/-e and --session-terminate/-st for resource-level aiEnabled and aiSessionTerminate. Routes on/off/default default resets through legacy DAG write because krouter mergeJson does not remove keys omitted from Layer-B payloads. Co-authored-by: Cursor --- .../tunnel/port_forward/TunnelGraph.py | 31 ++++++-- .../commands/tunnel_and_connections.py | 72 +++++++++++++++++-- unit-tests/pam/test_dag_layer_b_migration.py | 27 +++++++ unit-tests/pam/test_pam_connection_ai.py | 39 ++++++++++ 4 files changed, 160 insertions(+), 9 deletions(-) diff --git a/keepercommander/commands/tunnel/port_forward/TunnelGraph.py b/keepercommander/commands/tunnel/port_forward/TunnelGraph.py index ac89cb1d0..ab35794ed 100644 --- a/keepercommander/commands/tunnel/port_forward/TunnelGraph.py +++ b/keepercommander/commands/tunnel/port_forward/TunnelGraph.py @@ -177,11 +177,24 @@ def _convert_allowed_setting(value): return value return {"on": True, "off": False}.get(str(value).lower(), None) + @classmethod + def _is_allowed_setting_default_reset(cls, value): + """True when input is on/off/default and resolves to default (remove key).""" + return value is not None and cls._convert_allowed_setting(value) is None + def edit_tunneling_config(self, connections=None, tunneling=None, rotation=None, session_recording=None, typescript_recording=None, remote_browser_isolation=None, ai_enabled=None, ai_session_terminate=None): + resetting_allowed_settings = any( + self._is_allowed_setting_default_reset(v) + for v in ( + connections, tunneling, rotation, session_recording, + typescript_recording, remote_browser_isolation, + ai_enabled, ai_session_terminate, + ) + ) config_vertex = self.linking_dag.get_vertex(self.record.record_uid) if config_vertex is None: config_vertex = self.linking_dag.add_vertex(uid=self.record.record_uid, vertex_type=RefType.PAM_NETWORK) @@ -294,7 +307,7 @@ def edit_tunneling_config(self, connections=None, tunneling=None, from ...pam._layer_b import is_layer_b_feature_disabled host = get_router_url(self.params) endpoint = 'configure_network_graph' - if not is_layer_b_feature_disabled(host, endpoint): + if not resetting_allowed_settings and not is_layer_b_feature_disabled(host, endpoint): try: config_uid_bytes = url_safe_str_to_bytes(self.record.record_uid) rq = router_pb2.PAMNetworkConfigurationRequest( @@ -861,6 +874,14 @@ def set_resource_allowed(self, resource_uid, tunneling=None, connections=None, r allowed_settings_name='allowedSettings', is_config=False, v_type: RefType=str(RefType.PAM_MACHINE), meta_version=None, rotate_on_termination=None): + resetting_allowed_settings = any( + self._is_allowed_setting_default_reset(v) + for v in ( + connections, tunneling, rotation, session_recording, + typescript_recording, remote_browser_isolation, + ai_enabled, ai_session_terminate, + ) + ) 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: @@ -998,7 +1019,7 @@ def set_resource_allowed(self, resource_uid, tunneling=None, connections=None, r from ...pam._layer_b import is_layer_b_feature_disabled host = get_router_url(self.params) - if is_config: + if not resetting_allowed_settings and is_config: endpoint = 'configure_network_graph' if not is_layer_b_feature_disabled(host, endpoint): try: @@ -1022,7 +1043,7 @@ def set_resource_allowed(self, resource_uid, tunneling=None, connections=None, r f"configure_network_graph denied/unavailable for config {resource_uid}; " f"falling back to legacy DAG-write: {err}" ) - else: + elif not resetting_allowed_settings: endpoint = 'configure_resource' if not is_layer_b_feature_disabled(host, endpoint): try: @@ -1045,7 +1066,9 @@ def set_resource_allowed(self, resource_uid, tunneling=None, connections=None, r f"falling back to legacy DAG-write: {err}" ) - # Fallback: legacy direct DAG-write. + # Fallback: legacy direct DAG-write (also used when resetting any + # allowedSettings key to default — krouter mergeJson never deletes + # keys absent from a Layer-B payload). if meta_version is not None and meta_version != 0: resource_vertex.add_data(content=meta_payload, path='meta', needs_encryption=False) else: diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index aaa857b64..1edcb905e 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -3294,11 +3294,16 @@ def _do_set(self, params, kwargs, record_uid, config_uid): class PAMConnectionAiCommand(Command): + choices = ['on', 'off', 'default'] parser = argparse.ArgumentParser(prog='pam connection ai') parser.add_argument('record', type=str, action='store', help=f'Record UID, path, or title ({_PAM_CONNECTION_AI_RESOURCE_TYPES_LABEL})') parser.add_argument('--configuration', '-c', type=str, dest='configuration', action='store', default=None, help='PAM Configuration UID or title (required when record is not linked yet or is linked to 2+ configs)') + parser.add_argument('--enabled', '-e', dest='enabled', choices=choices, + help='Enable/disable KeeperAI threat detection (on/off/default; default removes from DAG JSON)') + parser.add_argument('--session-terminate', '-st', dest='session_terminate', choices=choices, + help='Enable/disable session termination on detection (on/off/default; default removes from DAG JSON)') parser.add_argument('--set', '-s', dest='set_values', action='append', default=None, metavar='LEVEL.SETTING=VALUE', help='Set a KeeperAI value (e.g. -s low.terminate=false -s low.allow=chmod -s "high.deny=kill -9")') @@ -3321,7 +3326,10 @@ def execute(self, params, **kwargs): show_flag = kwargs.get('show', False) set_values = kwargs.get('set_values') or [] unset_values = kwargs.get('unset_values') or [] - change_options_provided = bool(set_values or unset_values) + enabled = kwargs.get('enabled') + session_terminate = kwargs.get('session_terminate') + toggle_options_provided = enabled is not None or session_terminate is not None + change_options_provided = bool(set_values or unset_values or toggle_options_provided) if remove_flag and show_flag: raise CommandError('', f'{bcolors.FAIL}--remove cannot be used with --show.{bcolors.ENDC}') @@ -3330,7 +3338,8 @@ def execute(self, params, **kwargs): if show_flag and change_options_provided: raise CommandError('', f'{bcolors.FAIL}--show cannot be used with --remove or any KeeperAI option.{bcolors.ENDC}') if not remove_flag and not show_flag and not change_options_provided: - raise CommandError('', f'{bcolors.FAIL}Provide at least one --set/-s, --unset/-u, --remove, or --show.{bcolors.ENDC}') + raise CommandError('', f'{bcolors.FAIL}Provide at least one --enabled/-e, --session-terminate/-st, ' + f'--set/-s, --unset/-u, --remove, or --show.{bcolors.ENDC}') record = RecordMixin.resolve_single_record(params, record_name) if record is None: @@ -3362,7 +3371,10 @@ def execute(self, params, **kwargs): elif remove_flag: self._do_remove(params, record_uid, config_uid) else: - self._do_apply(params, set_values, unset_values, record_uid, config_uid) + if toggle_options_provided: + self._do_apply_allowed_settings(params, enabled, session_terminate, record_uid, config_uid) + if set_values or unset_values: + self._do_apply(params, set_values, unset_values, record_uid, config_uid) def _resolve_config_uid(self, params, record_uid, configuration, allow_unlinked_without_config=False): encrypted_session_token, encrypted_transmission_key, _ = get_keeper_tokens(params) @@ -3413,6 +3425,16 @@ def _resolve_configuration_record(params, configuration, linked_config_uids): f'{bcolors.FAIL}PAM Configuration "{configuration}" is not linked to this record.{bcolors.ENDC}') return config_uid + @staticmethod + def _format_allowed_setting_state(value: str) -> str: + if value == 'on': + return f'{bcolors.OKBLUE}on{bcolors.ENDC}' + if value == 'off': + return f'{bcolors.WARNING}off{bcolors.ENDC}' + if value == 'default': + return f'{bcolors.OKGREEN}default{bcolors.ENDC}' + return value or 'default' + def _do_show(self, params, record, record_uid, config_uid): from .pam_import.keeper_ai_settings import ( get_resource_keeper_ai_settings, @@ -3425,12 +3447,20 @@ def _do_show(self, params, record, record_uid, config_uid): f'(record is not linked to a PAM Configuration).{bcolors.ENDC}\n') return + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + tdag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, config_uid, + transmission_key=transmission_key) + ai_enabled = tdag.get_resource_setting(record_uid, 'allowedSettings', 'aiEnabled') + ai_session_terminate = tdag.get_resource_setting(record_uid, 'allowedSettings', 'aiSessionTerminate') + print(f' AI Threat Detection: {self._format_allowed_setting_state(ai_enabled)}') + print(f' AI Session Terminate on Detection: {self._format_allowed_setting_state(ai_session_terminate)}') + settings = get_resource_keeper_ai_settings(params, record_uid, config_uid) if is_default_keeper_ai_settings(settings): - print(f' {bcolors.WARNING}No KeeperAI settings configured.{bcolors.ENDC}\n') + print(f'\n {bcolors.WARNING}No risk-level KeeperAI settings configured.{bcolors.ENDC}\n') return - print(f' Version: {settings.get("version", "unknown")}') + print(f'\n Version: {settings.get("version", "unknown")}') risk_levels = settings.get('riskLevels', {}) for level in ('critical', 'high', 'medium', 'low'): level_data = risk_levels.get(level) @@ -3460,6 +3490,38 @@ def _do_show(self, params, record, record_uid, config_uid): print(f' - {tag_name}') print() + def _do_apply_allowed_settings(self, params, enabled, session_terminate, record_uid, config_uid): + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) + tdag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, config_uid, + transmission_key=transmission_key) + + if tdag is None or not tdag.linking_dag.has_graph: + raise CommandError('', + f'{bcolors.FAIL}No PAM Configuration UID set. ' + f'Specify --configuration|-c or link the record first.{bcolors.ENDC}') + + if not tdag.is_tunneling_config_set_up(record_uid): + tdag.link_resource_to_config(record_uid) + + if not tdag.is_tunneling_config_set_up(record_uid): + raise CommandError('', + f'{bcolors.FAIL}Record is not set up for connections. ' + f'Use: pam connection edit {record_uid} --config --connections=on ' + f'(list configs: pam config list).{bcolors.ENDC}') + + tdag.set_resource_allowed( + resource_uid=record_uid, + ai_enabled=enabled, + ai_session_terminate=session_terminate, + ) + + labels = [] + if enabled is not None: + labels.append(f'AI threat detection={enabled}') + if session_terminate is not None: + labels.append(f'AI session terminate={session_terminate}') + print(f'{bcolors.OKGREEN}KeeperAI allowed settings saved ({", ".join(labels)}).{bcolors.ENDC}') + def _do_remove(self, params, record_uid, config_uid): from .pam_import.keeper_ai_settings import remove_resource_keeper_ai_settings diff --git a/unit-tests/pam/test_dag_layer_b_migration.py b/unit-tests/pam/test_dag_layer_b_migration.py index 3ba9090c7..abb5f591b 100644 --- a/unit-tests/pam/test_dag_layer_b_migration.py +++ b/unit-tests/pam/test_dag_layer_b_migration.py @@ -1031,6 +1031,33 @@ def _capture(params, rq): assert 'connections' in meta_str assert 'rotation' in meta_str + def test_default_reset_skips_configure_resource_and_writes_legacy(self): + """on/off/default 'default' removes keys from meta; krouter mergeJson keeps + absent keys, so set_resource_allowed must use legacy DAG-write.""" + from keepercommander.commands.tunnel.port_forward import TunnelGraph as tg_mod + + tg, resource_vertex = self._build_tg() + existing = { + 'allowedSettings': { + 'aiEnabled': True, + 'aiSessionTerminate': True, + 'connections': True, + }, + } + + with patch.object(tg_mod, 'get_vertex_content', return_value=existing), \ + patch('keepercommander.commands.pam.router_helper.router_configure_resource') as cr_mock: + tg.set_resource_allowed(RESOURCE_UID_STR, ai_enabled='default', ai_session_terminate='default') + + cr_mock.assert_not_called() + resource_vertex.add_data.assert_called_once() + written = resource_vertex.add_data.call_args.kwargs['content'] + allowed = written.get('allowedSettings', {}) + assert 'aiEnabled' not in allowed + assert 'aiSessionTerminate' not in allowed + assert allowed.get('connections') is True + tg.linking_dag.save.assert_called_once() + def test_config_happy_path_uses_configure_network_graph(self): """is_config=True flips the call to configure_network_graph with the inner allowedSettings dict (matches edit_tunneling_config's shape).""" diff --git a/unit-tests/pam/test_pam_connection_ai.py b/unit-tests/pam/test_pam_connection_ai.py index ea4ad03d1..404dbfba4 100644 --- a/unit-tests/pam/test_pam_connection_ai.py +++ b/unit-tests/pam/test_pam_connection_ai.py @@ -286,3 +286,42 @@ def test_pam_connection_ai_resource_types_include_rbi(): assert 'pamMachine' in _PAM_CONNECTION_AI_RESOURCE_TYPES assert 'pamDatabase' in _PAM_CONNECTION_AI_RESOURCE_TYPES assert 'pamDirectory' in _PAM_CONNECTION_AI_RESOURCE_TYPES + + +def test_pam_connection_ai_parser_enabled_and_session_terminate_options(): + from keepercommander.commands.tunnel_and_connections import PAMConnectionAiCommand + + parser = PAMConnectionAiCommand().get_parser() + args = parser.parse_args(['resource-uid', '--enabled', 'on', '--session-terminate', 'default']) + assert args.enabled == 'on' + assert args.session_terminate == 'default' + + +def test_pam_connection_ai_parser_short_names(): + from keepercommander.commands.tunnel_and_connections import PAMConnectionAiCommand + + parser = PAMConnectionAiCommand().get_parser() + args = parser.parse_args(['resource-uid', '-e', 'off', '-st', 'on']) + assert args.enabled == 'off' + assert args.session_terminate == 'on' + + +def test_pam_connection_ai_apply_allowed_settings_calls_set_resource_allowed(): + from unittest.mock import MagicMock + from keepercommander.commands.tunnel_and_connections import PAMConnectionAiCommand + + cmd = PAMConnectionAiCommand() + params = _Params() + mock_tdag = MagicMock() + mock_tdag.linking_dag.has_graph = True + mock_tdag.is_tunneling_config_set_up.return_value = True + + with patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', return_value=(b'a', b'b', b'c')), \ + patch('keepercommander.commands.tunnel_and_connections.TunnelDAG', return_value=mock_tdag): + cmd._do_apply_allowed_settings(params, 'on', 'off', 'resource', 'config') + + mock_tdag.set_resource_allowed.assert_called_once_with( + resource_uid='resource', + ai_enabled='on', + ai_session_terminate='off', + ) From 9dfca9a59d9b5d4be44b06bd22d7af26cf80bbf7 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Mon, 22 Jun 2026 14:56:32 -0700 Subject: [PATCH 15/18] Update pedm protobuf --- keepercommander/pedm/pedm_shared.py | 10 +-- keepercommander/proto/pedm_pb2.py | 132 ++++++++++++++-------------- keepercommander/proto/pedm_pb2.pyi | 6 +- 3 files changed, 75 insertions(+), 73 deletions(-) diff --git a/keepercommander/pedm/pedm_shared.py b/keepercommander/pedm/pedm_shared.py index 3fecc2045..f02dbb7a9 100644 --- a/keepercommander/pedm/pedm_shared.py +++ b/keepercommander/pedm/pedm_shared.py @@ -110,15 +110,15 @@ def approval_type_to_name(event_type: int) -> str: return 'Other' def approval_status_to_name(approval_status: int, created: datetime.datetime, expire_in: int) -> str: - if approval_status == NotificationCenter_pb2.NAS_APPROVED: + if approval_status == pedm_pb2.AST_APPROVED: return 'Approved' - elif approval_status == NotificationCenter_pb2.NAS_DENIED: + elif approval_status == pedm_pb2.AST_DENIED: return 'Denied' - elif approval_status == NotificationCenter_pb2.NAS_ESCALATED: + elif approval_status == pedm_pb2.AST_ESCALATED: return 'Escalated' - elif approval_status == NotificationCenter_pb2.NAS_LOST_APPROVAL_RIGHTS: + elif approval_status == pedm_pb2.AST_EXPIRED: return 'Expired' - elif approval_status == NotificationCenter_pb2.NAS_UNSPECIFIED: + elif approval_status == pedm_pb2.AST_UNSPECIFIED: return 'Pending' else: return 'Unsupported' diff --git a/keepercommander/proto/pedm_pb2.py b/keepercommander/proto/pedm_pb2.py index 39a869ae7..84899398f 100644 --- a/keepercommander/proto/pedm_pb2.py +++ b/keepercommander/proto/pedm_pb2.py @@ -25,7 +25,7 @@ from . import folder_pb2 as folder__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\npedm.proto\x12\x04PEDM\x1a\x0c\x66older.proto\"O\n\x17PEDMTOTPValidateRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x02 \x01(\x05\x12\x0c\n\x04\x63ode\x18\x03 \x01(\x05\";\n\nPedmStatus\x12\x0b\n\x03key\x18\x01 \x03(\x0c\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x0f\n\x07message\x18\x03 \x01(\t\"\x89\x01\n\x12PedmStatusResponse\x12#\n\taddStatus\x18\x01 \x03(\x0b\x32\x10.PEDM.PedmStatus\x12&\n\x0cupdateStatus\x18\x02 \x03(\x0b\x32\x10.PEDM.PedmStatus\x12&\n\x0cremoveStatus\x18\x03 \x03(\x0b\x32\x10.PEDM.PedmStatus\"4\n\x0e\x44\x65ploymentData\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x65\x63PrivateKey\x18\x02 \x01(\x0c\"\x9a\x01\n\x17\x44\x65ploymentCreateRequest\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61\x65sKey\x18\x02 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x03 \x01(\x0c\x12\x19\n\x11spiffeCertificate\x18\x04 \x01(\x0c\x12\x15\n\rencryptedData\x18\x05 \x01(\x0c\x12\x11\n\tagentData\x18\x06 \x01(\x0c\"\x8d\x01\n\x17\x44\x65ploymentUpdateRequest\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x15\n\rencryptedData\x18\x02 \x01(\x0c\x12)\n\x08\x64isabled\x18\x03 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x19\n\x11spiffeCertificate\x18\x04 \x01(\x0c\"\xa2\x01\n\x17ModifyDeploymentRequest\x12\x34\n\raddDeployment\x18\x01 \x03(\x0b\x32\x1d.PEDM.DeploymentCreateRequest\x12\x37\n\x10updateDeployment\x18\x02 \x03(\x0b\x32\x1d.PEDM.DeploymentUpdateRequest\x12\x18\n\x10removeDeployment\x18\x03 \x03(\x0c\"a\n\x0b\x41gentUpdate\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12)\n\x08\x64isabled\x18\x02 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x15\n\rdeploymentUid\x18\x03 \x01(\x0c\"Q\n\x12ModifyAgentRequest\x12&\n\x0bupdateAgent\x18\x02 \x03(\x0b\x32\x11.PEDM.AgentUpdate\x12\x13\n\x0bremoveAgent\x18\x03 \x03(\x0c\"p\n\tPolicyAdd\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x14\n\x0c\x65ncryptedKey\x18\x04 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x05 \x01(\x08\"v\n\x0cPolicyUpdate\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12)\n\x08\x64isabled\x18\x04 \x01(\x0e\x32\x17.Folder.SetBooleanValue\"s\n\rPolicyRequest\x12\"\n\taddPolicy\x18\x01 \x03(\x0b\x32\x0f.PEDM.PolicyAdd\x12(\n\x0cupdatePolicy\x18\x02 \x03(\x0b\x32\x12.PEDM.PolicyUpdate\x12\x14\n\x0cremovePolicy\x18\x03 \x03(\x0c\"6\n\nPolicyLink\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x15\n\rcollectionUid\x18\x02 \x03(\x0c\"E\n\x1aSetPolicyCollectionRequest\x12\'\n\rsetCollection\x18\x01 \x03(\x0b\x32\x10.PEDM.PolicyLink\"W\n\x0f\x43ollectionValue\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ollectionType\x18\x02 \x01(\x05\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\"z\n\x12\x43ollectionLinkData\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x0f\n\x07linkUid\x18\x02 \x01(\x0c\x12*\n\x08linkType\x18\x03 \x01(\x0e\x32\x18.PEDM.CollectionLinkType\x12\x10\n\x08linkData\x18\x04 \x01(\x0c\"\x8c\x01\n\x11\x43ollectionRequest\x12,\n\raddCollection\x18\x01 \x03(\x0b\x32\x15.PEDM.CollectionValue\x12/\n\x10updateCollection\x18\x02 \x03(\x0b\x32\x15.PEDM.CollectionValue\x12\x18\n\x10removeCollection\x18\x03 \x03(\x0c\"{\n\x18SetCollectionLinkRequest\x12/\n\raddCollection\x18\x01 \x03(\x0b\x32\x18.PEDM.CollectionLinkData\x12.\n\x10removeCollection\x18\x02 \x03(\x0b\x32\x14.PEDM.CollectionLink\";\n\x12\x41pprovalExtendData\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x10\n\x08\x65xpireIn\x18\x02 \x01(\x05\"I\n\x15ModifyApprovalRequest\x12\x30\n\x0e\x65xtendApproval\x18\x01 \x03(\x0b\x32\x18.PEDM.ApprovalExtendData\"F\n\x15\x41pprovalActionRequest\x12\x0f\n\x07\x61pprove\x18\x01 \x03(\x0c\x12\x0c\n\x04\x64\x65ny\x18\x02 \x03(\x0c\x12\x0e\n\x06remove\x18\x03 \x03(\x0c\"\xab\x01\n\x0e\x44\x65ploymentNode\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x02 \x01(\x08\x12\x0e\n\x06\x61\x65sKey\x18\x03 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x04 \x01(\x0c\x12\x15\n\rencryptedData\x18\x05 \x01(\x0c\x12\x11\n\tagentData\x18\x06 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x07 \x01(\x03\x12\x10\n\x08modified\x18\x08 \x01(\x03\"\xa8\x01\n\tAgentNode\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12\x11\n\tmachineId\x18\x02 \x01(\t\x12\x15\n\rdeploymentUid\x18\x03 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x04 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x05 \x01(\x08\x12\x15\n\rencryptedData\x18\x06 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x07 \x01(\x03\x12\x10\n\x08modified\x18\x08 \x01(\x03\"\x94\x01\n\nPolicyNode\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x14\n\x0c\x65ncryptedKey\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x10\n\x08modified\x18\x06 \x01(\x03\x12\x10\n\x08\x64isabled\x18\x07 \x01(\x08\"g\n\x0e\x43ollectionNode\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ollectionType\x18\x02 \x01(\x05\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x04 \x01(\x03\"d\n\x0e\x43ollectionLink\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x0f\n\x07linkUid\x18\x02 \x01(\x0c\x12*\n\x08linkType\x18\x03 \x01(\x0e\x32\x18.PEDM.CollectionLinkType\"\x87\x01\n\x12\x41pprovalStatusNode\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x30\n\x0e\x61pprovalStatus\x18\x02 \x01(\x0e\x32\x18.PEDM.ApprovalStatusType\x12\x18\n\x10\x65nterpriseUserId\x18\x03 \x01(\x03\x12\x10\n\x08modified\x18\n \x01(\x03\"\xb3\x01\n\x0c\x41pprovalNode\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x61pprovalType\x18\x02 \x01(\x05\x12\x10\n\x08\x61gentUid\x18\x03 \x01(\x0c\x12\x13\n\x0b\x61\x63\x63ountInfo\x18\x04 \x01(\x0c\x12\x17\n\x0f\x61pplicationInfo\x18\x05 \x01(\x0c\x12\x15\n\rjustification\x18\x06 \x01(\x0c\x12\x10\n\x08\x65xpireIn\x18\x07 \x01(\x05\x12\x0f\n\x07\x63reated\x18\n \x01(\x03\"C\n\rFullSyncToken\x12\x15\n\rstartRevision\x18\x01 \x01(\x03\x12\x0e\n\x06\x65ntity\x18\x02 \x01(\x05\x12\x0b\n\x03key\x18\x03 \x03(\x0c\"$\n\x0cIncSyncToken\x12\x14\n\x0clastRevision\x18\x02 \x01(\x03\"h\n\rPedmSyncToken\x12\'\n\x08\x66ullSync\x18\x02 \x01(\x0b\x32\x13.PEDM.FullSyncTokenH\x00\x12%\n\x07incSync\x18\x03 \x01(\x0b\x32\x12.PEDM.IncSyncTokenH\x00\x42\x07\n\x05token\"/\n\x12GetPedmDataRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\"\xad\x04\n\x13GetPedmDataResponse\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\x12\x12\n\nresetCache\x18\x02 \x01(\x08\x12\x0f\n\x07hasMore\x18\x03 \x01(\x08\x12\x1a\n\x12removedDeployments\x18\n \x03(\x0c\x12\x15\n\rremovedAgents\x18\x0b \x03(\x0c\x12\x17\n\x0fremovedPolicies\x18\x0c \x03(\x0c\x12\x19\n\x11removedCollection\x18\r \x03(\x0c\x12\x33\n\x15removedCollectionLink\x18\x0e \x03(\x0b\x32\x14.PEDM.CollectionLink\x12\x18\n\x10removedApprovals\x18\x0f \x03(\x0c\x12)\n\x0b\x64\x65ployments\x18\x14 \x03(\x0b\x32\x14.PEDM.DeploymentNode\x12\x1f\n\x06\x61gents\x18\x15 \x03(\x0b\x32\x0f.PEDM.AgentNode\x12\"\n\x08policies\x18\x16 \x03(\x0b\x32\x10.PEDM.PolicyNode\x12)\n\x0b\x63ollections\x18\x17 \x03(\x0b\x32\x14.PEDM.CollectionNode\x12,\n\x0e\x63ollectionLink\x18\x18 \x03(\x0b\x32\x14.PEDM.CollectionLink\x12%\n\tapprovals\x18\x19 \x03(\x0b\x32\x12.PEDM.ApprovalNode\x12\x30\n\x0e\x61pprovalStatus\x18\x1a \x03(\x0b\x32\x18.PEDM.ApprovalStatusNode\"<\n\x12PolicyAgentRequest\x12\x11\n\tpolicyUid\x18\x01 \x03(\x0c\x12\x13\n\x0bsummaryOnly\x18\x02 \x01(\x08\";\n\x13PolicyAgentResponse\x12\x12\n\nagentCount\x18\x01 \x01(\x05\x12\x10\n\x08\x61gentUid\x18\x02 \x03(\x0c\"]\n\x16\x41uditCollectionRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\x12\x10\n\x08valueUid\x18\x02 \x03(\x0c\x12\x16\n\x0e\x63ollectionName\x18\x03 \x03(\t\"h\n\x14\x41uditCollectionValue\x12\x16\n\x0e\x63ollectionName\x18\x01 \x01(\t\x12\x10\n\x08valueUid\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x04 \x01(\x03\"q\n\x17\x41uditCollectionResponse\x12*\n\x06values\x18\x01 \x03(\x0b\x32\x1a.PEDM.AuditCollectionValue\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\x12\x19\n\x11\x63ontinuationToken\x18\x03 \x01(\x0c\"H\n\x18GetCollectionLinkRequest\x12,\n\x0e\x63ollectionLink\x18\x01 \x03(\x0b\x32\x14.PEDM.CollectionLink\"Q\n\x19GetCollectionLinkResponse\x12\x34\n\x12\x63ollectionLinkData\x18\x01 \x03(\x0b\x32\x18.PEDM.CollectionLinkData\"\xaa\x01\n\x1bOfflineAgentRegisterRequest\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12\x15\n\rdeploymentUid\x18\x02 \x01(\x0c\x12\x11\n\tpublicKey\x18\x03 \x01(\x0c\x12\x11\n\tmachineId\x18\x04 \x01(\t\x12)\n\ncollection\x18\x05 \x03(\x0b\x32\x15.PEDM.CollectionValue\x12\x11\n\tagentData\x18\x07 \x01(\x0c\"0\n\x1cOfflineAgentRegisterResponse\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\"/\n\x1bOfflineAgentSyncDownRequest\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\"9\n\x1cOfflineAgentSyncDownResponse\x12\x19\n\x11\x65ncryptedSyncData\x18\x01 \x01(\x0c\"?\n\x17GetAgentLastSeenRequest\x12\x12\n\nactiveOnly\x18\x01 \x01(\x08\x12\x10\n\x08\x61gentUid\x18\x02 \x03(\x0c\"3\n\rAgentLastSeen\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12\x10\n\x08lastSeen\x18\x02 \x01(\x03\"A\n\x18GetAgentLastSeenResponse\x12%\n\x08lastSeen\x18\x01 \x03(\x0b\x32\x13.PEDM.AgentLastSeen\"2\n\x1aGetActiveAgentCountRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x03(\x05\">\n\x10\x41\x63tiveAgentCount\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x14\n\x0c\x61\x63tiveAgents\x18\x02 \x01(\x05\";\n\x12\x41\x63tiveAgentFailure\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x0f\n\x07message\x18\x02 \x01(\t\"x\n\x1bGetActiveAgentCountResponse\x12*\n\nagentCount\x18\x01 \x03(\x0b\x32\x16.PEDM.ActiveAgentCount\x12-\n\x0b\x66\x61iledCount\x18\x02 \x03(\x0b\x32\x18.PEDM.ActiveAgentFailure\"\x87\x01\n\x19GetAgentDailyCountRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x03(\x05\x12$\n\tmonthYear\x18\x02 \x01(\x0b\x32\x0f.PEDM.MonthYearH\x00\x12$\n\tdateRange\x18\x03 \x01(\x0b\x32\x0f.PEDM.DateRangeH\x00\x42\x08\n\x06period\"(\n\tMonthYear\x12\r\n\x05month\x18\x01 \x01(\x05\x12\x0c\n\x04year\x18\x02 \x01(\x05\"\'\n\tDateRange\x12\r\n\x05start\x18\x01 \x01(\x03\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x03\"3\n\x0f\x41gentDailyCount\x12\x0c\n\x04\x64\x61te\x18\x01 \x01(\x03\x12\x12\n\nagentCount\x18\x02 \x01(\x05\"V\n\x17\x41gentCountForEnterprise\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12%\n\x06\x63ounts\x18\x02 \x03(\x0b\x32\x15.PEDM.AgentDailyCount\"U\n\x1aGetAgentDailyCountResponse\x12\x37\n\x10\x65nterpriseCounts\x18\x01 \x03(\x0b\x32\x1d.PEDM.AgentCountForEnterprise*j\n\x12\x43ollectionLinkType\x12\r\n\tCLT_OTHER\x10\x00\x12\r\n\tCLT_AGENT\x10\x01\x12\x0e\n\nCLT_POLICY\x10\x02\x12\x12\n\x0e\x43LT_COLLECTION\x10\x03\x12\x12\n\x0e\x43LT_DEPLOYMENT\x10\x04*o\n\x12\x41pprovalStatusType\x12\x13\n\x0f\x41ST_UNSPECIFIED\x10\x00\x12\x10\n\x0c\x41ST_APPROVED\x10\x01\x12\x0e\n\nAST_DENIED\x10\x02\x12\x0f\n\x0b\x41ST_EXPIRED\x10\x03\x12\x11\n\rAST_ESCALATED\x10\x05\x42 \n\x18\x63om.keepersecurity.protoB\x04PEDMb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\npedm.proto\x12\x04PEDM\x1a\x0c\x66older.proto\"O\n\x17PEDMTOTPValidateRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x02 \x01(\x05\x12\x0c\n\x04\x63ode\x18\x03 \x01(\x05\";\n\nPedmStatus\x12\x0b\n\x03key\x18\x01 \x03(\x0c\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x0f\n\x07message\x18\x03 \x01(\t\"\x89\x01\n\x12PedmStatusResponse\x12#\n\taddStatus\x18\x01 \x03(\x0b\x32\x10.PEDM.PedmStatus\x12&\n\x0cupdateStatus\x18\x02 \x03(\x0b\x32\x10.PEDM.PedmStatus\x12&\n\x0cremoveStatus\x18\x03 \x03(\x0b\x32\x10.PEDM.PedmStatus\"4\n\x0e\x44\x65ploymentData\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x65\x63PrivateKey\x18\x02 \x01(\x0c\"\x9a\x01\n\x17\x44\x65ploymentCreateRequest\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61\x65sKey\x18\x02 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x03 \x01(\x0c\x12\x19\n\x11spiffeCertificate\x18\x04 \x01(\x0c\x12\x15\n\rencryptedData\x18\x05 \x01(\x0c\x12\x11\n\tagentData\x18\x06 \x01(\x0c\"\x8d\x01\n\x17\x44\x65ploymentUpdateRequest\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x15\n\rencryptedData\x18\x02 \x01(\x0c\x12)\n\x08\x64isabled\x18\x03 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x19\n\x11spiffeCertificate\x18\x04 \x01(\x0c\"\xa2\x01\n\x17ModifyDeploymentRequest\x12\x34\n\raddDeployment\x18\x01 \x03(\x0b\x32\x1d.PEDM.DeploymentCreateRequest\x12\x37\n\x10updateDeployment\x18\x02 \x03(\x0b\x32\x1d.PEDM.DeploymentUpdateRequest\x12\x18\n\x10removeDeployment\x18\x03 \x03(\x0c\"a\n\x0b\x41gentUpdate\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12)\n\x08\x64isabled\x18\x02 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x15\n\rdeploymentUid\x18\x03 \x01(\x0c\"Q\n\x12ModifyAgentRequest\x12&\n\x0bupdateAgent\x18\x02 \x03(\x0b\x32\x11.PEDM.AgentUpdate\x12\x13\n\x0bremoveAgent\x18\x03 \x03(\x0c\"p\n\tPolicyAdd\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x14\n\x0c\x65ncryptedKey\x18\x04 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x05 \x01(\x08\"v\n\x0cPolicyUpdate\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12)\n\x08\x64isabled\x18\x04 \x01(\x0e\x32\x17.Folder.SetBooleanValue\"s\n\rPolicyRequest\x12\"\n\taddPolicy\x18\x01 \x03(\x0b\x32\x0f.PEDM.PolicyAdd\x12(\n\x0cupdatePolicy\x18\x02 \x03(\x0b\x32\x12.PEDM.PolicyUpdate\x12\x14\n\x0cremovePolicy\x18\x03 \x03(\x0c\"6\n\nPolicyLink\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x15\n\rcollectionUid\x18\x02 \x03(\x0c\"E\n\x1aSetPolicyCollectionRequest\x12\'\n\rsetCollection\x18\x01 \x03(\x0b\x32\x10.PEDM.PolicyLink\"W\n\x0f\x43ollectionValue\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ollectionType\x18\x02 \x01(\x05\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\"z\n\x12\x43ollectionLinkData\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x0f\n\x07linkUid\x18\x02 \x01(\x0c\x12*\n\x08linkType\x18\x03 \x01(\x0e\x32\x18.PEDM.CollectionLinkType\x12\x10\n\x08linkData\x18\x04 \x01(\x0c\"\x8c\x01\n\x11\x43ollectionRequest\x12,\n\raddCollection\x18\x01 \x03(\x0b\x32\x15.PEDM.CollectionValue\x12/\n\x10updateCollection\x18\x02 \x03(\x0b\x32\x15.PEDM.CollectionValue\x12\x18\n\x10removeCollection\x18\x03 \x03(\x0c\"{\n\x18SetCollectionLinkRequest\x12/\n\raddCollection\x18\x01 \x03(\x0b\x32\x18.PEDM.CollectionLinkData\x12.\n\x10removeCollection\x18\x02 \x03(\x0b\x32\x14.PEDM.CollectionLink\";\n\x12\x41pprovalExtendData\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x10\n\x08\x65xpireIn\x18\x02 \x01(\x05\"I\n\x15ModifyApprovalRequest\x12\x30\n\x0e\x65xtendApproval\x18\x01 \x03(\x0b\x32\x18.PEDM.ApprovalExtendData\"F\n\x15\x41pprovalActionRequest\x12\x0f\n\x07\x61pprove\x18\x01 \x03(\x0c\x12\x0c\n\x04\x64\x65ny\x18\x02 \x03(\x0c\x12\x0e\n\x06remove\x18\x03 \x03(\x0c\"\xab\x01\n\x0e\x44\x65ploymentNode\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x02 \x01(\x08\x12\x0e\n\x06\x61\x65sKey\x18\x03 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x04 \x01(\x0c\x12\x15\n\rencryptedData\x18\x05 \x01(\x0c\x12\x11\n\tagentData\x18\x06 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x07 \x01(\x03\x12\x10\n\x08modified\x18\x08 \x01(\x03\"\xa8\x01\n\tAgentNode\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12\x11\n\tmachineId\x18\x02 \x01(\t\x12\x15\n\rdeploymentUid\x18\x03 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x04 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x05 \x01(\x08\x12\x15\n\rencryptedData\x18\x06 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x07 \x01(\x03\x12\x10\n\x08modified\x18\x08 \x01(\x03\"\x94\x01\n\nPolicyNode\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x14\n\x0c\x65ncryptedKey\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x10\n\x08modified\x18\x06 \x01(\x03\x12\x10\n\x08\x64isabled\x18\x07 \x01(\x08\"g\n\x0e\x43ollectionNode\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ollectionType\x18\x02 \x01(\x05\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x04 \x01(\x03\"d\n\x0e\x43ollectionLink\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x0f\n\x07linkUid\x18\x02 \x01(\x0c\x12*\n\x08linkType\x18\x03 \x01(\x0e\x32\x18.PEDM.CollectionLinkType\"\x99\x01\n\x12\x41pprovalStatusNode\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x30\n\x0e\x61pprovalStatus\x18\x02 \x01(\x0e\x32\x18.PEDM.ApprovalStatusType\x12\x18\n\x10\x65nterpriseUserId\x18\x03 \x01(\x03\x12\x10\n\x08\x65xpireIn\x18\x07 \x01(\x05\x12\x10\n\x08modified\x18\n \x01(\x03\"\xb3\x01\n\x0c\x41pprovalNode\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x61pprovalType\x18\x02 \x01(\x05\x12\x10\n\x08\x61gentUid\x18\x03 \x01(\x0c\x12\x13\n\x0b\x61\x63\x63ountInfo\x18\x04 \x01(\x0c\x12\x17\n\x0f\x61pplicationInfo\x18\x05 \x01(\x0c\x12\x15\n\rjustification\x18\x06 \x01(\x0c\x12\x10\n\x08\x65xpireIn\x18\x07 \x01(\x05\x12\x0f\n\x07\x63reated\x18\n \x01(\x03\"C\n\rFullSyncToken\x12\x15\n\rstartRevision\x18\x01 \x01(\x03\x12\x0e\n\x06\x65ntity\x18\x02 \x01(\x05\x12\x0b\n\x03key\x18\x03 \x03(\x0c\"$\n\x0cIncSyncToken\x12\x14\n\x0clastRevision\x18\x02 \x01(\x03\"h\n\rPedmSyncToken\x12\'\n\x08\x66ullSync\x18\x02 \x01(\x0b\x32\x13.PEDM.FullSyncTokenH\x00\x12%\n\x07incSync\x18\x03 \x01(\x0b\x32\x12.PEDM.IncSyncTokenH\x00\x42\x07\n\x05token\"/\n\x12GetPedmDataRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\"\xad\x04\n\x13GetPedmDataResponse\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\x12\x12\n\nresetCache\x18\x02 \x01(\x08\x12\x0f\n\x07hasMore\x18\x03 \x01(\x08\x12\x1a\n\x12removedDeployments\x18\n \x03(\x0c\x12\x15\n\rremovedAgents\x18\x0b \x03(\x0c\x12\x17\n\x0fremovedPolicies\x18\x0c \x03(\x0c\x12\x19\n\x11removedCollection\x18\r \x03(\x0c\x12\x33\n\x15removedCollectionLink\x18\x0e \x03(\x0b\x32\x14.PEDM.CollectionLink\x12\x18\n\x10removedApprovals\x18\x0f \x03(\x0c\x12)\n\x0b\x64\x65ployments\x18\x14 \x03(\x0b\x32\x14.PEDM.DeploymentNode\x12\x1f\n\x06\x61gents\x18\x15 \x03(\x0b\x32\x0f.PEDM.AgentNode\x12\"\n\x08policies\x18\x16 \x03(\x0b\x32\x10.PEDM.PolicyNode\x12)\n\x0b\x63ollections\x18\x17 \x03(\x0b\x32\x14.PEDM.CollectionNode\x12,\n\x0e\x63ollectionLink\x18\x18 \x03(\x0b\x32\x14.PEDM.CollectionLink\x12%\n\tapprovals\x18\x19 \x03(\x0b\x32\x12.PEDM.ApprovalNode\x12\x30\n\x0e\x61pprovalStatus\x18\x1a \x03(\x0b\x32\x18.PEDM.ApprovalStatusNode\"<\n\x12PolicyAgentRequest\x12\x11\n\tpolicyUid\x18\x01 \x03(\x0c\x12\x13\n\x0bsummaryOnly\x18\x02 \x01(\x08\";\n\x13PolicyAgentResponse\x12\x12\n\nagentCount\x18\x01 \x01(\x05\x12\x10\n\x08\x61gentUid\x18\x02 \x03(\x0c\"]\n\x16\x41uditCollectionRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\x12\x10\n\x08valueUid\x18\x02 \x03(\x0c\x12\x16\n\x0e\x63ollectionName\x18\x03 \x03(\t\"h\n\x14\x41uditCollectionValue\x12\x16\n\x0e\x63ollectionName\x18\x01 \x01(\t\x12\x10\n\x08valueUid\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x04 \x01(\x03\"q\n\x17\x41uditCollectionResponse\x12*\n\x06values\x18\x01 \x03(\x0b\x32\x1a.PEDM.AuditCollectionValue\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\x12\x19\n\x11\x63ontinuationToken\x18\x03 \x01(\x0c\"H\n\x18GetCollectionLinkRequest\x12,\n\x0e\x63ollectionLink\x18\x01 \x03(\x0b\x32\x14.PEDM.CollectionLink\"Q\n\x19GetCollectionLinkResponse\x12\x34\n\x12\x63ollectionLinkData\x18\x01 \x03(\x0b\x32\x18.PEDM.CollectionLinkData\"\xaa\x01\n\x1bOfflineAgentRegisterRequest\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12\x15\n\rdeploymentUid\x18\x02 \x01(\x0c\x12\x11\n\tpublicKey\x18\x03 \x01(\x0c\x12\x11\n\tmachineId\x18\x04 \x01(\t\x12)\n\ncollection\x18\x05 \x03(\x0b\x32\x15.PEDM.CollectionValue\x12\x11\n\tagentData\x18\x07 \x01(\x0c\"0\n\x1cOfflineAgentRegisterResponse\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\"/\n\x1bOfflineAgentSyncDownRequest\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\"9\n\x1cOfflineAgentSyncDownResponse\x12\x19\n\x11\x65ncryptedSyncData\x18\x01 \x01(\x0c\"?\n\x17GetAgentLastSeenRequest\x12\x12\n\nactiveOnly\x18\x01 \x01(\x08\x12\x10\n\x08\x61gentUid\x18\x02 \x03(\x0c\"3\n\rAgentLastSeen\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12\x10\n\x08lastSeen\x18\x02 \x01(\x03\"A\n\x18GetAgentLastSeenResponse\x12%\n\x08lastSeen\x18\x01 \x03(\x0b\x32\x13.PEDM.AgentLastSeen\"2\n\x1aGetActiveAgentCountRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x03(\x05\">\n\x10\x41\x63tiveAgentCount\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x14\n\x0c\x61\x63tiveAgents\x18\x02 \x01(\x05\";\n\x12\x41\x63tiveAgentFailure\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x0f\n\x07message\x18\x02 \x01(\t\"x\n\x1bGetActiveAgentCountResponse\x12*\n\nagentCount\x18\x01 \x03(\x0b\x32\x16.PEDM.ActiveAgentCount\x12-\n\x0b\x66\x61iledCount\x18\x02 \x03(\x0b\x32\x18.PEDM.ActiveAgentFailure\"\x87\x01\n\x19GetAgentDailyCountRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x03(\x05\x12$\n\tmonthYear\x18\x02 \x01(\x0b\x32\x0f.PEDM.MonthYearH\x00\x12$\n\tdateRange\x18\x03 \x01(\x0b\x32\x0f.PEDM.DateRangeH\x00\x42\x08\n\x06period\"(\n\tMonthYear\x12\r\n\x05month\x18\x01 \x01(\x05\x12\x0c\n\x04year\x18\x02 \x01(\x05\"\'\n\tDateRange\x12\r\n\x05start\x18\x01 \x01(\x03\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x03\"3\n\x0f\x41gentDailyCount\x12\x0c\n\x04\x64\x61te\x18\x01 \x01(\x03\x12\x12\n\nagentCount\x18\x02 \x01(\x05\"V\n\x17\x41gentCountForEnterprise\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12%\n\x06\x63ounts\x18\x02 \x03(\x0b\x32\x15.PEDM.AgentDailyCount\"U\n\x1aGetAgentDailyCountResponse\x12\x37\n\x10\x65nterpriseCounts\x18\x01 \x03(\x0b\x32\x1d.PEDM.AgentCountForEnterprise*j\n\x12\x43ollectionLinkType\x12\r\n\tCLT_OTHER\x10\x00\x12\r\n\tCLT_AGENT\x10\x01\x12\x0e\n\nCLT_POLICY\x10\x02\x12\x12\n\x0e\x43LT_COLLECTION\x10\x03\x12\x12\n\x0e\x43LT_DEPLOYMENT\x10\x04*o\n\x12\x41pprovalStatusType\x12\x13\n\x0f\x41ST_UNSPECIFIED\x10\x00\x12\x10\n\x0c\x41ST_APPROVED\x10\x01\x12\x0e\n\nAST_DENIED\x10\x02\x12\x0f\n\x0b\x41ST_EXPIRED\x10\x03\x12\x11\n\rAST_ESCALATED\x10\x05\x42 \n\x18\x63om.keepersecurity.protoB\x04PEDMb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -33,10 +33,10 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\004PEDM' - _globals['_COLLECTIONLINKTYPE']._serialized_start=5890 - _globals['_COLLECTIONLINKTYPE']._serialized_end=5996 - _globals['_APPROVALSTATUSTYPE']._serialized_start=5998 - _globals['_APPROVALSTATUSTYPE']._serialized_end=6109 + _globals['_COLLECTIONLINKTYPE']._serialized_start=5908 + _globals['_COLLECTIONLINKTYPE']._serialized_end=6014 + _globals['_APPROVALSTATUSTYPE']._serialized_start=6016 + _globals['_APPROVALSTATUSTYPE']._serialized_end=6127 _globals['_PEDMTOTPVALIDATEREQUEST']._serialized_start=34 _globals['_PEDMTOTPVALIDATEREQUEST']._serialized_end=113 _globals['_PEDMSTATUS']._serialized_start=115 @@ -90,65 +90,65 @@ _globals['_COLLECTIONLINK']._serialized_start=2786 _globals['_COLLECTIONLINK']._serialized_end=2886 _globals['_APPROVALSTATUSNODE']._serialized_start=2889 - _globals['_APPROVALSTATUSNODE']._serialized_end=3024 - _globals['_APPROVALNODE']._serialized_start=3027 - _globals['_APPROVALNODE']._serialized_end=3206 - _globals['_FULLSYNCTOKEN']._serialized_start=3208 - _globals['_FULLSYNCTOKEN']._serialized_end=3275 - _globals['_INCSYNCTOKEN']._serialized_start=3277 - _globals['_INCSYNCTOKEN']._serialized_end=3313 - _globals['_PEDMSYNCTOKEN']._serialized_start=3315 - _globals['_PEDMSYNCTOKEN']._serialized_end=3419 - _globals['_GETPEDMDATAREQUEST']._serialized_start=3421 - _globals['_GETPEDMDATAREQUEST']._serialized_end=3468 - _globals['_GETPEDMDATARESPONSE']._serialized_start=3471 - _globals['_GETPEDMDATARESPONSE']._serialized_end=4028 - _globals['_POLICYAGENTREQUEST']._serialized_start=4030 - _globals['_POLICYAGENTREQUEST']._serialized_end=4090 - _globals['_POLICYAGENTRESPONSE']._serialized_start=4092 - _globals['_POLICYAGENTRESPONSE']._serialized_end=4151 - _globals['_AUDITCOLLECTIONREQUEST']._serialized_start=4153 - _globals['_AUDITCOLLECTIONREQUEST']._serialized_end=4246 - _globals['_AUDITCOLLECTIONVALUE']._serialized_start=4248 - _globals['_AUDITCOLLECTIONVALUE']._serialized_end=4352 - _globals['_AUDITCOLLECTIONRESPONSE']._serialized_start=4354 - _globals['_AUDITCOLLECTIONRESPONSE']._serialized_end=4467 - _globals['_GETCOLLECTIONLINKREQUEST']._serialized_start=4469 - _globals['_GETCOLLECTIONLINKREQUEST']._serialized_end=4541 - _globals['_GETCOLLECTIONLINKRESPONSE']._serialized_start=4543 - _globals['_GETCOLLECTIONLINKRESPONSE']._serialized_end=4624 - _globals['_OFFLINEAGENTREGISTERREQUEST']._serialized_start=4627 - _globals['_OFFLINEAGENTREGISTERREQUEST']._serialized_end=4797 - _globals['_OFFLINEAGENTREGISTERRESPONSE']._serialized_start=4799 - _globals['_OFFLINEAGENTREGISTERRESPONSE']._serialized_end=4847 - _globals['_OFFLINEAGENTSYNCDOWNREQUEST']._serialized_start=4849 - _globals['_OFFLINEAGENTSYNCDOWNREQUEST']._serialized_end=4896 - _globals['_OFFLINEAGENTSYNCDOWNRESPONSE']._serialized_start=4898 - _globals['_OFFLINEAGENTSYNCDOWNRESPONSE']._serialized_end=4955 - _globals['_GETAGENTLASTSEENREQUEST']._serialized_start=4957 - _globals['_GETAGENTLASTSEENREQUEST']._serialized_end=5020 - _globals['_AGENTLASTSEEN']._serialized_start=5022 - _globals['_AGENTLASTSEEN']._serialized_end=5073 - _globals['_GETAGENTLASTSEENRESPONSE']._serialized_start=5075 - _globals['_GETAGENTLASTSEENRESPONSE']._serialized_end=5140 - _globals['_GETACTIVEAGENTCOUNTREQUEST']._serialized_start=5142 - _globals['_GETACTIVEAGENTCOUNTREQUEST']._serialized_end=5192 - _globals['_ACTIVEAGENTCOUNT']._serialized_start=5194 - _globals['_ACTIVEAGENTCOUNT']._serialized_end=5256 - _globals['_ACTIVEAGENTFAILURE']._serialized_start=5258 - _globals['_ACTIVEAGENTFAILURE']._serialized_end=5317 - _globals['_GETACTIVEAGENTCOUNTRESPONSE']._serialized_start=5319 - _globals['_GETACTIVEAGENTCOUNTRESPONSE']._serialized_end=5439 - _globals['_GETAGENTDAILYCOUNTREQUEST']._serialized_start=5442 - _globals['_GETAGENTDAILYCOUNTREQUEST']._serialized_end=5577 - _globals['_MONTHYEAR']._serialized_start=5579 - _globals['_MONTHYEAR']._serialized_end=5619 - _globals['_DATERANGE']._serialized_start=5621 - _globals['_DATERANGE']._serialized_end=5660 - _globals['_AGENTDAILYCOUNT']._serialized_start=5662 - _globals['_AGENTDAILYCOUNT']._serialized_end=5713 - _globals['_AGENTCOUNTFORENTERPRISE']._serialized_start=5715 - _globals['_AGENTCOUNTFORENTERPRISE']._serialized_end=5801 - _globals['_GETAGENTDAILYCOUNTRESPONSE']._serialized_start=5803 - _globals['_GETAGENTDAILYCOUNTRESPONSE']._serialized_end=5888 + _globals['_APPROVALSTATUSNODE']._serialized_end=3042 + _globals['_APPROVALNODE']._serialized_start=3045 + _globals['_APPROVALNODE']._serialized_end=3224 + _globals['_FULLSYNCTOKEN']._serialized_start=3226 + _globals['_FULLSYNCTOKEN']._serialized_end=3293 + _globals['_INCSYNCTOKEN']._serialized_start=3295 + _globals['_INCSYNCTOKEN']._serialized_end=3331 + _globals['_PEDMSYNCTOKEN']._serialized_start=3333 + _globals['_PEDMSYNCTOKEN']._serialized_end=3437 + _globals['_GETPEDMDATAREQUEST']._serialized_start=3439 + _globals['_GETPEDMDATAREQUEST']._serialized_end=3486 + _globals['_GETPEDMDATARESPONSE']._serialized_start=3489 + _globals['_GETPEDMDATARESPONSE']._serialized_end=4046 + _globals['_POLICYAGENTREQUEST']._serialized_start=4048 + _globals['_POLICYAGENTREQUEST']._serialized_end=4108 + _globals['_POLICYAGENTRESPONSE']._serialized_start=4110 + _globals['_POLICYAGENTRESPONSE']._serialized_end=4169 + _globals['_AUDITCOLLECTIONREQUEST']._serialized_start=4171 + _globals['_AUDITCOLLECTIONREQUEST']._serialized_end=4264 + _globals['_AUDITCOLLECTIONVALUE']._serialized_start=4266 + _globals['_AUDITCOLLECTIONVALUE']._serialized_end=4370 + _globals['_AUDITCOLLECTIONRESPONSE']._serialized_start=4372 + _globals['_AUDITCOLLECTIONRESPONSE']._serialized_end=4485 + _globals['_GETCOLLECTIONLINKREQUEST']._serialized_start=4487 + _globals['_GETCOLLECTIONLINKREQUEST']._serialized_end=4559 + _globals['_GETCOLLECTIONLINKRESPONSE']._serialized_start=4561 + _globals['_GETCOLLECTIONLINKRESPONSE']._serialized_end=4642 + _globals['_OFFLINEAGENTREGISTERREQUEST']._serialized_start=4645 + _globals['_OFFLINEAGENTREGISTERREQUEST']._serialized_end=4815 + _globals['_OFFLINEAGENTREGISTERRESPONSE']._serialized_start=4817 + _globals['_OFFLINEAGENTREGISTERRESPONSE']._serialized_end=4865 + _globals['_OFFLINEAGENTSYNCDOWNREQUEST']._serialized_start=4867 + _globals['_OFFLINEAGENTSYNCDOWNREQUEST']._serialized_end=4914 + _globals['_OFFLINEAGENTSYNCDOWNRESPONSE']._serialized_start=4916 + _globals['_OFFLINEAGENTSYNCDOWNRESPONSE']._serialized_end=4973 + _globals['_GETAGENTLASTSEENREQUEST']._serialized_start=4975 + _globals['_GETAGENTLASTSEENREQUEST']._serialized_end=5038 + _globals['_AGENTLASTSEEN']._serialized_start=5040 + _globals['_AGENTLASTSEEN']._serialized_end=5091 + _globals['_GETAGENTLASTSEENRESPONSE']._serialized_start=5093 + _globals['_GETAGENTLASTSEENRESPONSE']._serialized_end=5158 + _globals['_GETACTIVEAGENTCOUNTREQUEST']._serialized_start=5160 + _globals['_GETACTIVEAGENTCOUNTREQUEST']._serialized_end=5210 + _globals['_ACTIVEAGENTCOUNT']._serialized_start=5212 + _globals['_ACTIVEAGENTCOUNT']._serialized_end=5274 + _globals['_ACTIVEAGENTFAILURE']._serialized_start=5276 + _globals['_ACTIVEAGENTFAILURE']._serialized_end=5335 + _globals['_GETACTIVEAGENTCOUNTRESPONSE']._serialized_start=5337 + _globals['_GETACTIVEAGENTCOUNTRESPONSE']._serialized_end=5457 + _globals['_GETAGENTDAILYCOUNTREQUEST']._serialized_start=5460 + _globals['_GETAGENTDAILYCOUNTREQUEST']._serialized_end=5595 + _globals['_MONTHYEAR']._serialized_start=5597 + _globals['_MONTHYEAR']._serialized_end=5637 + _globals['_DATERANGE']._serialized_start=5639 + _globals['_DATERANGE']._serialized_end=5678 + _globals['_AGENTDAILYCOUNT']._serialized_start=5680 + _globals['_AGENTDAILYCOUNT']._serialized_end=5731 + _globals['_AGENTCOUNTFORENTERPRISE']._serialized_start=5733 + _globals['_AGENTCOUNTFORENTERPRISE']._serialized_end=5819 + _globals['_GETAGENTDAILYCOUNTRESPONSE']._serialized_start=5821 + _globals['_GETAGENTDAILYCOUNTRESPONSE']._serialized_end=5906 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/pedm_pb2.pyi b/keepercommander/proto/pedm_pb2.pyi index 52f82d9b3..f94ac72f9 100644 --- a/keepercommander/proto/pedm_pb2.pyi +++ b/keepercommander/proto/pedm_pb2.pyi @@ -322,16 +322,18 @@ class CollectionLink(_message.Message): def __init__(self, collectionUid: _Optional[bytes] = ..., linkUid: _Optional[bytes] = ..., linkType: _Optional[_Union[CollectionLinkType, str]] = ...) -> None: ... class ApprovalStatusNode(_message.Message): - __slots__ = ("approvalUid", "approvalStatus", "enterpriseUserId", "modified") + __slots__ = ("approvalUid", "approvalStatus", "enterpriseUserId", "expireIn", "modified") APPROVALUID_FIELD_NUMBER: _ClassVar[int] APPROVALSTATUS_FIELD_NUMBER: _ClassVar[int] ENTERPRISEUSERID_FIELD_NUMBER: _ClassVar[int] + EXPIREIN_FIELD_NUMBER: _ClassVar[int] MODIFIED_FIELD_NUMBER: _ClassVar[int] approvalUid: bytes approvalStatus: ApprovalStatusType enterpriseUserId: int + expireIn: int modified: int - def __init__(self, approvalUid: _Optional[bytes] = ..., approvalStatus: _Optional[_Union[ApprovalStatusType, str]] = ..., enterpriseUserId: _Optional[int] = ..., modified: _Optional[int] = ...) -> None: ... + def __init__(self, approvalUid: _Optional[bytes] = ..., approvalStatus: _Optional[_Union[ApprovalStatusType, str]] = ..., enterpriseUserId: _Optional[int] = ..., expireIn: _Optional[int] = ..., modified: _Optional[int] = ...) -> None: ... class ApprovalNode(_message.Message): __slots__ = ("approvalUid", "approvalType", "agentUid", "accountInfo", "applicationInfo", "justification", "expireIn", "created") From 450585002df006a0c8cef49cfd110a68346911b5 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Mon, 22 Jun 2026 16:47:35 +0530 Subject: [PATCH 16/18] KC-1314: Add vault-style passphrase generation to Commander CLI (#2164) * Add vault-style passphrase generation with CLI overrides and validation Introduce KeeperPassphraseGenerator using the bundled EFF word list and wire it into generate --passphrase and :passphrase on record-add, record-update, and nsf-record commands. Honor enterprise passphrase-* policy fields with CLI/ overrides for word count, separator, capitals, and digit. Add Vault-aligned passphrase validation in PasswordComplexityEnforcer so passphrases that meet passphrase policy pass record commands without --force, even when random password rules (upper-min, digit-min) would reject them. Includes unit tests for generation, enforcement, and existing NSF coverage. * Fix passphrase separator handling and document allowed separator characters * Made capitals + digit on the first word as default * addressed review comment --- keepercommander/commands/record_edit.py | 22 +- keepercommander/commands/utils.py | 53 ++++- keepercommander/enforcement.py | 91 +++++++- keepercommander/generator.py | 256 +++++++++++++++++++--- unit-tests/test_passphrase_enforcement.py | 76 +++++++ unit-tests/test_passphrase_generator.py | 124 +++++++++++ 6 files changed, 589 insertions(+), 33 deletions(-) create mode 100644 unit-tests/test_passphrase_enforcement.py create mode 100644 unit-tests/test_passphrase_generator.py diff --git a/keepercommander/commands/record_edit.py b/keepercommander/commands/record_edit.py index 10b62b5f9..620c2c9c6 100644 --- a/keepercommander/commands/record_edit.py +++ b/keepercommander/commands/record_edit.py @@ -188,8 +188,12 @@ Value Field type Description Example ==================== =============== =================== ============== $GEN:[alg],[n] password Generates a random password $GEN:dice,5 - Default algorith is rand alg: [rand | dice | crypto] + Default algorith is rand alg: [rand | dice | crypto | passphrase] Optional: password length + Passphrase extras (override $GEN:passphrase,7,_,true,true + policy for generation only): + word_count[,separator][,capitalize][,number] + Separators allowed: - . _ ? ! space $GEN oneTimeCode Generates TOTP URL $GEN:[alg,][enc] keyPair Generates a key pair and $GEN:ec,enc optional passcode alg: [rsa | ec | ed25519], enc @@ -398,7 +402,7 @@ def generate_key_pair(key_type, passphrase): # type: (str, str) -> dict @staticmethod def generate_password(parameters=None, policy=None): # type: (Optional[Sequence[str]], Optional[dict]) -> str if isinstance(parameters, (tuple, list, set)): - algorithm = next((x for x in parameters if x in ('rand', 'dice', 'crypto')), 'rand') + algorithm = next((x for x in parameters if x in ('rand', 'dice', 'crypto', 'passphrase')), 'rand') length = next((x for x in parameters if x.isnumeric()), None) if isinstance(length, str) and len(length) > 0: try: @@ -411,6 +415,20 @@ def generate_password(parameters=None, policy=None): # type: (Optional[Sequenc if algorithm == 'crypto': gen = generator.CryptoPassphraseGenerator() + elif algorithm == 'passphrase': + pp_opts = generator.parse_passphrase_gen_parameters(parameters) + if policy and policy.get('passphrase-allow') is False: + logging.warning('Passphrase generation is disabled by enterprise policy; using random password.') + gen = generator.KeeperPasswordGenerator.create_from_policy( + policy, length_override=pp_opts.word_count or length) + else: + gen = generator.KeeperPassphraseGenerator.create_with_options( + policy, + word_count=pp_opts.word_count if pp_opts.word_count is not None else length, + separator=pp_opts.separator, + capitalize=pp_opts.capitalize, + append_number=pp_opts.append_number, + ) elif algorithm == 'dice': if isinstance(length, int): if length < 1: diff --git a/keepercommander/commands/utils.py b/keepercommander/commands/utils.py index 9980f365c..6f381e0fa 100644 --- a/keepercommander/commands/utils.py +++ b/keepercommander/commands/utils.py @@ -43,7 +43,10 @@ from ..breachwatch import BreachWatch from ..display import bcolors, post_login_summary from ..error import CommandError -from ..generator import KeeperPasswordGenerator, DicewarePasswordGenerator, CryptoPassphraseGenerator +from ..generator import ( + KeeperPasswordGenerator, DicewarePasswordGenerator, CryptoPassphraseGenerator, + KeeperPassphraseGenerator, PASSPHRASE_SEPARATOR_HELP, +) from ..params import KeeperParams, LAST_RECORD_UID, LAST_FOLDER_UID, LAST_SHARED_FOLDER_UID from ..proto import ssocloud_pb2, enterprise_pb2, APIRequest_pb2 from ..security_audit import needs_security_audit, update_security_audit_data @@ -402,6 +405,30 @@ def register_command_info(aliases, command_info): ) passphrase_group = generate_parser.add_argument_group('Keeper Passphrase') +passphrase_group.add_argument('--passphrase', dest='passphrase', action='store_true', + help='Generate a vault-style passphrase from the EFF word list') +passphrase_group.add_argument( + '--pp-separator', '-pps', dest='pp_separator', action='store', + help=f'Word separator (single character, or "space"). Allowed: {PASSPHRASE_SEPARATOR_HELP}. ' + 'Overrides enterprise policy for this command.' +) +passphrase_group.add_argument( + '--pp-capitalize', '-ppc', dest='pp_capitalize', action='store_true', + help='Capitalize the first letter of each word. Overrides enterprise policy for this command.' +) +passphrase_group.add_argument( + '--pp-no-capitalize', dest='pp_capitalize', action='store_false', + help='Do not capitalize words. Overrides enterprise policy for this command.' +) +passphrase_group.add_argument( + '--pp-number', '-ppn', dest='pp_number', action='store_true', + help='Append a digit (0-9) to the first word only. Overrides enterprise policy for this command.' +) +passphrase_group.add_argument( + '--pp-no-number', dest='pp_number', action='store_false', + help='Do not append a digit to the first word. Overrides enterprise policy for this command.' +) +passphrase_group.set_defaults(pp_capitalize=None, pp_number=None) passphrase_group.add_argument('--recoveryphrase', dest='recoveryphrase', action='store_true', help='Generate Generate a 24-word recovery phrase') @@ -2208,7 +2235,29 @@ def execute(self, params, number=None, no_breachwatch=None, if kwargs.get('crypto') is True: kpg = CryptoPassphraseGenerator() - if kwargs.get('recoveryphrase') is True: + elif kwargs.get('passphrase') is True: + from ..enforcement import PasswordComplexityEnforcer + policy = PasswordComplexityEnforcer.get_policy(params) + word_count = length if length != 20 else None + pp_separator = kwargs.get('pp_separator') + if isinstance(pp_separator, str) and pp_separator.lower() in ('space', 'sp'): + pp_separator = ' ' + elif isinstance(pp_separator, str) and pp_separator: + pp_separator = pp_separator[0] + else: + pp_separator = None + if policy and policy.get('passphrase-allow') is False: + logging.warning('Passphrase generation is disabled by enterprise policy; using random password.') + kpg = KeeperPasswordGenerator.create_from_policy(policy, length_override=word_count) + else: + kpg = KeeperPassphraseGenerator.create_with_options( + policy, + word_count=word_count, + separator=pp_separator, + capitalize=kwargs.get('pp_capitalize'), + append_number=kwargs.get('pp_number'), + ) + elif kwargs.get('recoveryphrase') is True: kpg = DicewarePasswordGenerator(24, word_list_file='bip-39.english.txt', delimiter=' ') elif isinstance(kwargs.get('dice_rolls'), int): dice_rolls = kwargs.get('dice_rolls') diff --git a/keepercommander/enforcement.py b/keepercommander/enforcement.py index 4609568f9..2657e6a21 100644 --- a/keepercommander/enforcement.py +++ b/keepercommander/enforcement.py @@ -13,6 +13,7 @@ import json import logging import getpass +import re import threading from datetime import datetime, timedelta from typing import Tuple, Optional, List, Dict, Any, Set @@ -22,6 +23,11 @@ from .display import bcolors from .params import KeeperParams from .error import KeeperApiError, CommandError +from .generator import ( + DEFAULT_PASSPHRASE_WORD_COUNT, DEFAULT_PASSPHRASE_CAPITALIZE, DEFAULT_PASSPHRASE_NUMBER, + PP_SEPARATOR_CHARACTERS, _passphrase_separators_from_policy, + format_passphrase_separators_for_display, +) def _find_enforcement_value(enforcements, key): @@ -422,6 +428,78 @@ def get_policy(cls, params): # type: (KeeperParams) -> Optional[Dict[str, Any] return rules return None + @classmethod + def _normalize_passphrase_separators(cls, policy): # type: (Dict[str, Any]) -> Optional[str] + raw = policy.get('passphrase-separator') + if isinstance(raw, str) and raw.strip(): + allowed = _passphrase_separators_from_policy(raw.strip()) + return allowed or None + return PP_SEPARATOR_CHARACTERS + + @classmethod + def validate_passphrase(cls, password, policy): # type: (str, Dict[str, Any]) -> List[str] + """Validate password as a vault-style passphrase per policy passphrase-* fields.""" + failures = [] # type: List[str] + if policy.get('passphrase-allow') is False: + return failures + + allowed_seps = cls._normalize_passphrase_separators(policy) + if not allowed_seps: + failures.append( + 'Passphrase cannot meet separator criteria set by policy. ' + f'Allowed: {format_passphrase_separators_for_display(PP_SEPARATOR_CHARACTERS)}.') + return failures + + separator = next((ch for ch in allowed_seps if ch in password), None) + if separator is None: + # Allow CLI-selected separators even when not listed in policy. + separator = next((ch for ch in PP_SEPARATOR_CHARACTERS if ch in password), None) + if separator is None: + failures.append( + 'Passphrase must contain an allowed separator character. ' + f'Allowed: {format_passphrase_separators_for_display(PP_SEPARATOR_CHARACTERS)}.') + return failures + + words = password.split(separator) + if not words or any(not word for word in words): + failures.append('Passphrase contains an empty word.') + return failures + + min_words = _coerce_int(policy.get('passphrase-length')) or DEFAULT_PASSPHRASE_WORD_COUNT + if len(words) < min_words: + failures.append( + f'Passphrase must contain at least {min_words} words (got {len(words)}).') + + capitalize = bool(policy.get('passphrase-capitalize', DEFAULT_PASSPHRASE_CAPITALIZE)) + append_number = bool(policy.get('passphrase-number', DEFAULT_PASSPHRASE_NUMBER)) + + if capitalize: + word_re = re.compile(r'^[A-Z][a-z]{2,}$') + if append_number: + first_re = re.compile(r'^[A-Z][a-z]{2,}[0-9]$') + else: + first_re = re.compile(r'^[A-Z][a-z]{2,}[0-9]?$') + else: + word_re = re.compile(r'^[A-Za-z]{3,}$') + if append_number: + first_re = re.compile(r'^[A-Za-z]{3,}[0-9]$') + else: + first_re = re.compile(r'^[A-Za-z]{3,}[0-9]?$') + + for index, word in enumerate(words): + pattern = first_re if index == 0 else word_re + if not pattern.match(word): + if index == 0 and append_number: + failures.append('First passphrase word must end with a digit (0-9).') + elif capitalize: + failures.append( + 'Each passphrase word must start with a capital letter and contain at least 3 letters.') + else: + failures.append('Each passphrase word must contain at least 3 letters.') + break + + return failures + @classmethod def validate_password(cls, password, policy): # type: (str, Dict[str, Any]) -> List[str] failures = [] # type: List[str] @@ -454,7 +532,18 @@ def validate_password(cls, password, policy): # type: (str, Dict[str, Any]) -> failures.append( f'Password must contain at least {required} special character(s) (got {count}).') - return failures + if not failures: + return [] + + # Vault re-validates as a passphrase when random password rules fail. + if policy.get('passphrase-allow') is False: + return failures + + passphrase_failures = cls.validate_passphrase(password, policy) + if not passphrase_failures: + return [] + + return passphrase_failures @classmethod def validate_record(cls, params, source): # type: (KeeperParams, Any) -> List[str] diff --git a/keepercommander/generator.py b/keepercommander/generator.py index e566d0187..b32adef10 100644 --- a/keepercommander/generator.py +++ b/keepercommander/generator.py @@ -15,7 +15,7 @@ import secrets import string from secrets import choice -from typing import Optional, List, Iterator +from typing import Optional, List, Iterator, Sequence from collections import namedtuple from . import crypto @@ -24,8 +24,28 @@ DEFAULT_PASSWORD_LENGTH = 20 PW_SPECIAL_CHARACTERS = '!@#$%()+;<>=?[]{}^.,' +PP_SEPARATOR_CHARACTERS = '-._?! ' +DEFAULT_PASSPHRASE_SEPARATOR = '-' +DEFAULT_PASSPHRASE_WORD_COUNT = 5 +DEFAULT_PASSPHRASE_CAPITALIZE = True +DEFAULT_PASSPHRASE_NUMBER = True +DEFAULT_DICEWARE_WORDLIST = 'diceware.wordlist.asc.txt' +PASSPHRASE_SEPARATOR_HELP = '- . _ ? ! space' + + +def format_passphrase_separators_for_display(separators=None): + # type: (Optional[str]) -> str + """Human-readable list of allowed passphrase separator characters.""" + if not separators: + separators = PP_SEPARATOR_CHARACTERS + parts = [] # type: List[str] + for ch in separators: + parts.append('space' if ch == ' ' else ch) + return ', '.join(parts) PasswordStrength = namedtuple('PasswordStrength', 'length caps lower digits symbols') +PassphraseGenOptions = namedtuple( + 'PassphraseGenOptions', ('word_count', 'separator', 'capitalize', 'append_number')) def get_password_strength(password): # type: (str) -> PasswordStrength @@ -153,37 +173,133 @@ def create_from_policy(cls, policy, length_override=None): ) +def _normalize_passphrase_separator(separator): + # type: (Optional[str]) -> str + if not separator: + return DEFAULT_PASSPHRASE_SEPARATOR + if separator == '\u2423': # OPEN BOX (Vault UI glyph for space) + return ' ' + return separator[0] + + +def _passphrase_separators_from_policy(policy_sep): + # type: (str) -> str + """Return allowed separators in Vault order (see getPasswordRules.ts).""" + normalized = policy_sep.replace('\u2423', ' ') + allowed = '' + for ch in PP_SEPARATOR_CHARACTERS: + if ch in normalized: + allowed += ch + return allowed + + +def _default_passphrase_separator_from_policy(policy_sep): + # type: (Optional[str]) -> str + """Pick the default generation separator matching Vault / PowerCommander.""" + if not policy_sep or not isinstance(policy_sep, str) or not policy_sep.strip(): + return DEFAULT_PASSPHRASE_SEPARATOR + allowed = _passphrase_separators_from_policy(policy_sep.strip()) + return allowed[0] if allowed else DEFAULT_PASSPHRASE_SEPARATOR + + +def _parse_gen_bool(value): + # type: (str) -> Optional[bool] + normalized = value.strip().lower() + if normalized in ('true', '1', 'yes', 'on'): + return True + if normalized in ('false', '0', 'no', 'off'): + return False + return None + + +def parse_passphrase_gen_parameters(parameters): + # type: (Optional[Sequence[str]]) -> PassphraseGenOptions + """Parse $GEN:passphrase optional parameters. + + Format: $GEN:passphrase[,word_count][,separator][,capitalize][,number] + Examples: + $GEN:passphrase,7 + $GEN:passphrase,7,_ + $GEN:passphrase,7,_,true,true + $GEN:passphrase,7,space,false,true + """ + if not parameters: + return PassphraseGenOptions(None, None, None, None) + + extras = [p.strip() for p in parameters if p.strip() and p.strip() != 'passphrase'] + word_count = None + separator = None + capitalize = None + append_number = None + idx = 0 + + if idx < len(extras) and extras[idx].isdigit(): + word_count = int(extras[idx]) + idx += 1 + + if idx < len(extras): + candidate = extras[idx] + if _parse_gen_bool(candidate) is None: + if candidate.lower() in ('space', 'sp'): + separator = ' ' + else: + separator = _normalize_passphrase_separator(candidate) + idx += 1 + + if idx < len(extras): + parsed = _parse_gen_bool(extras[idx]) + if parsed is not None: + capitalize = parsed + idx += 1 + + if idx < len(extras): + parsed = _parse_gen_bool(extras[idx]) + if parsed is not None: + append_number = parsed + + return PassphraseGenOptions(word_count, separator, capitalize, append_number) + + +def _resolve_wordlist_path(word_list_file=None): + # type: (Optional[str]) -> str + if word_list_file: + dice_path = os.path.join(os.path.dirname(__file__), 'resources', word_list_file) + if not os.path.isfile(dice_path): + dice_path = os.path.expanduser(word_list_file) + else: + dice_path = os.path.join(os.path.dirname(__file__), 'resources', DEFAULT_DICEWARE_WORDLIST) + return dice_path + + +def _load_wordlist(word_list_file=None): + # type: (Optional[str]) -> List[str] + dice_path = _resolve_wordlist_path(word_list_file) + if not os.path.isfile(dice_path): + raise Exception(f'Word list file \"{dice_path}\" not found.') + + vocabulary = [] # type: List[str] + unique_words = set() + with open(dice_path, 'r', encoding='utf-8') as dw: + for line in dw: + line = line.strip() + if not line or line.startswith('--'): + continue + if line.lower().startswith('source url:') or line.lower().startswith('title:'): + continue + parts = line.split() + word = parts[1] if len(parts) >= 2 else parts[0] + vocabulary.append(word) + unique_words.add(word.lower()) + if len(vocabulary) != len(unique_words): + raise Exception(f'Word list file \"{dice_path}\" contains non-unique words.') + return vocabulary + + class DicewarePasswordGenerator(PasswordGenerator): def __init__(self, number_of_rolls, word_list_file=None, delimiter=' '): # type: (int, Optional[str], str) -> None self._number_of_rolls = number_of_rolls if number_of_rolls > 0 else 5 self.delimiter = delimiter - - if word_list_file: - dice_path = os.path.join(os.path.dirname(__file__), 'resources', word_list_file) - if not os.path.isfile(dice_path): - dice_path = os.path.expanduser(word_list_file) - else: - dice_path = os.path.join(os.path.dirname(__file__), 'resources', 'diceware.wordlist.asc.txt') - self._vocabulary = None # type: Optional[List[str]] - if os.path.isfile(dice_path): - with open(dice_path, 'r', encoding='utf-8') as dw: - self._vocabulary = [] - line_count = 0 - unique_words = set() - for line in dw.readlines(): - if not line: - continue - if line.startswith('--'): - continue - line_count += 1 - words = [x.strip() for x in line.split()] - word = words[1] if len(words) >= 2 else words[0] - self._vocabulary.append(word) - unique_words.add(word.lower()) - if line_count != len(unique_words): - raise Exception(f'Word list file \"{dice_path}\" contains non-unique words.') - else: - raise Exception(f'Word list file \"{dice_path}\" not found.') + self._vocabulary = _load_wordlist(word_list_file) def generate(self): if not self._vocabulary: @@ -194,6 +310,90 @@ def generate(self): return self.delimiter.join(words) +class KeeperPassphraseGenerator(PasswordGenerator): + """Vault-style passphrase generator using the bundled EFF large word list.""" + + def __init__(self, word_count=DEFAULT_PASSPHRASE_WORD_COUNT, separator=DEFAULT_PASSPHRASE_SEPARATOR, + capitalize=DEFAULT_PASSPHRASE_CAPITALIZE, append_number=DEFAULT_PASSPHRASE_NUMBER, + word_list_file=None): + # type: (int, str, bool, bool, Optional[str]) -> None + if isinstance(word_count, int): + if word_count < 1: + word_count = 1 + elif word_count > 40: + word_count = 40 + else: + word_count = DEFAULT_PASSPHRASE_WORD_COUNT + self.word_count = word_count + self.separator = _normalize_passphrase_separator(separator) + self.capitalize = capitalize + self.append_number = append_number + self._vocabulary = _load_wordlist(word_list_file) + + def generate(self): + if not self._vocabulary: + raise Exception('Passphrase word list was not loaded') + + passphrase = '' + first_word = True + for _ in range(self.word_count): + word = secrets.choice(self._vocabulary) + if self.capitalize and word: + word = word[0].upper() + word[1:] + if self.append_number and first_word: + word += str(secrets.randbelow(10)) + if not first_word: + passphrase += self.separator + passphrase += word + first_word = False + return passphrase + + @classmethod + def create_with_options(cls, policy=None, word_count=None, separator=None, + capitalize=None, append_number=None): + # type: (Optional[dict], Optional[int], Optional[str], Optional[bool], Optional[bool]) -> KeeperPassphraseGenerator + """Build a generator from CLI/$GEN overrides with optional policy defaults.""" + wc = word_count + if wc is None: + if policy: + wc = policy.get('passphrase-length', DEFAULT_PASSPHRASE_WORD_COUNT) + else: + wc = DEFAULT_PASSPHRASE_WORD_COUNT + + sep = separator + if sep is not None and sep not in PP_SEPARATOR_CHARACTERS: + logging.warning( + 'Ignoring invalid passphrase separator %r. Allowed: %s.', + sep, format_passphrase_separators_for_display()) + sep = None + if sep is None: + if policy: + policy_sep = policy.get('passphrase-separator') + sep = _default_passphrase_separator_from_policy( + policy_sep if isinstance(policy_sep, str) else None) + else: + sep = DEFAULT_PASSPHRASE_SEPARATOR + + cap = capitalize + if cap is None: + cap = DEFAULT_PASSPHRASE_CAPITALIZE + + num = append_number + if num is None: + num = DEFAULT_PASSPHRASE_NUMBER + + return cls(word_count=wc, separator=sep, capitalize=cap, append_number=num) + + @classmethod + def create_from_policy(cls, policy, length_override=None, separator_override=None): + # type: (dict, Optional[int], Optional[str]) -> KeeperPassphraseGenerator + return cls.create_with_options( + policy, + word_count=length_override, + separator=separator_override, + ) + + class CryptoPassphraseGenerator(PasswordGenerator): def __init__(self): self._vocabulary = None # type: Optional[List[str]] diff --git a/unit-tests/test_passphrase_enforcement.py b/unit-tests/test_passphrase_enforcement.py new file mode 100644 index 000000000..bc16f593c --- /dev/null +++ b/unit-tests/test_passphrase_enforcement.py @@ -0,0 +1,76 @@ +from unittest import TestCase + +from keepercommander.enforcement import PasswordComplexityEnforcer + + +STRICT_RANDOM_POLICY = { + 'length': 8, + 'upper-use': True, + 'upper-min': 5, + 'digit-use': True, + 'digit-min': 3, + 'passphrase-allow': True, + 'passphrase-length': 5, + 'passphrase-capitalize': False, + 'passphrase-number': False, + 'passphrase-separator': '-', +} + + +class TestPassphraseEnforcement(TestCase): + + def test_passphrase_accepted_when_random_rules_fail(self): + password = 'alpha-bravo-charlie-delta-echo' + failures = PasswordComplexityEnforcer.validate_password(password, STRICT_RANDOM_POLICY) + self.assertEqual(failures, []) + + def test_passphrase_with_capitalized_words_and_one_digit_accepted(self): + policy = dict(STRICT_RANDOM_POLICY) + policy.update({ + 'passphrase-length': 7, + 'passphrase-capitalize': True, + 'passphrase-number': True, + 'passphrase-separator': '_', + }) + password = 'Alpha7_Bravo_Charlie_Delta_Echo_Foxtrot_Golf' + failures = PasswordComplexityEnforcer.validate_password(password, policy) + self.assertEqual(failures, []) + + def test_random_rules_apply_when_passphrase_disabled(self): + policy = dict(STRICT_RANDOM_POLICY) + policy['passphrase-allow'] = False + password = 'alpha-bravo-charlie-delta-echo' + failures = PasswordComplexityEnforcer.validate_password(password, policy) + self.assertTrue(any('uppercase' in f for f in failures)) + self.assertTrue(any('digit' in f for f in failures)) + + def test_passphrase_with_cli_style_digit_when_policy_number_off(self): + policy = dict(STRICT_RANDOM_POLICY) + policy['passphrase-number'] = False + password = 'Alpha7_Bravo_Charlie_Delta_Echo_Foxtrot_Golf' + failures = PasswordComplexityEnforcer.validate_password(password, policy) + self.assertEqual(failures, []) + + def test_passphrase_accepts_underscore_separator_not_in_policy(self): + policy = dict(STRICT_RANDOM_POLICY) + policy['passphrase-separator'] = '-' + password = 'alpha_bravo_charlie_delta_echo' + failures = PasswordComplexityEnforcer.validate_password(password, policy) + self.assertEqual(failures, []) + + def test_invalid_separator_error_lists_allowed_characters(self): + policy = dict(STRICT_RANDOM_POLICY) + password = 'alpha~bravo~charlie~delta~echo' + failures = PasswordComplexityEnforcer.validate_password(password, policy) + self.assertTrue(any('Allowed:' in f and 'space' in f for f in failures)) + + def test_invalid_passphrase_returns_passphrase_errors(self): + policy = dict(STRICT_RANDOM_POLICY) + password = 'ab-cd-ef' + failures = PasswordComplexityEnforcer.validate_password(password, policy) + self.assertTrue(any('word' in f.lower() for f in failures)) + + def test_random_password_still_validated(self): + password = 'ABCDE123!!!' + failures = PasswordComplexityEnforcer.validate_password(password, STRICT_RANDOM_POLICY) + self.assertEqual(failures, []) diff --git a/unit-tests/test_passphrase_generator.py b/unit-tests/test_passphrase_generator.py new file mode 100644 index 000000000..7bffb477b --- /dev/null +++ b/unit-tests/test_passphrase_generator.py @@ -0,0 +1,124 @@ +from unittest import TestCase, mock + +from keepercommander import generator + + +class TestKeeperPassphraseGenerator(TestCase): + + def test_default_generates_five_hyphen_separated_words(self): + gen = generator.KeeperPassphraseGenerator() + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie', 'delta', 'echo']): + with mock.patch('secrets.randbelow', return_value=3): + result = gen.generate() + self.assertEqual(result, 'Alpha3-Bravo-Charlie-Delta-Echo') + + def test_does_not_shuffle_words_like_diceware(self): + gen = generator.KeeperPassphraseGenerator( + word_count=3, separator=' ', capitalize=False, append_number=False) + with mock.patch('secrets.choice', side_effect=['one', 'two', 'three']): + result = gen.generate() + self.assertEqual(result, 'one two three') + + def test_capitalize_and_number_apply_to_first_word_only(self): + gen = generator.KeeperPassphraseGenerator( + word_count=2, separator='-', capitalize=True, append_number=True) + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo']): + with mock.patch('secrets.randbelow', return_value=7): + result = gen.generate() + self.assertEqual(result, 'Alpha7-Bravo') + + def test_create_from_policy_honors_passphrase_fields(self): + gen = generator.KeeperPassphraseGenerator.create_from_policy({ + 'passphrase-length': 3, + 'passphrase-separator': '-', + 'passphrase-capitalize': True, + 'passphrase-number': True, + }) + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie']): + with mock.patch('secrets.randbelow', return_value=4): + result = gen.generate() + self.assertEqual(result, 'Alpha4-Bravo-Charlie') + + def test_parse_passphrase_gen_parameters(self): + opts = generator.parse_passphrase_gen_parameters(['passphrase', '7', '_', 'true', 'false']) + self.assertEqual(opts.word_count, 7) + self.assertEqual(opts.separator, '_') + self.assertTrue(opts.capitalize) + self.assertFalse(opts.append_number) + + def test_create_with_options_overrides_policy(self): + policy = { + 'passphrase-length': 5, + 'passphrase-separator': '-', + 'passphrase-capitalize': False, + 'passphrase-number': False, + } + gen = generator.KeeperPassphraseGenerator.create_with_options( + policy, word_count=3, separator='_', capitalize=True, append_number=True) + self.assertEqual(gen.word_count, 3) + self.assertEqual(gen.separator, '_') + self.assertTrue(gen.capitalize) + self.assertTrue(gen.append_number) + + def test_commander_defaults_override_policy_capitalize_and_number(self): + gen = generator.KeeperPassphraseGenerator.create_with_options({ + 'passphrase-capitalize': False, + 'passphrase-number': False, + }) + self.assertTrue(gen.capitalize) + self.assertTrue(gen.append_number) + + def test_policy_separator_uses_vault_order_not_raw_first_char(self): + gen = generator.KeeperPassphraseGenerator.create_with_options({ + 'passphrase-separator': '!._?-', + }) + self.assertEqual(gen.separator, '-') + + def test_invalid_separator_override_falls_back_to_default(self): + gen = generator.KeeperPassphraseGenerator.create_with_options( + None, separator='~', word_count=3) + self.assertEqual(gen.separator, '-') + + def test_loads_bundled_eff_wordlist(self): + words = generator._load_wordlist() + self.assertEqual(len(words), 7776) + self.assertEqual(words[0], 'abacus') + + +class TestGeneratePasswordPassphrase(TestCase): + + def test_record_edit_generate_password_passphrase(self): + from keepercommander.commands.record_edit import RecordEditMixin + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie', 'delta', 'echo']): + with mock.patch('secrets.randbelow', return_value=5): + result = RecordEditMixin.generate_password(['passphrase']) + self.assertEqual(result, 'Alpha5-Bravo-Charlie-Delta-Echo') + + def test_record_edit_passphrase_uses_policy_when_allowed(self): + from keepercommander.commands.record_edit import RecordEditMixin + policy = { + 'passphrase-allow': True, + 'passphrase-length': 2, + 'passphrase-separator': '_', + 'passphrase-capitalize': True, + 'passphrase-number': False, + } + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo']): + with mock.patch('secrets.randbelow', return_value=4): + result = RecordEditMixin.generate_password(['passphrase'], policy=policy) + self.assertEqual(result, 'Alpha4_Bravo') + + def test_record_edit_passphrase_cli_overrides_policy(self): + from keepercommander.commands.record_edit import RecordEditMixin + policy = { + 'passphrase-allow': True, + 'passphrase-length': 2, + 'passphrase-separator': '-', + 'passphrase-capitalize': False, + 'passphrase-number': False, + } + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie']): + with mock.patch('secrets.randbelow', return_value=9): + result = RecordEditMixin.generate_password( + ['passphrase', '3', '_', 'true', 'true'], policy=policy) + self.assertEqual(result, 'Alpha9_Bravo_Charlie') From de79fcdc3d54834cfc343cc6c522bcb29515a277 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Tue, 23 Jun 2026 13:24:49 +0530 Subject: [PATCH 17/18] =?UTF-8?q?Strictly=20validate=20passphrase=20word?= =?UTF-8?q?=20count=20(5=E2=80=939),=20separator,=20and=20true/false=20par?= =?UTF-8?q?ameters;=20reject=20unknown=20=20algorithms,=20trailing=20comma?= =?UTF-8?q?s,=20and=20invalid=20separators=20instead=20of=20silently=20fal?= =?UTF-8?q?ling=20back.=20Block=20record-add/update=20on=20=20errors=20eve?= =?UTF-8?q?n=20with=20--force.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nested_share_folder/record_commands.py | 18 +- keepercommander/commands/record_edit.py | 79 +++++++-- keepercommander/commands/utils.py | 10 +- keepercommander/enforcement.py | 4 + keepercommander/generator.py | 164 +++++++++++++----- unit-tests/test_passphrase_enforcement.py | 8 + unit-tests/test_passphrase_generator.py | 149 +++++++++++++--- 7 files changed, 350 insertions(+), 82 deletions(-) diff --git a/keepercommander/commands/nested_share_folder/record_commands.py b/keepercommander/commands/nested_share_folder/record_commands.py index 31663ac3b..6fde8bc25 100644 --- a/keepercommander/commands/nested_share_folder/record_commands.py +++ b/keepercommander/commands/nested_share_folder/record_commands.py @@ -77,8 +77,13 @@ def execute(self, params, **kwargs): folder_uid = self._resolve_folder(params, kwargs.get('folder_uid')) data = self._build_record_data(params, record_type, title, notes, record_fields) + if self.abort_if_errors(): + return self._check_password_policy(params, data, **kwargs) + if self.abort_if_errors(): + return + if self.warnings: for w in self.warnings: logging.warning(w) @@ -205,8 +210,14 @@ def _resolve_field_value(self, parsed): return action_params[0] if action_params else None action_params.clear() if self.is_generate_value(raw, action_params): + if self.warn_wrong_password_gen_field(parsed): + return None if parsed.type == 'password': - return self.generate_password(action_params, policy=self._password_policy) + password, gen_error = self.generate_password(action_params, policy=self._password_policy) + if gen_error: + self.on_error(gen_error) + return None + return password if parsed.type in ('oneTimeCode', 'otp'): return self.generate_totp_url() return raw @@ -238,6 +249,8 @@ def execute(self, params, **kwargs): for spec in [f.strip() for f in kwargs.get('fields', []) if f.strip()]: try: parsed = RecordEditMixin.parse_field(spec) + if self.warn_wrong_password_gen_field(parsed): + continue value = self._resolve_field_value(parsed) if value is None: continue @@ -250,6 +263,9 @@ def execute(self, params, **kwargs): except ValueError as e: raise CommandError('nsf-record-update', f'Invalid field specification: {e}') + if self.abort_if_errors(): + return + if self.warnings: for w in self.warnings: logging.warning(w) diff --git a/keepercommander/commands/record_edit.py b/keepercommander/commands/record_edit.py index 620c2c9c6..6836a853a 100644 --- a/keepercommander/commands/record_edit.py +++ b/keepercommander/commands/record_edit.py @@ -192,7 +192,7 @@ Optional: password length Passphrase extras (override $GEN:passphrase,7,_,true,true policy for generation only): - word_count[,separator][,capitalize][,number] + word_count(5-9)[,separator][,capitalize][,number] Separators allowed: - . _ ? ! space $GEN oneTimeCode Generates TOTP URL $GEN:[alg,][enc] keyPair Generates a key pair and $GEN:ec,enc @@ -234,20 +234,50 @@ ParsedFieldValue = collections.namedtuple('ParsedFieldValue', ['section', 'type', 'label', 'value']) +PASSWORD_GEN_FIELD_LABELS = frozenset(('password', 'passphrase', 'pwd', 'secret')) class RecordEditMixin: def __init__(self): self.warnings = [] + self.errors = [] self._password_policy = None # type: Optional[Dict[str, Any]] def on_warning(self, message): if message: self.warnings.append(message) + def on_error(self, message): + if message: + self.errors.append(message) + + def abort_if_errors(self): + # type: () -> bool + """Log command errors and return True when execution must stop.""" + if not self.errors: + return False + for error in self.errors: + logging.warning(error) + self.errors.clear() + return True + def on_info(self, message): logging.info(message) + def warn_wrong_password_gen_field(self, parsed_field): + # type: (ParsedFieldValue) -> bool + """Warn when $GEN is used with a label like Password= instead of password=.""" + if not parsed_field.value or not parsed_field.value.startswith('$GEN'): + return False + if parsed_field.type == 'password': + return False + if (parsed_field.label or '').lower() not in PASSWORD_GEN_FIELD_LABELS: + return False + self.on_error( + f'Use lowercase field type password=\'{parsed_field.value}\' ' + f'instead of {parsed_field.label}=... ($GEN only works on password fields).') + return True + @staticmethod def parse_field(field): # type: (str) -> ParsedFieldValue if not isinstance(field, str): @@ -296,7 +326,11 @@ def assign_legacy_fields(self, record, fields): elif parsed_field.type == 'password': action_params.clear() if self.is_generate_value(parsed_field.value, action_params): - record.password = self.generate_password(action_params, policy=self._password_policy) + password, gen_error = self.generate_password(action_params, policy=self._password_policy) + if gen_error: + self.on_error(gen_error) + elif password is not None: + record.password = password elif self.is_base64_value(parsed_field.value, action_params): if action_params: record.password = action_params[0] @@ -346,7 +380,8 @@ def is_generate_value(value, parameters): # type: (str, List[str]) -> Optiona if value.startswith(':'): gen_parameters = value[1:] if gen_parameters and isinstance(parameters, list): - parameters.extend((x.strip() for x in gen_parameters.split(','))) + for part in gen_parameters.split(','): + parameters.append(part.strip() if part.strip() else '') return True def is_base64_value(self, value, parameters): # type: (str, List[str]) -> Optional[bool] @@ -400,23 +435,27 @@ def generate_key_pair(key_type, passphrase): # type: (str, str) -> dict } @staticmethod - def generate_password(parameters=None, policy=None): # type: (Optional[Sequence[str]], Optional[dict]) -> str + def generate_password(parameters=None, policy=None): # type: (Optional[Sequence[str]], Optional[dict]) -> tuple + algorithm, error = generator.resolve_gen_password_algorithm( + parameters if isinstance(parameters, (tuple, list, set)) else None) + if error: + return None, error + + length = None if isinstance(parameters, (tuple, list, set)): - algorithm = next((x for x in parameters if x in ('rand', 'dice', 'crypto', 'passphrase')), 'rand') length = next((x for x in parameters if x.isnumeric()), None) if isinstance(length, str) and len(length) > 0: try: length = int(length) except ValueError: pass - else: - algorithm = 'rand' - length = None if algorithm == 'crypto': gen = generator.CryptoPassphraseGenerator() elif algorithm == 'passphrase': - pp_opts = generator.parse_passphrase_gen_parameters(parameters) + pp_opts, pp_error = generator.parse_passphrase_gen_parameters(parameters) + if pp_error: + return None, pp_error if policy and policy.get('passphrase-allow') is False: logging.warning('Passphrase generation is disabled by enterprise policy; using random password.') gen = generator.KeeperPasswordGenerator.create_from_policy( @@ -450,7 +489,7 @@ def generate_password(parameters=None, policy=None): # type: (Optional[Sequenc else: length = 20 gen = generator.KeeperPasswordGenerator(length=length) - return gen.generate() + return gen.generate(), None @staticmethod def generate_totp_url(): @@ -569,6 +608,8 @@ def assign_typed_fields(self, record, fields): parsed_fields = collections.deque(fields) while len(parsed_fields) > 0: parsed_field = parsed_fields.popleft() + if self.warn_wrong_password_gen_field(parsed_field): + continue field_type = parsed_field.type or 'text' field_label = parsed_field.label or '' skip_validation = not parsed_field.value or parsed_field.value.startswith('$JSON') @@ -626,12 +667,20 @@ def assign_typed_fields(self, record, fields): action_params = [] if self.is_generate_value(parsed_field.value, action_params): if record_field.type == 'password': - value = self.generate_password(action_params, policy=self._password_policy) + value, gen_error = self.generate_password(action_params, policy=self._password_policy) + if gen_error: + self.on_error(gen_error) + continue elif record_field.type in ('oneTimeCode', 'otp'): value = self.generate_totp_url() elif record_field.type in ('keyPair', 'privateKey'): should_encrypt = 'enc' in action_params - passphrase = self.generate_password() if should_encrypt else None + passphrase = None + if should_encrypt: + passphrase, gen_error = self.generate_password() + if gen_error: + self.on_error(gen_error) + continue key_type = next((x for x in action_params if x in ('rsa', 'ec', 'ed25519')), 'rsa') value = self.generate_key_pair(key_type, passphrase) if passphrase: @@ -907,6 +956,9 @@ def execute(self, params, **kwargs): record.fields.append(field) self.assign_typed_fields(record, record_fields) + if self.abort_if_errors(): + return + record_uid = str(kwargs.get('record_uid', '')) if RecordV3.is_valid_ref_uid(record_uid): record.record_uid = record_uid @@ -1299,6 +1351,9 @@ def execute(self, params, **kwargs): else: raise CommandError('record-update', f'Record \"{record_name}\" can not be edited.') + if self.abort_if_errors(): + return + if isinstance(record, vault.TypedRecord): pw_failures = PasswordComplexityEnforcer.validate_record(params, record) for f in pw_failures: diff --git a/keepercommander/commands/utils.py b/keepercommander/commands/utils.py index 6f381e0fa..cf5f2b336 100644 --- a/keepercommander/commands/utils.py +++ b/keepercommander/commands/utils.py @@ -2240,10 +2240,12 @@ def execute(self, params, number=None, no_breachwatch=None, policy = PasswordComplexityEnforcer.get_policy(params) word_count = length if length != 20 else None pp_separator = kwargs.get('pp_separator') - if isinstance(pp_separator, str) and pp_separator.lower() in ('space', 'sp'): - pp_separator = ' ' - elif isinstance(pp_separator, str) and pp_separator: - pp_separator = pp_separator[0] + if isinstance(pp_separator, str) and pp_separator.strip(): + pp_separator, pp_sep_error = generator._parse_passphrase_separator_token( + pp_separator.strip()) + if pp_sep_error: + logging.error(pp_sep_error) + return else: pp_separator = None if policy and policy.get('passphrase-allow') is False: diff --git a/keepercommander/enforcement.py b/keepercommander/enforcement.py index 2657e6a21..1923395c3 100644 --- a/keepercommander/enforcement.py +++ b/keepercommander/enforcement.py @@ -25,6 +25,7 @@ from .error import KeeperApiError, CommandError from .generator import ( DEFAULT_PASSPHRASE_WORD_COUNT, DEFAULT_PASSPHRASE_CAPITALIZE, DEFAULT_PASSPHRASE_NUMBER, + MAX_PASSPHRASE_WORD_COUNT, PP_SEPARATOR_CHARACTERS, _passphrase_separators_from_policy, format_passphrase_separators_for_display, ) @@ -469,6 +470,9 @@ def validate_passphrase(cls, password, policy): # type: (str, Dict[str, Any]) if len(words) < min_words: failures.append( f'Passphrase must contain at least {min_words} words (got {len(words)}).') + if len(words) > MAX_PASSPHRASE_WORD_COUNT: + failures.append( + f'Passphrase must contain at most {MAX_PASSPHRASE_WORD_COUNT} words (got {len(words)}).') capitalize = bool(policy.get('passphrase-capitalize', DEFAULT_PASSPHRASE_CAPITALIZE)) append_number = bool(policy.get('passphrase-number', DEFAULT_PASSPHRASE_NUMBER)) diff --git a/keepercommander/generator.py b/keepercommander/generator.py index b32adef10..14955376e 100644 --- a/keepercommander/generator.py +++ b/keepercommander/generator.py @@ -9,13 +9,14 @@ # import abc +import difflib import hashlib import logging import os import secrets import string from secrets import choice -from typing import Optional, List, Iterator, Sequence +from typing import Optional, List, Iterator, Sequence, Tuple from collections import namedtuple from . import crypto @@ -27,12 +28,32 @@ PP_SEPARATOR_CHARACTERS = '-._?! ' DEFAULT_PASSPHRASE_SEPARATOR = '-' DEFAULT_PASSPHRASE_WORD_COUNT = 5 +MIN_PASSPHRASE_WORD_COUNT = 5 +MAX_PASSPHRASE_WORD_COUNT = 9 DEFAULT_PASSPHRASE_CAPITALIZE = True DEFAULT_PASSPHRASE_NUMBER = True +GEN_PASSWORD_ALGORITHMS = ('rand', 'dice', 'crypto', 'passphrase') DEFAULT_DICEWARE_WORDLIST = 'diceware.wordlist.asc.txt' PASSPHRASE_SEPARATOR_HELP = '- . _ ? ! space' +def clamp_passphrase_word_count(word_count): + # type: (Optional[int]) -> int + """Clamp passphrase word count to the Vault range (5-9 words).""" + if not isinstance(word_count, int): + return DEFAULT_PASSPHRASE_WORD_COUNT + original = word_count + if word_count < MIN_PASSPHRASE_WORD_COUNT: + word_count = MIN_PASSPHRASE_WORD_COUNT + elif word_count > MAX_PASSPHRASE_WORD_COUNT: + word_count = MAX_PASSPHRASE_WORD_COUNT + if word_count != original: + logging.warning( + 'Passphrase word count must be between %d and %d; using %d.', + MIN_PASSPHRASE_WORD_COUNT, MAX_PASSPHRASE_WORD_COUNT, word_count) + return word_count + + def format_passphrase_separators_for_display(separators=None): # type: (Optional[str]) -> str """Human-readable list of allowed passphrase separator characters.""" @@ -202,6 +223,25 @@ def _default_passphrase_separator_from_policy(policy_sep): return allowed[0] if allowed else DEFAULT_PASSPHRASE_SEPARATOR +def resolve_gen_password_algorithm(parameters): + # type: (Optional[Sequence[str]]) -> Tuple[Optional[str], Optional[str]] + """Resolve $GEN password algorithm; return (algorithm, error_message).""" + if not parameters: + return 'rand', None + first = parameters[0].strip() + first_lower = first.lower() + if first_lower in GEN_PASSWORD_ALGORITHMS: + return first_lower, None + if first.isdigit(): + return 'rand', None + suggestions = difflib.get_close_matches(first_lower, GEN_PASSWORD_ALGORITHMS, n=1, cutoff=0.6) + message = f'Unknown $GEN password algorithm "{first}".' + if suggestions: + message += f' Did you mean "{suggestions[0]}"?' + message += f' Valid algorithms: {", ".join(GEN_PASSWORD_ALGORITHMS)}.' + return None, message + + def _parse_gen_bool(value): # type: (str) -> Optional[bool] normalized = value.strip().lower() @@ -212,52 +252,105 @@ def _parse_gen_bool(value): return None +def _is_strict_gen_bool_token(value): + # type: (str) -> bool + return value.strip().lower() in ('true', 'false') + + +def _parse_gen_bool_strict(value, param_name): + # type: (str, str) -> Tuple[Optional[bool], Optional[str]] + normalized = value.strip().lower() + if normalized == 'true': + return True, None + if normalized == 'false': + return False, None + return None, ( + f'Invalid $GEN:passphrase {param_name} parameter "{value}". ' + f'Expected true or false.') + + +def _is_passphrase_separator_token(token): + # type: (str) -> bool + if token.lower() in ('space', 'sp'): + return True + return len(token) == 1 and token in PP_SEPARATOR_CHARACTERS + + +def _parse_passphrase_separator_token(token): + # type: (str) -> Tuple[Optional[str], Optional[str]] + if token.lower() in ('space', 'sp'): + return ' ', None + if len(token) == 1 and token in PP_SEPARATOR_CHARACTERS: + return token, None + return None, ( + f'Invalid passphrase separator "{token}". ' + f'Allowed: {format_passphrase_separators_for_display(PP_SEPARATOR_CHARACTERS)}.') + + def parse_passphrase_gen_parameters(parameters): - # type: (Optional[Sequence[str]]) -> PassphraseGenOptions + # type: (Optional[Sequence[str]]) -> Tuple[PassphraseGenOptions, Optional[str]] """Parse $GEN:passphrase optional parameters. Format: $GEN:passphrase[,word_count][,separator][,capitalize][,number] - Examples: - $GEN:passphrase,7 - $GEN:passphrase,7,_ - $GEN:passphrase,7,_,true,true - $GEN:passphrase,7,space,false,true + word_count must be between 5 and 9 (Vault range). """ + empty = PassphraseGenOptions(None, None, None, None) if not parameters: - return PassphraseGenOptions(None, None, None, None) + return empty, None + + tokens = [p if isinstance(p, str) else str(p) for p in parameters] + if not tokens or tokens[0].strip().lower() != 'passphrase': + return empty, None + + extras = tokens[1:] + if any(t.strip() == '' for t in extras): + return empty, ( + 'Incomplete $GEN:passphrase parameters: missing value after comma. ' + 'Format: $GEN:passphrase[,word_count][,separator][,capitalize][,number]') - extras = [p.strip() for p in parameters if p.strip() and p.strip() != 'passphrase'] word_count = None separator = None capitalize = None append_number = None idx = 0 - if idx < len(extras) and extras[idx].isdigit(): - word_count = int(extras[idx]) + if idx < len(extras): + token = extras[idx].strip() + if token.isdigit(): + word_count = int(token) + if word_count < MIN_PASSPHRASE_WORD_COUNT or word_count > MAX_PASSPHRASE_WORD_COUNT: + return empty, ( + f'Passphrase word count must be between {MIN_PASSPHRASE_WORD_COUNT} ' + f'and {MAX_PASSPHRASE_WORD_COUNT} (got {word_count}).') + idx += 1 + elif not _is_passphrase_separator_token(token) and not _is_strict_gen_bool_token(token): + return empty, ( + f'Invalid passphrase word count "{token}". ' + f'Expected an integer between {MIN_PASSPHRASE_WORD_COUNT} ' + f'and {MAX_PASSPHRASE_WORD_COUNT}.') + + if idx < len(extras) and not _is_strict_gen_bool_token(extras[idx].strip()): + separator, sep_error = _parse_passphrase_separator_token(extras[idx].strip()) + if sep_error: + return empty, sep_error idx += 1 if idx < len(extras): - candidate = extras[idx] - if _parse_gen_bool(candidate) is None: - if candidate.lower() in ('space', 'sp'): - separator = ' ' - else: - separator = _normalize_passphrase_separator(candidate) - idx += 1 + capitalize, cap_error = _parse_gen_bool_strict(extras[idx].strip(), 'capitalize') + if cap_error: + return empty, cap_error + idx += 1 if idx < len(extras): - parsed = _parse_gen_bool(extras[idx]) - if parsed is not None: - capitalize = parsed - idx += 1 + append_number, num_error = _parse_gen_bool_strict(extras[idx].strip(), 'number') + if num_error: + return empty, num_error + idx += 1 if idx < len(extras): - parsed = _parse_gen_bool(extras[idx]) - if parsed is not None: - append_number = parsed + return empty, f'Unexpected $GEN:passphrase parameter "{extras[idx].strip()}".' - return PassphraseGenOptions(word_count, separator, capitalize, append_number) + return PassphraseGenOptions(word_count, separator, capitalize, append_number), None def _resolve_wordlist_path(word_list_file=None): @@ -317,14 +410,8 @@ def __init__(self, word_count=DEFAULT_PASSPHRASE_WORD_COUNT, separator=DEFAULT_P capitalize=DEFAULT_PASSPHRASE_CAPITALIZE, append_number=DEFAULT_PASSPHRASE_NUMBER, word_list_file=None): # type: (int, str, bool, bool, Optional[str]) -> None - if isinstance(word_count, int): - if word_count < 1: - word_count = 1 - elif word_count > 40: - word_count = 40 - else: - word_count = DEFAULT_PASSPHRASE_WORD_COUNT - self.word_count = word_count + self.word_count = clamp_passphrase_word_count( + word_count if isinstance(word_count, int) else DEFAULT_PASSPHRASE_WORD_COUNT) self.separator = _normalize_passphrase_separator(separator) self.capitalize = capitalize self.append_number = append_number @@ -361,11 +448,6 @@ def create_with_options(cls, policy=None, word_count=None, separator=None, wc = DEFAULT_PASSPHRASE_WORD_COUNT sep = separator - if sep is not None and sep not in PP_SEPARATOR_CHARACTERS: - logging.warning( - 'Ignoring invalid passphrase separator %r. Allowed: %s.', - sep, format_passphrase_separators_for_display()) - sep = None if sep is None: if policy: policy_sep = policy.get('passphrase-separator') @@ -382,7 +464,9 @@ def create_with_options(cls, policy=None, word_count=None, separator=None, if num is None: num = DEFAULT_PASSPHRASE_NUMBER - return cls(word_count=wc, separator=sep, capitalize=cap, append_number=num) + return cls( + word_count=clamp_passphrase_word_count(wc) if isinstance(wc, int) else wc, + separator=sep, capitalize=cap, append_number=num) @classmethod def create_from_policy(cls, policy, length_override=None, separator_override=None): diff --git a/unit-tests/test_passphrase_enforcement.py b/unit-tests/test_passphrase_enforcement.py index bc16f593c..4093ba7de 100644 --- a/unit-tests/test_passphrase_enforcement.py +++ b/unit-tests/test_passphrase_enforcement.py @@ -70,6 +70,14 @@ def test_invalid_passphrase_returns_passphrase_errors(self): failures = PasswordComplexityEnforcer.validate_password(password, policy) self.assertTrue(any('word' in f.lower() for f in failures)) + def test_passphrase_rejects_more_than_nine_words(self): + policy = dict(STRICT_RANDOM_POLICY) + password = '-'.join( + ['alpha', 'bravo', 'charlie', 'delta', 'echo', + 'foxtrot', 'golf', 'hotel', 'india', 'juliet']) + failures = PasswordComplexityEnforcer.validate_passphrase(password, policy) + self.assertTrue(any('at most 9 words' in f for f in failures)) + def test_random_password_still_validated(self): password = 'ABCDE123!!!' failures = PasswordComplexityEnforcer.validate_password(password, STRICT_RANDOM_POLICY) diff --git a/unit-tests/test_passphrase_generator.py b/unit-tests/test_passphrase_generator.py index 7bffb477b..dfc708986 100644 --- a/unit-tests/test_passphrase_generator.py +++ b/unit-tests/test_passphrase_generator.py @@ -14,38 +14,64 @@ def test_default_generates_five_hyphen_separated_words(self): def test_does_not_shuffle_words_like_diceware(self): gen = generator.KeeperPassphraseGenerator( - word_count=3, separator=' ', capitalize=False, append_number=False) - with mock.patch('secrets.choice', side_effect=['one', 'two', 'three']): + word_count=5, separator=' ', capitalize=False, append_number=False) + with mock.patch('secrets.choice', side_effect=['one', 'two', 'three', 'four', 'five']): result = gen.generate() - self.assertEqual(result, 'one two three') + self.assertEqual(result, 'one two three four five') def test_capitalize_and_number_apply_to_first_word_only(self): gen = generator.KeeperPassphraseGenerator( - word_count=2, separator='-', capitalize=True, append_number=True) - with mock.patch('secrets.choice', side_effect=['alpha', 'bravo']): + word_count=5, separator='-', capitalize=True, append_number=True) + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie', 'delta', 'echo']): with mock.patch('secrets.randbelow', return_value=7): result = gen.generate() - self.assertEqual(result, 'Alpha7-Bravo') + self.assertEqual(result, 'Alpha7-Bravo-Charlie-Delta-Echo') def test_create_from_policy_honors_passphrase_fields(self): gen = generator.KeeperPassphraseGenerator.create_from_policy({ - 'passphrase-length': 3, + 'passphrase-length': 5, 'passphrase-separator': '-', 'passphrase-capitalize': True, 'passphrase-number': True, }) - with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie']): + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie', 'delta', 'echo']): with mock.patch('secrets.randbelow', return_value=4): result = gen.generate() - self.assertEqual(result, 'Alpha4-Bravo-Charlie') + self.assertEqual(result, 'Alpha4-Bravo-Charlie-Delta-Echo') def test_parse_passphrase_gen_parameters(self): - opts = generator.parse_passphrase_gen_parameters(['passphrase', '7', '_', 'true', 'false']) + opts, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '_', 'true', 'false']) + self.assertIsNone(error) self.assertEqual(opts.word_count, 7) self.assertEqual(opts.separator, '_') self.assertTrue(opts.capitalize) self.assertFalse(opts.append_number) + def test_parse_passphrase_rejects_invalid_separator(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '@', 'true', 'true']) + self.assertIn('Invalid passphrase separator', error) + + def test_parse_passphrase_rejects_invalid_boolean(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '_', 'tr', 'true']) + self.assertIn('capitalize', error) + + def test_parse_passphrase_rejects_trailing_comma(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '9', '_', 'true', '']) + self.assertIn('missing value after comma', error) + + def test_parse_passphrase_rejects_extra_parameters(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '_', 'true', 'true', 'test']) + self.assertIn('Unexpected', error) + + def test_parse_passphrase_rejects_out_of_range_word_count(self): + _, error = generator.parse_passphrase_gen_parameters(['passphrase', '12']) + self.assertIn('between 5 and 9', error) + def test_create_with_options_overrides_policy(self): policy = { 'passphrase-length': 5, @@ -55,7 +81,7 @@ def test_create_with_options_overrides_policy(self): } gen = generator.KeeperPassphraseGenerator.create_with_options( policy, word_count=3, separator='_', capitalize=True, append_number=True) - self.assertEqual(gen.word_count, 3) + self.assertEqual(gen.word_count, 5) self.assertEqual(gen.separator, '_') self.assertTrue(gen.capitalize) self.assertTrue(gen.append_number) @@ -74,16 +100,34 @@ def test_policy_separator_uses_vault_order_not_raw_first_char(self): }) self.assertEqual(gen.separator, '-') - def test_invalid_separator_override_falls_back_to_default(self): - gen = generator.KeeperPassphraseGenerator.create_with_options( - None, separator='~', word_count=3) - self.assertEqual(gen.separator, '-') + def test_invalid_separator_override_is_rejected_by_parser(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '~', 'true', 'true']) + self.assertIn('Invalid passphrase separator', error) + + def test_word_count_clamped_to_vault_range(self): + self.assertEqual(generator.clamp_passphrase_word_count(2), 5) + self.assertEqual(generator.clamp_passphrase_word_count(9), 9) + self.assertEqual(generator.clamp_passphrase_word_count(12), 9) + with self.assertLogs('root', level='WARNING') as logs: + generator.clamp_passphrase_word_count(12) + self.assertIn('between 5 and 9', logs.output[0]) def test_loads_bundled_eff_wordlist(self): words = generator._load_wordlist() self.assertEqual(len(words), 7776) self.assertEqual(words[0], 'abacus') + def test_resolve_gen_password_algorithm_rejects_typos(self): + algorithm, error = generator.resolve_gen_password_algorithm(['passphra']) + self.assertIsNone(algorithm) + self.assertIn('passphrase', error) + + def test_resolve_gen_password_algorithm_accepts_numeric_length(self): + algorithm, error = generator.resolve_gen_password_algorithm(['16']) + self.assertEqual(algorithm, 'rand') + self.assertIsNone(error) + class TestGeneratePasswordPassphrase(TestCase): @@ -91,34 +135,89 @@ def test_record_edit_generate_password_passphrase(self): from keepercommander.commands.record_edit import RecordEditMixin with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie', 'delta', 'echo']): with mock.patch('secrets.randbelow', return_value=5): - result = RecordEditMixin.generate_password(['passphrase']) + result, error = RecordEditMixin.generate_password(['passphrase']) + self.assertIsNone(error) self.assertEqual(result, 'Alpha5-Bravo-Charlie-Delta-Echo') def test_record_edit_passphrase_uses_policy_when_allowed(self): from keepercommander.commands.record_edit import RecordEditMixin policy = { 'passphrase-allow': True, - 'passphrase-length': 2, + 'passphrase-length': 5, 'passphrase-separator': '_', 'passphrase-capitalize': True, 'passphrase-number': False, } - with mock.patch('secrets.choice', side_effect=['alpha', 'bravo']): + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie', 'delta', 'echo']): with mock.patch('secrets.randbelow', return_value=4): - result = RecordEditMixin.generate_password(['passphrase'], policy=policy) - self.assertEqual(result, 'Alpha4_Bravo') + result, error = RecordEditMixin.generate_password(['passphrase'], policy=policy) + self.assertIsNone(error) + self.assertEqual(result, 'Alpha4_Bravo_Charlie_Delta_Echo') def test_record_edit_passphrase_cli_overrides_policy(self): from keepercommander.commands.record_edit import RecordEditMixin policy = { 'passphrase-allow': True, - 'passphrase-length': 2, + 'passphrase-length': 5, 'passphrase-separator': '-', 'passphrase-capitalize': False, 'passphrase-number': False, } - with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie']): + with mock.patch('secrets.choice', side_effect=[ + 'alpha', 'bravo', 'charlie', 'delta', 'echo']): with mock.patch('secrets.randbelow', return_value=9): - result = RecordEditMixin.generate_password( - ['passphrase', '3', '_', 'true', 'true'], policy=policy) - self.assertEqual(result, 'Alpha9_Bravo_Charlie') + result, error = RecordEditMixin.generate_password( + ['passphrase', '5', '_', 'true', 'true'], policy=policy) + self.assertIsNone(error) + self.assertEqual(result, 'Alpha9_Bravo_Charlie_Delta_Echo') + + def test_unknown_gen_algorithm_returns_error(self): + from keepercommander.commands.record_edit import RecordEditMixin + result, error = RecordEditMixin.generate_password(['passphra']) + self.assertIsNone(result) + self.assertIn('passphrase', error) + + def test_password_label_gen_syntax_warning(self): + from keepercommander.commands.record_edit import RecordEditMixin, ParsedFieldValue + mixin = RecordEditMixin() + parsed = ParsedFieldValue('', '', 'Password', '$GEN:passphrase') + self.assertTrue(mixin.warn_wrong_password_gen_field(parsed)) + self.assertEqual(len(mixin.errors), 1) + self.assertIn('password=', mixin.errors[0]) + + def test_invalid_gen_algorithm_aborts_record_add(self): + from keepercommander.commands.record_edit import RecordAddCommand, ParsedFieldValue + from keepercommander import vault + cmd = RecordAddCommand() + record = vault.TypedRecord() + record.type_name = 'login' + record.fields.append(vault.TypedField.new_field('login', '', '')) + record.fields.append(vault.TypedField.new_field('password', '', '')) + cmd.assign_typed_fields(record, [ + ParsedFieldValue('', 'login', '', 'my@email.com'), + ParsedFieldValue('', 'password', '', '$GEN:passphra'), + ]) + self.assertEqual(len(cmd.errors), 1) + self.assertIn('passphrase', cmd.errors[0]) + + def test_invalid_passphrase_separator_aborts_generation(self): + from keepercommander.commands.record_edit import RecordEditMixin + result, error = RecordEditMixin.generate_password( + ['passphrase', '7', '@', 'true', 'true']) + self.assertIsNone(result) + self.assertIn('Invalid passphrase separator', error) + + def test_invalid_passphrase_boolean_aborts_generation(self): + from keepercommander.commands.record_edit import RecordEditMixin + result, error = RecordEditMixin.generate_password( + ['passphrase', '7', '_', 'test', 'true']) + self.assertIsNone(result) + self.assertIn('capitalize', error) + + def test_trailing_comma_aborts_generation(self): + from keepercommander.commands.record_edit import RecordEditMixin + action_params = [] + RecordEditMixin.is_generate_value('$GEN:passphrase,9,_,true,', action_params) + result, error = RecordEditMixin.generate_password(action_params) + self.assertIsNone(result) + self.assertIn('missing value after comma', error) From 995a782bf62ef37a16c0db1912d35ddd4442a684 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Tue, 23 Jun 2026 17:26:59 +0530 Subject: [PATCH 18/18] updated test file --- unit-tests/test_passphrase_generator.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/unit-tests/test_passphrase_generator.py b/unit-tests/test_passphrase_generator.py index dfc708986..8cf9b40e2 100644 --- a/unit-tests/test_passphrase_generator.py +++ b/unit-tests/test_passphrase_generator.py @@ -109,9 +109,14 @@ def test_word_count_clamped_to_vault_range(self): self.assertEqual(generator.clamp_passphrase_word_count(2), 5) self.assertEqual(generator.clamp_passphrase_word_count(9), 9) self.assertEqual(generator.clamp_passphrase_word_count(12), 9) - with self.assertLogs('root', level='WARNING') as logs: + + def test_word_count_clamp_logs_warning(self): + with mock.patch('keepercommander.generator.logging.warning') as mock_warning: generator.clamp_passphrase_word_count(12) - self.assertIn('between 5 and 9', logs.output[0]) + mock_warning.assert_called_once() + args, _ = mock_warning.call_args + self.assertIn('between', args[0]) + self.assertEqual(args[1:], (5, 9, 9)) def test_loads_bundled_eff_wordlist(self): words = generator._load_wordlist()