From 2f1447ac14a750e78b0db96a230f2c4a0715c3a7 Mon Sep 17 00:00:00 2001 From: John Walstra Date: Thu, 20 Aug 2026 17:51:57 -0500 Subject: [PATCH 01/12] Add COM, DCOM, COM+, and SCOM to PAM service management. --- keepercommander/commands/discoveryrotation.py | 7 - keepercommander/commands/pam_debug/acl.py | 156 ------------------ keepercommander/commands/pam_debug/info.py | 60 ++++++- keepercommander/commands/pam_debug/link.py | 75 --------- keepercommander/commands/pam_service/add.py | 10 +- keepercommander/commands/pam_service/list.py | 26 ++- .../commands/pam_service/remove.py | 13 +- .../discovery_common/__version__.py | 2 +- keepercommander/discovery_common/types.py | 26 ++- .../discovery_common/user_service.py | 9 +- 10 files changed, 120 insertions(+), 264 deletions(-) delete mode 100644 keepercommander/commands/pam_debug/acl.py delete mode 100644 keepercommander/commands/pam_debug/link.py diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index c8c3bd13b..f011378da 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -75,13 +75,11 @@ from .discover.rule_list import PAMGatewayActionDiscoverRuleListCommand from .discover.rule_remove import PAMGatewayActionDiscoverRuleRemoveCommand from .discover.rule_update import PAMGatewayActionDiscoverRuleUpdateCommand -from .pam_debug.acl import PAMDebugACLCommand from .pam_debug.dump import PAMDebugDumpCommand from .pam_debug.gateway import PAMDebugGatewayCommand from .pam_debug.graph import PAMDebugGraphCommand from .pam_debug.info import PAMDebugInfoCommand from .pam_debug.krouter import PAMDebugKRouterCommand -from .pam_debug.link import PAMDebugLinkCommand from .pam_debug.rotation_setting import PAMDebugRotationSettingsCommand from .pam_debug.vertex import PAMDebugVertexCommand from .pam.cnapp_commands import PAMCnappCommand @@ -463,11 +461,6 @@ def __init__(self): self.register_command('gateway', PAMDebugGatewayCommand(), 'Debug a gateway', 'g') self.register_command('krouter', PAMDebugKRouterCommand(), 'Show connected krouter version', 'k') self.register_command('graph', PAMDebugGraphCommand(), 'Render graphs', 'r') - - # Disable for now. Needs more work. - # self.register_command('verify', PAMDebugVerifyCommand(), 'Verify graphs') - self.register_command('acl', PAMDebugACLCommand(), 'Control ACL of PAM Users', 'c') - self.register_command('link', PAMDebugLinkCommand(), 'Link resource to configuration', 'l') self.register_command('rs-reset', PAMDebugRotationSettingsCommand(), 'Create/reset rotation settings', 'rs') self.register_command('vertex', PAMDebugVertexCommand(), diff --git a/keepercommander/commands/pam_debug/acl.py b/keepercommander/commands/pam_debug/acl.py deleted file mode 100644 index 3b1430646..000000000 --- a/keepercommander/commands/pam_debug/acl.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations -import argparse -import logging -from ..discover import (PAMGatewayActionDiscoverCommandBase, GatewayContext, PAM_USER, MultiConfigurationException, - multi_conf_msg) -from ...display import bcolors -from . import load_pam_record -from ...discovery_common.record_link import RecordLink -from ...discovery_common.types import UserAcl -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from ...vault import TypedRecord - from ...params import KeeperParams - - -class PAMDebugACLCommand(PAMGatewayActionDiscoverCommandBase): - parser = argparse.ArgumentParser(prog='pam action debug acl') - - # The record to base everything on. - parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', - help='Gateway name or UID.') - parser.add_argument('--configuration-uid', "-c", 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='User UID.') - parser.add_argument('--parent-uid', '-r', required=True, dest='parent_uid', action='store', - help='Resource or Configuration UID.') - parser.add_argument('--debug-gs-level', required=False, dest='debug_level', action='store', - help='GraphSync debug level. Default is 0', type=int, default=0) - - def get_parser(self): - return PAMDebugACLCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - - gateway = kwargs.get("gateway") - user_uid = kwargs.get("user_uid") - parent_uid = kwargs.get("parent_uid") - debug_level = int(kwargs.get("debug_level", 0)) - - print("") - - configuration_uid = kwargs.get('configuration_uid') - try: - gateway_context = GatewayContext.from_gateway(params=params, - gateway=gateway, - configuration_uid=configuration_uid) - if gateway_context is None: - print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") - return - except MultiConfigurationException as err: - multi_conf_msg(gateway, err) - return - - record_link = RecordLink(record=gateway_context.configuration, - params=params, - logger=logging, - debug_level=debug_level, use_per_graph_endpoints=False) - - user_record = load_pam_record(params, user_uid) # type: TypedRecord | None - if user_record is None: - print(f"{bcolors.FAIL}The user record does not exists.{bcolors.ENDC}") - return - - print(f"{bcolors.BOLD}The user record is {user_record.title}{bcolors.ENDC}") - - if user_record.record_type != PAM_USER: - print(f"{bcolors.FAIL}The user record is not a PAM User record.{bcolors.ENDC}") - return - - parent_record = load_pam_record(params, parent_uid) # type: TypedRecord | None - if parent_record is None: - print(f"{bcolors.FAIL}The parent record does not exists.{bcolors.ENDC}") - return - - print(f"{bcolors.BOLD}The parent record is {parent_record.title}{bcolors.ENDC}") - - if parent_record.record_type.startswith("pam") is False: - print(f"{bcolors.FAIL}The parent record is not a PAM record.{bcolors.ENDC}") - return - - if parent_record.record_type == PAM_USER: - print(f"{bcolors.FAIL}The parent record cannot be a PAM User record.{bcolors.ENDC}") - return - - parent_is_config = parent_record.record_type.endswith("Configuration") - - # Get the ACL between the user and the parent. - # It might not exist. - acl_exists = True - acl = record_link.get_acl(user_uid, parent_uid) - if acl is None: - print("No existing ACL, creating an ACL.") - acl = UserAcl() - acl_exists = False - - # Make sure the ACL for cloud user is set. - if parent_is_config is True: - print("Is an IAM user.") - acl.is_iam_user = True - - rl_parent_vertex = record_link.dag.get_vertex(parent_uid) - if rl_parent_vertex is None: - print("Parent record linking vertex did not exists, creating one.") - rl_parent_vertex = record_link.dag.add_vertex(parent_uid) - - rl_user_vertex = record_link.dag.get_vertex(user_uid) - if rl_user_vertex is None: - print("User record linking vertex did not exists, creating one.") - rl_user_vertex = record_link.dag.add_vertex(user_uid) - - has_admin_uid = record_link.get_admin_record_uid(parent_uid) - if has_admin_uid is not None: - print("Parent record already has an admin.") - else: - print("Parent record does not have an admin.") - - belongs_to_vertex = record_link.acl_has_belong_to_record_uid(user_uid) - if belongs_to_vertex is None: - print("User record does not belong to any resource, or provider.") - else: - if not belongs_to_vertex.active: - print("User record belongs to an inactive parent.") - else: - print("User record belongs to another record.") - - print("") - - while True: - res = input(f"Does this user belong to {parent_record.title} Y/N >").lower() - if res == "y": - acl.belongs_to = True - break - elif res == "n": - acl.belongs_to = False - break - - if has_admin_uid is None: - while True: - res = input(f"Is this user the admin of {parent_record.title} Y/N >").lower() - if res == "y": - acl.is_admin = True - break - elif res == "n": - acl.is_admin = False - break - - try: - record_link.belongs_to(user_uid, parent_uid, acl=acl) - record_link.save() - print(f"{bcolors.OKGREEN}Updated/added ACL between {user_record.title} and " - f"{parent_record.title}{bcolors.ENDC}") - except Exception as err: - print(f"{bcolors.FAIL}Could not update ACL: {err}{bcolors.ENDC}") diff --git a/keepercommander/commands/pam_debug/info.py b/keepercommander/commands/pam_debug/info.py index a89ddbae7..74ad5d3db 100644 --- a/keepercommander/commands/pam_debug/info.py +++ b/keepercommander/commands/pam_debug/info.py @@ -5,7 +5,7 @@ from . import load_pam_record from ...discovery_common.infrastructure import Infrastructure from ...discovery_common.record_link import RecordLink -from ...discovery_common.types import UserAcl, DiscoveryObject +from ...discovery_common.types import UserAcl, DiscoveryObject, ServiceEnum from ...discovery_common.constants import PAM_USER, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY from ...keeper_dag import EdgeType import time @@ -16,6 +16,7 @@ if TYPE_CHECKING: from ...vault import TypedRecord from ...params import KeeperParams + from ...discovery_common.types import UserAclServiceNames, UserAclServiceNamesItem class PAMDebugInfoCommand(PAMGatewayActionDiscoverCommandBase): @@ -28,8 +29,18 @@ class PAMDebugInfoCommand(PAMGatewayActionDiscoverCommandBase): PAM_DIRECTORY: "PAM Directory", } + TITLES = { + ServiceEnum.service: "Service", + ServiceEnum.task: "Scheduled Task", + ServiceEnum.iis_pool: "IIS Pool", + ServiceEnum.com: "COM (Classic)", + ServiceEnum.dcom: "DCOM", + ServiceEnum.com_plus: "COM Plus", + ServiceEnum.scom: "SCOM", + } + # The record to base everything on. - parser.add_argument('--record-uid', '-i', required=True, dest='record_uid', action='store', + parser.add_argument('--record-uid', '-i', '-r', required=True, dest='record_uid', action='store', help='Keeper PAM record UID.') def get_parser(self): @@ -298,7 +309,7 @@ def _print_field(f): # Get the resource record machine_record = load_pam_record(params, - machine_vertex.uid) # type: TypedRecord | None + machine_vertex.uid) # type: TypedRecord | None # If the resource record does not exist. if machine_record is None: @@ -319,8 +330,18 @@ def _print_field(f): # Record exists; just use information from the record. else: - machines.append(f" * {machine_record.title}, {machine_record.record_uid}, " - f"vertex {machine_vertex.uid}") + text = f" * {machine_record.title}, {machine_record.record_uid}, "\ + f"vertex {machine_vertex.uid}" + if acl.service_names is not None: + for service_name in acl.service_names: # type: UserAclServiceNames + if len(service_name.items) > 0: + text += f"\n + {PAMDebugInfoCommand.TITLES.get(service_name.type)}\n" + for service_item in service_name.items: + text += f"\n . {service_item.name}" + if not service_item.via_discovery: + text += f" (manual entry)" + text += "\n" + machines.append(text) if len(machines) > 0: print(f"{bcolors.HEADER}Controls Services on Machine{bcolors.ENDC}") @@ -465,6 +486,35 @@ def _print_field(f): print(f" * {iis_pool.name} = {iis_pool.user}") else: print(" Machines has no IIS Pools that are using non-builtin users.") + + print(f" {self._b('COM (classic)')} (Non Builtin Users)") + if len(content.item.facts.coms) > 0: + for com in content.item.facts.coms: + print(f" * {com.name} = {com.user}") + else: + print(" Machines has no COM (classic) applications that are using non-builtin users.") + + print(f" {self._b('DCOM')} (Non Builtin Users)") + if len(content.item.facts.dcoms) > 0: + for dcom in content.item.facts.dcoms: + print(f" * {dcom.name} = {dcom.user}") + else: + print(" Machines has no DCOM applications that are using non-builtin users.") + + print(f" {self._b('COM Plus')} (Non Builtin Users)") + if len(content.item.facts.com_pluses) > 0: + for com in content.item.facts.com_pluses: + print(f" * {com.name} = {com.user}") + else: + print(" Machines has no COM Plus applications that are using non-builtin users.") + + print(f" {self._b('SCOM')} (Non Builtin Users)") + if len(content.item.facts.scoms) > 0: + for scom in content.item.facts.scoms: + print(f" * {scom.name} = {scom.user}") + else: + print(" Machines has no SCOM applications that are using non-builtin users.") + else: print(f"{bcolors.FAIL} Machine facts are not set. Discover inside may not have been " f"performed.{bcolors.ENDC}") diff --git a/keepercommander/commands/pam_debug/link.py b/keepercommander/commands/pam_debug/link.py deleted file mode 100644 index deae2697a..000000000 --- a/keepercommander/commands/pam_debug/link.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations -import argparse -import logging -from ..discover import (PAMGatewayActionDiscoverCommandBase, GatewayContext, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY, - MultiConfigurationException, multi_conf_msg) -from ...display import bcolors -from . import load_pam_record -from ...discovery_common.record_link import RecordLink -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from ...vault import TypedRecord - from ...params import KeeperParams - - -class PAMDebugLinkCommand(PAMGatewayActionDiscoverCommandBase): - parser = argparse.ArgumentParser(prog='pam action debug link') - - # The record to base everything on. - parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', - help='Gateway name or UID.') - parser.add_argument('--configuration-uid', "-c", required=False, dest='configuration_uid', - action='store', help='PAM configuration UID, if gateway has multiple.') - - parser.add_argument('--resource-uid', '-r', required=True, dest='resource_uid', action='store', - help='Resource record UID.') - parser.add_argument('--debug-gs-level', required=False, dest='debug_level', action='store', - help='GraphSync debug level. Default is 0', type=int, default=0) - - def get_parser(self): - return PAMDebugLinkCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - - gateway = kwargs.get("gateway") - resource_uid = kwargs.get("resource_uid") - debug_level = int(kwargs.get("debug_level", 0)) - - print("") - - configuration_uid = kwargs.get('configuration_uid') - try: - gateway_context = GatewayContext.from_gateway(params=params, - gateway=gateway, - configuration_uid=configuration_uid) - if gateway_context is None: - print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") - return - except MultiConfigurationException as err: - multi_conf_msg(gateway, err) - return - - record_link = RecordLink(record=gateway_context.configuration, - params=params, - logger=logging, - debug_level=debug_level, use_per_graph_endpoints=False) - - resource_record = load_pam_record(params, resource_uid) # type: TypedRecord | None - if resource_record is None: - print(f"{bcolors.FAIL}The parent record does not exists.{bcolors.ENDC}") - return - - if resource_record.record_type not in [PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY]: - print(f"{bcolors.FAIL}The resource record type, {resource_record.record_type} " - f"is not allowed.{bcolors.ENDC}") - return - - try: - record_link.belongs_to(resource_uid, gateway_context.configuration_uid, ) - record_link.save() - print(f"{bcolors.OKGREEN}Added link between '{resource_uid}' and " - f"{gateway_context.configuration_uid}{bcolors.ENDC}") - except Exception as err: - print(f"{bcolors.FAIL}Could not add LINK: {err}{bcolors.ENDC}") - raise err diff --git a/keepercommander/commands/pam_service/add.py b/keepercommander/commands/pam_service/add.py index 05c9ec055..079d15fa4 100644 --- a/keepercommander/commands/pam_service/add.py +++ b/keepercommander/commands/pam_service/add.py @@ -32,7 +32,15 @@ class PAMActionServiceAddCommand(PAMGatewayActionDiscoverCommandBase): parser.add_argument('--user-uid', '-u', required=True, dest='user_uid', action='store', help='The UID of the User record') parser.add_argument('--type', '-t', required=True, dest='service_type', action='store', - choices=["service", "task", "iis_pool"], + choices=[ + "service", + "task", + "iis_pool", + "com", + "dcom", + "com_plus", + "scom" + ], help='Type of service.') parser.add_argument('--name', '-n', required=True, dest='name', action='store', help='Name label for reporting.') diff --git a/keepercommander/commands/pam_service/list.py b/keepercommander/commands/pam_service/list.py index c74cd4c6e..7b61459a6 100644 --- a/keepercommander/commands/pam_service/list.py +++ b/keepercommander/commands/pam_service/list.py @@ -28,6 +28,16 @@ class PAMActionServiceListCommand(PAMGatewayActionDiscoverCommandBase): parser.add_argument('--by-machine', '-m', required=False, dest='do_by_machine', action='store_true', help='List by machine') + TITLES = { + ServiceEnum.service: "Service", + ServiceEnum.task: "Scheduled Task", + ServiceEnum.iis_pool: "IIS Pool", + ServiceEnum.com: "COM (Classic)", + ServiceEnum.dcom: "DCOM", + ServiceEnum.com_plus: "COM Plus", + ServiceEnum.scom: "SCOM", + } + def get_parser(self): return PAMActionServiceListCommand.parser @@ -76,13 +86,7 @@ def _by_user(self, params: KeeperParams, record_link: RecordLink, user_service: items = [] if acl.service_names is not None or acl.service_names != "": for service_name in acl.get_service_names(user_record.record_key): - text = "" - if service_name.type == ServiceEnum.service: - text = "Service" - elif service_name.type == ServiceEnum.task: - text = "Scheduled Task" - elif service_name.type == ServiceEnum.iis_pool: - text = "IIS Pool" + text = PAMActionServiceListCommand.TITLES.get(service_name.type) for item in service_name.items: text += f": {item.name}" if "Unknown" in item.name: @@ -159,13 +163,7 @@ def _by_machine(self, params: KeeperParams, record_link: RecordLink, user_servic items = [] if acl.service_names is not None or acl.service_names != "": for service_name in acl.get_service_names(user_record.record_key): - text = "" - if service_name.type == ServiceEnum.service: - text = "Service" - elif service_name.type == ServiceEnum.task: - text = "Scheduled Task" - elif service_name.type == ServiceEnum.iis_pool: - text = "IIS Pool" + text = PAMActionServiceListCommand.TITLES.get(service_name.type) for item in service_name.items: text += f": {item.name}" if "Unknown" in item.name: diff --git a/keepercommander/commands/pam_service/remove.py b/keepercommander/commands/pam_service/remove.py index f66da90e0..0451eb0cb 100644 --- a/keepercommander/commands/pam_service/remove.py +++ b/keepercommander/commands/pam_service/remove.py @@ -31,8 +31,17 @@ class PAMActionServiceRemoveCommand(PAMGatewayActionDiscoverCommandBase): help='The UID of the User record') parser.add_argument('--type', '-t', required=True, dest='service_type', action='store', - choices=["service", "task", "iis_pool", "all"], - help='Type of service. "all" will clear all') + choices=[ + "all", + "service", + "task", + "iis_pool", + "com", + "dcom", + "com_plus", + "scom" + ], + help='Type of service. "all" will clear all.') parser.add_argument('--name', '-n', required=False, dest='name', action='store', help='Name label for reporting. Exclude will remove all service types.') diff --git a/keepercommander/discovery_common/__version__.py b/keepercommander/discovery_common/__version__.py index b46c29a4b..5eba4ad50 100644 --- a/keepercommander/discovery_common/__version__.py +++ b/keepercommander/discovery_common/__version__.py @@ -1 +1 @@ -__version__ = '1.1.22' +__version__ = '1.1.23' diff --git a/keepercommander/discovery_common/types.py b/keepercommander/discovery_common/types.py index 04ecbae81..1ba1eafc8 100644 --- a/keepercommander/discovery_common/types.py +++ b/keepercommander/discovery_common/types.py @@ -358,6 +358,9 @@ class ServiceEnum(BaseEnum): task = "task" iis_pool = "iis_pool" dcom = "dcom" + com = "com" + com_plus = "com_plus" + scom = "scom" class UserAclServiceNames(BaseModel): @@ -550,7 +553,10 @@ class Facts(BaseModel): services: List[FactsNameUser] = [] tasks: List[FactsNameUser] = [] iis_pools: List[FactsNameUser] = [] + coms: List[FactsNameUser] = [] dcoms: List[FactsNameUser] = [] + com_pluses: List[FactsNameUser] = [] + scoms: List[FactsNameUser] = [] @property def has_services(self): @@ -564,13 +570,31 @@ def has_tasks(self): def has_iis_pools(self): return self.iis_pools is not None and len(self.iis_pools) > 0 + @property + def has_coms(self): + return self.coms is not None and len(self.coms) > 0 + @property def has_dcoms(self): return self.dcoms is not None and len(self.dcoms) > 0 + @property + def has_com_pluses(self): + return self.com_pluses is not None and len(self.com_pluses) > 0 + + @property + def has_scoms(self): + return self.scoms is not None and len(self.scoms) > 0 + @property def has_service_items(self): - return self.has_services or self.has_tasks or self.has_iis_pools or self.has_dcoms + return self.has_services \ + or self.has_tasks \ + or self.has_iis_pools \ + or self.has_coms \ + or self.has_dcoms \ + or self.has_com_pluses \ + or self.has_scoms class DiscoveryMachine(DiscoveryItem): diff --git a/keepercommander/discovery_common/user_service.py b/keepercommander/discovery_common/user_service.py index 328903561..619f49e60 100644 --- a/keepercommander/discovery_common/user_service.py +++ b/keepercommander/discovery_common/user_service.py @@ -858,14 +858,19 @@ def _connect_users_to_machine_services(self, self.debug(f" > {k} = {v}") # Add mapping from user to machine, that control services. - for service_type in [ServiceEnum.service, ServiceEnum.task, ServiceEnum.iis_pool]: + for service_type in [ServiceEnum.service, ServiceEnum.task, ServiceEnum.iis_pool, + ServiceEnum.com, ServiceEnum.dcom, ServiceEnum.com_plus, ServiceEnum.scom]: self.debug("-" * 40) self.debug(f"processing {service_type.value}s for {infra_machine_content.name} " f"({infra_machine_vertex.uid})") # Get the pair of name of the service and the user that controls it. # This is from discovery. - service_pairs = getattr(infra_machine_content.item.facts, f"{service_type.value}s") + if hasattr(infra_machine_content.item.facts, f"{service_type.value}s"): + service_pairs = getattr(infra_machine_content.item.facts, f"{service_type.value}s") + else: + service_pairs = getattr(infra_machine_content.item.facts, f"{service_type.value}es") + if len(service_pairs) == 0: self.debug(" no users control this type of service, skipping") continue From 55cee818526921ced5530cbeae569f3bccb25c1b Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Wed, 26 Aug 2026 13:43:16 -0700 Subject: [PATCH 02/12] SCIM Active Directory: bind is called twice --- keepercommander/scim/data_sources.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/keepercommander/scim/data_sources.py b/keepercommander/scim/data_sources.py index 866b433a4..9f872fdfb 100644 --- a/keepercommander/scim/data_sources.py +++ b/keepercommander/scim/data_sources.py @@ -80,14 +80,17 @@ def get_ldap_connection(self): logging.debug('AD connect: Kerberos auth method. Requires Windows, domain user, and domain computer') auth_method = ldap3.SASL server = ldap3.Server(self.ad_url, tls=tls) - with ldap3.Connection(server, user=self.ad_user, password=self.ad_password, + with ldap3.Connection(server, raise_exceptions=True, + user=self.ad_user, password=self.ad_password, authentication=auth_method, sasl_mechanism=ldap3.KERBEROS if auth_method == ldap3.SASL else None) as connection: - connection.open() - connection.bind() - if not connection.bind(): - raise Exception('Invalid AD username or password') - yield connection + try: + connection.open() + if not connection.bind(): + raise Exception('Invalid AD username or password') + yield connection + finally: + connection.unbind() def _build_domain_lookup(self, connection) -> Dict[str, str]: """Build a mapping of DNS domain names to NetBIOS names. From 1ec04850b3477d95391dcbc3a43c8fd9a7989c0b Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Thu, 27 Aug 2026 21:01:13 +0530 Subject: [PATCH 03/12] KC-1408: Treat first-time share invitations as success in Service Mode (#2325) * KC-1408: Treat first-time share invitations as success in Service Mode (#2303) * Treat first-time share invitations as success in Service Mode * Use ShareInviteSentError so NSF invite success does not mask real share failures * Fix test imports for deleted pam_debug modules Remove imports and tests for PAMDebugACLCommand and PAMDebugLinkCommand, which were deleted in commit 2f1447ac. Update test file to only include tests for modules that still exist. --- .../nested_share_folder/folder_commands.py | 2 +- .../nested_share_folder/sharing_commands.py | 10 ++- keepercommander/commands/register.py | 4 +- .../nested_share_folder/__init__.py | 2 +- keepercommander/nested_share_folder/common.py | 14 +++- .../service/util/parse_keeper_response.py | 13 ++++ unit-tests/pam/test_pam_debug_nsf.py | 43 ------------- unit-tests/service/test_throttle_response.py | 38 +++++++++++ unit-tests/test_nested_share_folder.py | 64 ++++++++++++++++++- 9 files changed, 137 insertions(+), 53 deletions(-) diff --git a/keepercommander/commands/nested_share_folder/folder_commands.py b/keepercommander/commands/nested_share_folder/folder_commands.py index 28f3a677f..2f1a95922 100644 --- a/keepercommander/commands/nested_share_folder/folder_commands.py +++ b/keepercommander/commands/nested_share_folder/folder_commands.py @@ -530,7 +530,7 @@ def _apply(cls, params, action, folder_uid, recipient, role, expiration, logging.info("%s share '%s' %s", kind, recipient, verb) else: logging.warning("%s share '%s' failed", kind, recipient) - except ValueError as e: + except _nsf.ShareInviteSentError as e: logging.warning("nsf-share-folder: %s", e) except Exception as e: raise CommandError('nsf-share-folder', str(e)) diff --git a/keepercommander/commands/nested_share_folder/sharing_commands.py b/keepercommander/commands/nested_share_folder/sharing_commands.py index 8872ba78f..5e66a7545 100644 --- a/keepercommander/commands/nested_share_folder/sharing_commands.py +++ b/keepercommander/commands/nested_share_folder/sharing_commands.py @@ -106,9 +106,13 @@ def execute(self, params, **kwargs): raise_if_record_share_target_is_owner( params, record_uid, email, 'nsf-share-record', is_ownership_transfer=(action == 'owner')) - result, effective_action = self._dispatch( - params, action, record_uid, email, access_role_type, expiration, - rotate_on_expiration) + try: + result, effective_action = self._dispatch( + params, action, record_uid, email, access_role_type, expiration, + rotate_on_expiration) + except _nsf.ShareInviteSentError as e: + logging.warning('nsf-share-record: %s', e) + continue self._log_results(result, effective_action, email) # Strategy dispatch — returns (result, effective_action) diff --git a/keepercommander/commands/register.py b/keepercommander/commands/register.py index 4c3222e6b..73b839576 100644 --- a/keepercommander/commands/register.py +++ b/keepercommander/commands/register.py @@ -807,7 +807,9 @@ def apply_share_expiration(target): uo.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_public_key rq.sharedFolderAddUser.append(uo) - else: + elif not invited: + # After a successful invite, keys are unavailable until accepted. + # Do not emit "User not found" — Service Mode treats that as failure. logging.warning('User %s not found', email) if len(teams) > 0: diff --git a/keepercommander/nested_share_folder/__init__.py b/keepercommander/nested_share_folder/__init__.py index b2d656c99..607c53a90 100644 --- a/keepercommander/nested_share_folder/__init__.py +++ b/keepercommander/nested_share_folder/__init__.py @@ -22,7 +22,7 @@ 'get_record_from_cache', 'get_record_revision', 'patch_record_revision', 'parse_sharing_status', 'get_record_key_type', 'encrypt_record_key_for_folder', 'encrypt_for_recipient', - 'handle_share_invite', 'resolve_user_uid_bytes', + 'handle_share_invite', 'ShareInviteSentError', 'resolve_user_uid_bytes', 'load_user_public_key', 'parse_folder_access_result', 'resolve_team_uid_bytes', 'resolve_team_identifier', 'get_team_keys', 'encrypt_for_team', 'is_keeper_uid', diff --git a/keepercommander/nested_share_folder/common.py b/keepercommander/nested_share_folder/common.py index 9b0f4c387..64db3b199 100644 --- a/keepercommander/nested_share_folder/common.py +++ b/keepercommander/nested_share_folder/common.py @@ -514,8 +514,16 @@ def _retry_with_canonical_email(params, recipient_email, _load_pk, # Share invite helper (previously duplicated in share + update_share) # ═══════════════════════════════════════════════════════════════════════════ +class ShareInviteSentError(ValueError): + """Invite was sent; caller should treat as success-with-notice.""" + + def handle_share_invite(params, recipient_email, needs_invite): - """Send a share invite if *needs_invite* is True; raise ValueError.""" + """Send a share invite if *needs_invite* is True. + + Raises ShareInviteSentError after a successful invite (subclass of ValueError). + Raises ValueError when the invite could not be sent. + """ if not needs_invite: return try: @@ -523,10 +531,10 @@ def handle_share_invite(params, recipient_email, needs_invite): rq = APIRequest_pb2.SendShareInviteRequest() rq.email = recipient_email api.communicate_rest(params, rq, 'vault/send_share_invite') - raise ValueError( + raise ShareInviteSentError( f"Share invitation has been sent to '{recipient_email}'. " f"Please repeat this command once the invitation is accepted.") - except ValueError: + except ShareInviteSentError: raise except Exception: raise ValueError( diff --git a/keepercommander/service/util/parse_keeper_response.py b/keepercommander/service/util/parse_keeper_response.py index 9081b81fc..8a923dc59 100644 --- a/keepercommander/service/util/parse_keeper_response.py +++ b/keepercommander/service/util/parse_keeper_response.py @@ -1148,6 +1148,19 @@ def _parse_logging_based_command(command: str, response_str: str) -> Dict[str, A has_bad_request = any(pattern in response_lower for pattern in bad_request_patterns) has_error = any(pattern in response_lower for pattern in error_patterns) has_warning = any(pattern in response_lower for pattern in warning_patterns) + + # First-time share invite is success. Mixed multi-recipient output is + # only partially represented: throttle (above) and forbidden keep + # precedence over this short-circuit. + has_invitation = 'share invitation has been sent to' in response_lower + if has_invitation and not has_forbidden: + formatted_message = KeeperResponseParser._format_multiline_message(response_str) + return { + "status": "success", + "command": command.split()[0] if command.split() else command, + "message": formatted_message, + "data": None, + } if has_success_indicator and (has_not_found or has_bad_request or has_error): return { diff --git a/unit-tests/pam/test_pam_debug_nsf.py b/unit-tests/pam/test_pam_debug_nsf.py index b68666192..855327b0f 100644 --- a/unit-tests/pam/test_pam_debug_nsf.py +++ b/unit-tests/pam/test_pam_debug_nsf.py @@ -9,9 +9,7 @@ from keepercommander import utils, vault from keepercommander.commands.discover import GatewayContext from keepercommander.commands.pam_debug import load_pam_record -from keepercommander.commands.pam_debug.acl import PAMDebugACLCommand from keepercommander.commands.pam_debug.dump import PAMDebugDumpCommand -from keepercommander.commands.pam_debug.link import PAMDebugLinkCommand from keepercommander.subfolder import NestedShareFolderNode, RootFolderNode @@ -74,47 +72,6 @@ def test_load_pam_record_resolves_nsf_machine(self): self.assertEqual(rec.title, 'NSF Machine') self.assertEqual(rec.record_type, 'pamMachine') - def test_acl_uses_load_pam_record_for_nsf_uids(self): - params = _params() - user = _typed('user_uid', 'NSF User', 'pamUser') - parent = _typed('machine_uid', 'NSF Machine', 'pamMachine') - gw = MagicMock() - gw.configuration = _typed('config_uid', 'NSF Config', 'pamNetworkConfiguration', version=6) - gw.configuration_uid = 'config_uid' - - with patch('keepercommander.commands.pam_debug.acl.GatewayContext.from_gateway', return_value=gw), \ - patch('keepercommander.commands.pam_debug.acl.RecordLink') as rl_cls, \ - patch('keepercommander.commands.pam_debug.acl.load_pam_record', - side_effect=[user, parent]) as load, \ - patch('builtins.input', side_effect=['n', 'n']): - rl = rl_cls.return_value - rl.get_acl.return_value = None - rl.get_admin_record_uid.return_value = None - rl.acl_has_belong_to_record_uid.return_value = None - rl.dag.get_vertex.return_value = MagicMock() - PAMDebugACLCommand().execute( - params, gateway='gw', user_uid='user_uid', parent_uid='machine_uid') - - self.assertEqual(load.call_count, 2) - self.assertEqual(load.call_args_list[0].args[1], 'user_uid') - self.assertEqual(load.call_args_list[1].args[1], 'machine_uid') - - def test_link_uses_load_pam_record_for_nsf_resource(self): - params = _params() - parent = _typed('machine_uid', 'NSF Machine', 'pamMachine') - gw = MagicMock() - gw.configuration = _typed('config_uid', 'NSF Config', 'pamNetworkConfiguration', version=6) - gw.configuration_uid = 'config_uid' - - with patch('keepercommander.commands.pam_debug.link.GatewayContext.from_gateway', return_value=gw), \ - patch('keepercommander.commands.pam_debug.link.RecordLink') as rl_cls, \ - patch('keepercommander.commands.pam_debug.link.load_pam_record', return_value=parent) as load: - rl = rl_cls.return_value - PAMDebugLinkCommand().execute(params, gateway='gw', resource_uid='machine_uid') - rl.belongs_to.assert_called_once() - rl.save.assert_called_once() - self.assertEqual(load.call_args.args[1], 'machine_uid') - def test_dump_collects_nsf_folder_records(self): params = _params() with tempfile.TemporaryDirectory() as tmp: diff --git a/unit-tests/service/test_throttle_response.py b/unit-tests/service/test_throttle_response.py index 361ea5173..f0747bf76 100644 --- a/unit-tests/service/test_throttle_response.py +++ b/unit-tests/service/test_throttle_response.py @@ -78,6 +78,44 @@ def test_parser_maps_throttled_text_to_429(self): 'Due to repeated attempts, your request has been throttled.', ) + def test_parser_treats_share_invitation_as_success(self): + result = KeeperResponseParser._parse_logging_based_command( + 'share-folder', + "Share invitation has been sent to 'user@example.com'\n" + "Please repeat this command when invitation is accepted.\n" + "User user@example.com not found", + ) + self.assertEqual(result['status'], 'success') + self.assertNotIn('error', result) + self.assertIn('Share invitation has been sent', str(result['message'])) + + def test_parser_treats_nsf_share_record_invitation_as_success(self): + result = KeeperResponseParser._parse_logging_based_command( + 'nsf-share-record', + "nsf-share-record: Share invitation has been sent to 'user@example.com'. " + "Please repeat this command once the invitation is accepted.", + ) + self.assertEqual(result['status'], 'success') + self.assertIn('Share invitation has been sent', str(result['message'])) + + def test_parser_keeps_throttle_precedence_over_invitation(self): + result = KeeperResponseParser._parse_logging_based_command( + 'share-folder', + "Share invitation has been sent to 'user@example.com'\n" + "throttled: Due to repeated attempts, your request has been throttled.", + ) + self.assertEqual(result['status_code'], 429) + self.assertEqual(result['result_code'], RESULT_THROTTLED) + + def test_parser_keeps_forbidden_precedence_over_invitation(self): + result = KeeperResponseParser._parse_logging_based_command( + 'share-record', + "Share invitation has been sent to 'user@example.com'\n" + "Permission denied", + ) + self.assertEqual(result['status'], 'error') + self.assertEqual(result.get('status_code'), 403) + def test_parser_does_not_treat_rate_limit_config_text_as_throttle(self): result = KeeperResponseParser._parse_logging_based_command( 'help', diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index 7c912c6bc..7eed51d12 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -1393,11 +1393,14 @@ def test_share_folder_rejects_grant_to_owner(self, mock_grant): @patch('keepercommander.nested_share_folder.folder_api.grant_folder_access_v3') def test_share_folder_invite_message_uses_command_prefix(self, mock_grant): + import keepercommander.nested_share_folder as nsf from keepercommander.commands.nested_share_folder import NestedShareFolderShareCommand + from keepercommander.nested_share_folder.common import ShareInviteSentError + nsf.__dict__.pop('grant_folder_access_v3', None) fuid, fobj = _make_folder() email = 'user@example.com' - mock_grant.side_effect = ValueError( + mock_grant.side_effect = ShareInviteSentError( f"Share invitation has been sent to '{email}'. " "Please repeat this command once the invitation is accepted.") @@ -1410,6 +1413,65 @@ def test_share_folder_invite_message_uses_command_prefix(self, mock_grant): self.assertIn('nsf-share-folder: Share invitation has been sent', output) self.assertNotIn("User '", output) + @patch('keepercommander.nested_share_folder.folder_api.grant_folder_access_v3') + def test_share_folder_no_relationship_still_fails(self, mock_grant): + import keepercommander.nested_share_folder as nsf + from keepercommander.commands.nested_share_folder import NestedShareFolderShareCommand + + nsf.__dict__.pop('grant_folder_access_v3', None) + fuid, fobj = _make_folder() + mock_grant.side_effect = ValueError( + "No sharing relationship with 'user@example.com'. " + "Please invite them to share first, then repeat this command.") + + cmd = NestedShareFolderShareCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(_make_params(nested_share_folders={fuid: fobj}), + folder=[fuid], user=['user@example.com'], action='grant', role='viewer') + self.assertIn('No sharing relationship', str(ctx.exception)) + + @patch('keepercommander.nested_share_folder.record_api.share_record_v3') + def test_share_record_invite_message_logged_as_warning(self, mock_share): + import keepercommander.nested_share_folder as nsf + from keepercommander.commands.nested_share_folder import NestedShareRecordShareCommand + from keepercommander.nested_share_folder.common import ShareInviteSentError + + nsf.__dict__.pop('share_record_v3', None) + ruid, robj = _make_record() + email = 'user@example.com' + mock_share.side_effect = ShareInviteSentError( + f"Share invitation has been sent to '{email}'. " + "Please repeat this command once the invitation is accepted.") + + cmd = NestedShareRecordShareCommand() + with self.assertLogs(level='WARNING') as logs, \ + mock.patch.object(NestedShareRecordShareCommand, '_get_direct_user_share', + return_value=None): + cmd.execute(_make_params(nested_share_records={ruid: robj}), + record=ruid, email=[email], action='grant', role='viewer') + + output = '\n'.join(logs.output) + self.assertIn('nsf-share-record: Share invitation has been sent', output) + + @patch('keepercommander.nested_share_folder.record_api.share_record_v3') + def test_share_record_no_relationship_still_fails(self, mock_share): + import keepercommander.nested_share_folder as nsf + from keepercommander.commands.nested_share_folder import NestedShareRecordShareCommand + + nsf.__dict__.pop('share_record_v3', None) + ruid, robj = _make_record() + mock_share.side_effect = ValueError( + "No sharing relationship with 'user@example.com'. " + "Please invite them to share first, then repeat this command.") + + cmd = NestedShareRecordShareCommand() + with mock.patch.object(NestedShareRecordShareCommand, '_get_direct_user_share', + return_value=None): + with self.assertRaises(CommandError) as ctx: + cmd.execute(_make_params(nested_share_records={ruid: robj}), + record=ruid, email=['user@example.com'], action='grant', role='viewer') + self.assertIn('No sharing relationship', str(ctx.exception)) + def test_share_record_roe_rejects_non_grant(self): from keepercommander.commands.nested_share_folder import NestedShareRecordShareCommand ruid, robj = _make_record() From d19804d854ceff14c1d8978ddab19bbe11c68abe Mon Sep 17 00:00:00 2001 From: pvagare-ks Date: Fri, 28 Aug 2026 21:20:30 +0530 Subject: [PATCH 04/12] added PathDelimiter (#2328) --- keepercommander/importer/cyberark/cyberark.py | 1 + 1 file changed, 1 insertion(+) diff --git a/keepercommander/importer/cyberark/cyberark.py b/keepercommander/importer/cyberark/cyberark.py index c66a2bd9f..2873bf209 100644 --- a/keepercommander/importer/cyberark/cyberark.py +++ b/keepercommander/importer/cyberark/cyberark.py @@ -28,6 +28,7 @@ BaseDownloadMembership, BaseImporter, Folder, + PathDelimiter, Permission, Record, RecordField, From 08df741699ea6f3bc1ac08db98f1c4de457b8513 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Fri, 28 Aug 2026 17:21:24 +0530 Subject: [PATCH 05/12] KC-1427: Add NSF folder support to list-sf --roe-eligible (#2323) * Support NSF folders in list-sf --roe-eligible Add search_nested_share_folders() helper and extend RecordListSfCommand to include NSF folders with PAM User rotation in --roe-eligible results. Enables integrations (Slack, Google Chat) to detect PAM eligibility on NSF folders. * Add NSF folder support to list-sf --roe-eligible with folder_type column Gate NSF search behind --roe-eligible flag; add folder_type column ('Classic' or 'Nested') to enable integrations to distinguish classic vs NSF folder UIDs. Includes test coverage. --- keepercommander/api.py | 36 ++++++++++++++++++++++++++++++ keepercommander/commands/record.py | 18 ++++++++++----- unit-tests/test_api.py | 31 +++++++++++++++++++++++++ unit-tests/test_command_record.py | 33 +++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/keepercommander/api.py b/keepercommander/api.py index ad83ace7a..77c76717d 100644 --- a/keepercommander/api.py +++ b/keepercommander/api.py @@ -550,6 +550,42 @@ def search_shared_folders(params, searchstring, use_regex=False): return search_results +def search_nested_share_folders(params, searchstring, use_regex=False): + """Search Nested Share Folders (v3 folder tree). + + Args: + params: KeeperParams + searchstring: Search string (tokens or regex depending on use_regex) + use_regex: If True, treat as regex. If False (default), token-based search. + If searchstring is empty, returns all Nested Share Folders. + + Returns: + List of (folder_uid, name) tuples. + """ + nsf_folders = getattr(params, 'nested_share_folders', {}) or {} + + if not searchstring: + match_func = lambda target: True + elif use_regex: + p = re.compile(searchstring.lower()) + match_func = lambda target: p.search(target) + else: + tokens = [t.lower() for t in searchstring.split() if t.strip()] + if not tokens: + match_func = lambda target: True + else: + match_func = lambda target: all(token in target for token in tokens) + + search_results = [] + for folder_uid, folder in nsf_folders.items(): + name = folder.get('name', '') + target = (folder_uid + ' ' + name).lower() + if match_func(target): + search_results.append((folder_uid, name)) + + return search_results + + def search_teams(params, searchstring, use_regex=False): """Search teams. diff --git a/keepercommander/commands/record.py b/keepercommander/commands/record.py index 1774a65d5..a66ab6f3c 100644 --- a/keepercommander/commands/record.py +++ b/keepercommander/commands/record.py @@ -1882,15 +1882,21 @@ def execute(self, params, **kwargs): fmt = kwargs.get('format', 'table') pattern = kwargs['pattern'] if 'pattern' in kwargs else None results = api.search_shared_folders(params, pattern or '') + nsf_results = [] + if kwargs.get('roe_eligible'): results = [sf for sf in results if vault_extensions.shared_folder_has_pam_user_with_rotation(params, sf.shared_folder_uid)] - if any(results): - table = [] - headers = ['shared_folder_uid', 'name'] if fmt == 'json' else ['Shared Folder UID', 'Name'] - for sf in results: - row = [sf.shared_folder_uid, sf.name] - table.append(row) + + nsf_results = api.search_nested_share_folders(params, pattern or '') + nsf_results = [(folder_uid, name) for folder_uid, name in nsf_results + if vault_extensions.nested_share_folder_has_pam_user_with_rotation(params, folder_uid)] + + table = [[sf.shared_folder_uid, sf.name, 'Classic'] for sf in results] + table.extend([[folder_uid, name, 'Nested'] for folder_uid, name in nsf_results]) + + if table: + headers = ['shared_folder_uid', 'name', 'folder_type'] if fmt == 'json' else ['Shared Folder UID', 'Name', 'Type'] table.sort(key=lambda x: (x[1] or '').lower()) return base.dump_report_data(table, headers, fmt=fmt, filename=kwargs.get('output'), diff --git a/unit-tests/test_api.py b/unit-tests/test_api.py index 7601c1926..415057ed5 100644 --- a/unit-tests/test_api.py +++ b/unit-tests/test_api.py @@ -61,6 +61,37 @@ def test_search_shared_folders(self): sfs = api.search_shared_folders(params, 'INVALID') self.assertEqual(len(sfs), 0) + def test_search_nested_share_folders(self): + params = get_synced_params() + params.nested_share_folders = { + 'nsf_uid_1': {'name': 'Test Folder 1'}, + 'nsf_uid_2': {'name': 'Test Folder 2'}, + 'nsf_uid_3': {'name': 'Another Folder'}, + } + + # Empty search returns all NSF folders + nsfs = api.search_nested_share_folders(params, '') + self.assertEqual(len(nsfs), 3) + + # Token search matches name + nsfs = api.search_nested_share_folders(params, 'test') + self.assertEqual(len(nsfs), 2) + + # UID match + nsfs = api.search_nested_share_folders(params, 'nsf_uid_1') + self.assertEqual(len(nsfs), 1) + self.assertEqual(nsfs[0], ('nsf_uid_1', 'Test Folder 1')) + + # No match + nsfs = api.search_nested_share_folders(params, 'INVALID') + self.assertEqual(len(nsfs), 0) + + # Missing nested_share_folders attribute + params_no_nsf = get_synced_params() + delattr(params_no_nsf, 'nested_share_folders') + nsfs = api.search_nested_share_folders(params_no_nsf, '') + self.assertEqual(len(nsfs), 0) + def test_search_teams(self): params = get_synced_params() diff --git a/unit-tests/test_command_record.py b/unit-tests/test_command_record.py index d21e3927f..584f5172d 100644 --- a/unit-tests/test_command_record.py +++ b/unit-tests/test_command_record.py @@ -309,6 +309,39 @@ def test_shared_list_filters_by_roe_eligible(self): cmd.execute(params, roe_eligible=True) mock_print.assert_called() + def test_shared_list_nsf_only_when_roe_eligible(self): + params = get_synced_params() + params.nested_share_folders = {'nsf_uid_1': {'name': 'Test NSF Folder'}} + params.shared_folder_cache = {} # Clear classic folders to isolate NSF + cmd = record.RecordListSfCommand() + + # Without roe_eligible, NSF should not be included + with mock.patch('keepercommander.commands.base.dump_report_data') as dump: + cmd.execute(params, roe_eligible=False) + self.assertEqual(dump.call_count, 0, "NSF should not be searched when roe_eligible=False (no results)") + + # With roe_eligible, NSF should be included if it has PAM rotation + with mock.patch('keepercommander.commands.base.dump_report_data') as dump: + with mock.patch( + 'keepercommander.vault_extensions.nested_share_folder_has_pam_user_with_rotation', + return_value=True): + cmd.execute(params, roe_eligible=True) + rows = dump.call_args[0][0] + self.assertEqual(len(rows), 1, "NSF should be included when roe_eligible=True and has PAM rotation") + self.assertEqual(rows[0][2], 'Nested', "NSF rows should have folder_type='Nested'") + + def test_shared_list_folder_type_column(self): + params = get_synced_params() + cmd = record.RecordListSfCommand() + + with mock.patch( + 'keepercommander.vault_extensions.shared_folder_has_pam_user_with_rotation', + return_value=True): + with mock.patch('keepercommander.commands.base.dump_report_data') as dump: + cmd.execute(params, roe_eligible=True, format='json') + headers = dump.call_args[0][1] + self.assertIn('folder_type', headers, "JSON output should include folder_type column") + def test_team_list_command(self): params = get_synced_params() cmd = record.RecordListTeamCommand() From 87e574b3442ecd2314cbbb3ba03dc9259b6d9194 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Fri, 28 Aug 2026 10:15:49 -0700 Subject: [PATCH 06/12] NSF: failed to load record for ls --- keepercommander/commands/folder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/commands/folder.py b/keepercommander/commands/folder.py index 7da2d0da8..ade0054c6 100644 --- a/keepercommander/commands/folder.py +++ b/keepercommander/commands/folder.py @@ -231,7 +231,7 @@ def _load_record_for_ls(params, uid): rec = vault.TypedRecord(version=3) rec.record_uid = uid rec.title = dj.get('title', uid) - rec.record_type = dj.get('type', '') + rec.type_name = dj.get('type', '') rec.load_record_data(dj, None) return rec return None From 3fe3adeea3d423f901df3c4f1cd1a7d405b9c7a7 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Mon, 31 Aug 2026 16:58:30 +0530 Subject: [PATCH 07/12] KC-1437: Fix NSF record fields reverting after pam tunnel/connection edit due to stale cache (#2329) * KC-1408: Treat first-time share invitations as success in Service Mode (#2325) * KC-1408: Treat first-time share invitations as success in Service Mode (#2303) * Treat first-time share invitations as success in Service Mode * Use ShareInviteSentError so NSF invite success does not mask real share failures * Fix test imports for deleted pam_debug modules Remove imports and tests for PAMDebugACLCommand and PAMDebugLinkCommand, which were deleted in commit 2f1447ac. Update test file to only include tests for modules that still exist. * added PathDelimiter (#2328) * KC-1427: Add NSF folder support to list-sf --roe-eligible (#2323) * Support NSF folders in list-sf --roe-eligible Add search_nested_share_folders() helper and extend RecordListSfCommand to include NSF folders with PAM User rotation in --roe-eligible results. Enables integrations (Slack, Google Chat) to detect PAM eligibility on NSF folders. * Add NSF folder support to list-sf --roe-eligible with folder_type column Gate NSF search behind --roe-eligible flag; add folder_type column ('Classic' or 'Nested') to enable integrations to distinguish classic vs NSF folder UIDs. Includes test coverage. * NSF: failed to load record for ls * Fix NSF record fields reverting after pam tunnel/connection edit due to stale cache * Fix NSF record fields reverting after pam tunnel edit Load fresh record from raw cache after sync to avoid stale objects. * Fix NSF record fields reverting after pam tunnel/connection edit. Reload fresh record from cache after sync to avoid stale TypedRecord instances. * addressed review comments * Fix unit test mocks: update_pam_record should return False, remove redundant decorator --------- Co-authored-by: pvagare-ks Co-authored-by: Sergey Kolupaev --- keepercommander/commands/discoveryrotation.py | 4 +- keepercommander/commands/pam/vault_target.py | 27 +++++++++- .../commands/pam_import/record_loader.py | 32 +++++++---- keepercommander/commands/pam_saas/config.py | 4 +- .../commands/tunnel_and_connections.py | 54 +++++++++++++++---- .../nested_share_folder/record_api.py | 8 +++ .../test_pam_connection_edit_scrollback.py | 32 +++++++++-- .../pam/test_pam_connection_edit_security.py | 30 ++++++++++- unit-tests/pam/test_pam_debug_nsf.py | 4 ++ unit-tests/pam/test_pam_nsf_config.py | 31 +++++++++++ unit-tests/pam/test_pam_rbi_edit.py | 33 ++++++++++++ 11 files changed, 231 insertions(+), 28 deletions(-) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index f011378da..9be7a7711 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -35,6 +35,7 @@ get_vault_record_title_type, find_pam_records_by_search, resolve_pam_config_folder_info, pam_folder_json_payload, place_record_in_folder, create_pam_configuration_in_folder, update_pam_record, records_in_folder, + reload_pam_record_if_nsf_updated, ) from .pam.config_helper import configuration_controller_get, \ pam_configurations_get_all, pam_configuration_remove, \ @@ -3121,7 +3122,8 @@ def execute(self, params, **kwargs): self.parse_properties(params, configuration, config_edit=True, **kwargs) self.verify_required(configuration, command='pam-config-edit') - update_pam_record(params, configuration, command='pam-config-edit') + was_nsf = update_pam_record(params, configuration, command='pam-config-edit') + configuration = reload_pam_record_if_nsf_updated(params, configuration, configuration.record_uid, was_nsf) admin_cred_ref = '' value = field.get_default_value(dict) diff --git a/keepercommander/commands/pam/vault_target.py b/keepercommander/commands/pam/vault_target.py index 91cd019ad..adfc63342 100644 --- a/keepercommander/commands/pam/vault_target.py +++ b/keepercommander/commands/pam/vault_target.py @@ -566,8 +566,31 @@ def is_pam_nsf_record(params, record_uid): return False -def update_pam_record(params, record, command='pam', force_nsf=False): +def reload_pam_record_if_nsf_updated(params, record, record_uid, was_nsf_updated): + """Reload a PAM record from cache if it was updated via NSF. + + After NSF updates, the in-memory record object becomes stale. This helper + reloads it from the refreshed cache. For classic updates, returns the record + unchanged since classic updates use deferred sync (no immediate cache refresh). + + Raises CommandError if NSF update occurred but reload fails, rather than + proceeding with known-stale data (which reintroduces the field-reversion bug). + """ + if was_nsf_updated: + from ..pam_import.record_loader import load_pam_record + reloaded = load_pam_record(params, record_uid) + if not reloaded: + raise CommandError('pam', f'Failed to reload NSF-updated record {record_uid} from cache after sync. ' + 'Record data may be stale; aborting edit to prevent field reversion.') + return reloaded + return record + + +def update_pam_record(params, record, command='pam', force_nsf=False) -> bool: """Update a PAM record via NSF v3 API or classic record_management. + + Returns True if the record was updated via NSF and the in-memory object is now stale + and must be reloaded from cache. Returns False for classic updates using deferred sync. """ from ..nested_share_folder.helpers import normalize_nsf_user_message from ...nested_share_folder.record_api import update_record_v3 @@ -587,11 +610,13 @@ def update_pam_record(params, record, command='pam', force_nsf=False): 'Failed to update record in Nested Share Folder') from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys sync_down_preserving_nsf_keys(params) + return True else: record_management.update_record(params, record) # Defer vault refresh so a second classic edit in the same session does # not send a stale record_cache revision (no immediate sync_down here). params.sync_data = True + return False def execute_record_add_in_folder(params, args, folder_uid, command='pam'): diff --git a/keepercommander/commands/pam_import/record_loader.py b/keepercommander/commands/pam_import/record_loader.py index 306614798..b24b26b0e 100644 --- a/keepercommander/commands/pam_import/record_loader.py +++ b/keepercommander/commands/pam_import/record_loader.py @@ -35,33 +35,43 @@ def iter_accessible_record_uids(params) -> Iterator[str]: def load_pam_record(params, record_uid: str) -> Optional[vault.KeeperRecord]: - """Load a vault record from classic cache, NSF cache, or NSF record data.""" + """Load a vault record from classic cache, NSF cache, or NSF record data. + + Priority: classic cache (highest revision) > NSF cache > NSF record data (lowest). + """ record_uid = (record_uid or '').strip() if not record_uid: return None + # Try classic cache first (most reliable, has encryption keys and metadata) cached = get_record_from_cache(params, record_uid) if cached and cached.get('data_unencrypted'): rec = vault.KeeperRecord.load(params, cached) if rec: return rec + # Try loading by UID from classic vault (may trigger decryption from cached key material) rec = vault.KeeperRecord.load(params, record_uid) if rec: return rec + # Fall back to NSF cache (record_key=None, may be stale, but better than nothing) nsf_record_data = getattr(params, 'nested_share_record_data', None) or {} nsf_records = getattr(params, 'nested_share_records', None) or {} rd = nsf_record_data.get(record_uid) or {} - if 'data_json' not in rd: - return None + if 'data_json' in rd: + dj = rd['data_json'] + version = nsf_records.get(record_uid, {}).get('version', 3) + if version in (3, 6): + keeper_record = vault.TypedRecord(version=version) + keeper_record.record_uid = record_uid + keeper_record.load_record_data(dj, None) + return keeper_record - dj = rd['data_json'] - version = nsf_records.get(record_uid, {}).get('version', 3) - if version not in (3, 6): - return None + cached = get_record_from_cache(params, record_uid) + if cached and cached.get('data_unencrypted'): + rec = vault.KeeperRecord.load(params, cached) + if rec: + return rec - keeper_record = vault.TypedRecord(version=version) - keeper_record.record_uid = record_uid - keeper_record.load_record_data(dj, None) - return keeper_record + return vault.KeeperRecord.load(params, record_uid) diff --git a/keepercommander/commands/pam_saas/config.py b/keepercommander/commands/pam_saas/config.py index b27eff20e..f40b151f4 100644 --- a/keepercommander/commands/pam_saas/config.py +++ b/keepercommander/commands/pam_saas/config.py @@ -181,6 +181,7 @@ def _create_config(params: KeeperParams, plugin_code_bytes: bytes | None = None): from ..pam.vault_target import ( create_record_in_folder, update_pam_record, is_nested_share_folder, + reload_pam_record_if_nsf_updated, ) from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys @@ -269,7 +270,8 @@ def _create_config(params: KeeperParams, attachment.upload_attachments(params, existing_record, [task]) # upload_attachments updates fileRef on existing_record via facade - update_pam_record(params, existing_record, command='pam action saas config') + was_nsf = update_pam_record(params, existing_record, command='pam action saas config') + existing_record = reload_pam_record_if_nsf_updated(params, existing_record, existing_record.record_uid, was_nsf) print("") print(f"{bcolors.OKGREEN}Created SaaS configuration record with UID of {record.record_uid}{bcolors.ENDC}") diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index caf45a35f..f818ef8f7 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -36,7 +36,8 @@ wait_for_tunnel_connection, create_rust_webrtc_settings, \ print_above_keeper_prompt from .pam.router_helper import get_dag_leafs -from .pam.vault_target import update_pam_record +from .pam.vault_target import update_pam_record, reload_pam_record_if_nsf_updated +from .pam_import.nsf_helpers import sync_down_preserving_nsf_keys from .tunnel_registry import ( PARENT_GRACE_SECONDS, is_pid_alive, @@ -48,6 +49,7 @@ from .. import api, vault from ..display import bcolors from ..error import CommandError +import json from ..params import LAST_RECORD_UID from ..subfolder import find_folders from ..utils import value_to_boolean @@ -394,6 +396,9 @@ def get_parser(self): return PAMTunnelEditCommand.pam_cmd_parser def execute(self, params, **kwargs): + # Ensure cache is fresh to avoid stale data from concurrent nsf-record-update calls + sync_down_preserving_nsf_keys(params) + tunneling_override_port = kwargs.get('tunneling_override_port') if ((kwargs.get('enable_tunneling') and kwargs.get('disable_tunneling')) or @@ -414,12 +419,16 @@ def execute(self, params, **kwargs): record_name = kwargs.get('record') if not record_name: raise CommandError('pam tunnel edit', '"record" parameter is required.') + + # First resolve to get record_uid record = RecordMixin.resolve_single_record(params, record_name) if not record: raise CommandError('pam tunnel edit', f'{bcolors.FAIL}Record \"{record_name}\" not found.{bcolors.ENDC}') if not isinstance(record, vault.TypedRecord): raise CommandError('pam tunnel edit', f'Record \"{record_name}\" can not be edited.') + record_uid = record.record_uid + # config parameter is optional and maybe (auto)resolved from PAM record config_name = kwargs.get('config', None) cfg_rec = RecordMixin.resolve_single_record(params, config_name) @@ -460,7 +469,8 @@ def execute(self, params, **kwargs): record.custom.append(record_seed) dirty = True if dirty: - update_pam_record(params, record, command='pam tunnel edit') + was_nsf = update_pam_record(params, record, command='pam tunnel edit') + record = reload_pam_record_if_nsf_updated(params, record, record_uid, was_nsf) traffic_encryption_key = record.get_typed_field('trafficEncryptionSeed') if not traffic_encryption_key: @@ -532,7 +542,8 @@ def execute(self, params, **kwargs): dirty = True # Persist the record changes (new pamSettings field or port modifications) if dirty: - update_pam_record(params, record, command='pam tunnel edit') + was_nsf = update_pam_record(params, record, command='pam tunnel edit') + record = reload_pam_record_if_nsf_updated(params, record, record_uid, was_nsf) dirty = False if not tmp_dag.is_tunneling_config_set_up(record_uid): print(f"{bcolors.FAIL}No PAM Configuration UID set. This must be set for tunneling to work. " @@ -582,12 +593,17 @@ def execute(self, params, **kwargs): if dirty: tmp_dag.set_resource_allowed(resource_uid=record_uid, tunneling=_tunneling, allowed_settings_name=allowed_settings_name) - update_pam_record(params, record, command='pam tunnel edit') + was_nsf = update_pam_record(params, record, command='pam tunnel edit') + record = reload_pam_record_if_nsf_updated(params, record, record_uid, was_nsf) # Print out the tunnel settings if not kwargs.get('silent'): tmp_dag.print_tunneling_config(record_uid, record.get_typed_field('pamSettings'), config_uid) + # Final sync ensures NSF record updates are fully propagated. (First sync occurs within + # update_pam_record for NSF updates; this is a belt-and-suspenders safety flush.) + sync_down_preserving_nsf_keys(params) + class PAMTunnelStartCommand(Command): pam_cmd_parser = argparse.ArgumentParser(prog='pam tunnel start') @@ -2711,6 +2727,9 @@ def get_parser(self): return PAMConnectionEditCommand.parser def execute(self, params, **kwargs): + # Ensure cache is fresh to avoid stale data from concurrent nsf-record-update calls + sync_down_preserving_nsf_keys(params) + connection_override_port = kwargs.get('connections_override_port', None) # Convert on/off/default to True/False/None @@ -2727,12 +2746,16 @@ def execute(self, params, **kwargs): record_name = kwargs.get('record') if not record_name: raise CommandError('pam connection edit', 'Record parameter is required.') + + # First resolve to get record_uid record = RecordMixin.resolve_single_record(params, record_name) if not record: raise CommandError('pam connection edit', f'{bcolors.FAIL}Record \"{record_name}\" not found.{bcolors.ENDC}') if not isinstance(record, vault.TypedRecord): raise CommandError('pam connection edit', f'Record \"{record_name}\" can not be edited.') + record_uid = record.record_uid + # config parameter is optional and maybe (auto)resolved from PAM record config_name = kwargs.get('config', None) cfg_rec = RecordMixin.resolve_single_record(params, config_name) @@ -2980,7 +3003,8 @@ def _get_effective_protocol(): logging.debug(f'security is already {target_sec} on record={record_uid}') if dirty: - update_pam_record(params, record, command='pam connection edit') + was_nsf = update_pam_record(params, record, command='pam connection edit') + record = reload_pam_record_if_nsf_updated(params, record, record_uid, was_nsf) traffic_encryption_key = record.get_typed_field('trafficEncryptionSeed') if not traffic_encryption_key: @@ -3145,6 +3169,10 @@ def _get_effective_protocol(): # Print out PAM Settings if not kwargs.get("silent", False): tdag.print_tunneling_config(record_uid, record.get_typed_field('pamSettings'), config_uid) + # Final sync ensures NSF record updates are fully propagated. (First sync occurs within + # update_pam_record for NSF updates; this is a belt-and-suspenders safety flush.) + sync_down_preserving_nsf_keys(params) + class PAMConnectionJitCommand(Command): parser = argparse.ArgumentParser(prog='pam connection jit') @@ -3760,6 +3788,9 @@ def get_parser(self): return PAMRbiEditCommand.parser def execute(self, params, **kwargs): + # Ensure cache is fresh to avoid stale data from concurrent nsf-record-update calls + sync_down_preserving_nsf_keys(params) + record_name = kwargs.get('record') or '' config_name = kwargs.get('config') or '' autofill = kwargs.get('autofill') or '' @@ -4051,7 +4082,8 @@ def update_connection_choice(field_name, value): update_connection_choice('sessionPersistence', session_persistence) if dirty: - update_pam_record(params, record, command='pam rbi edit') + was_nsf = update_pam_record(params, record, command='pam rbi edit') + record = reload_pam_record_if_nsf_updated(params, record, record_uid, was_nsf) traffic_encryption_key = record.get_typed_field('trafficEncryptionSeed') if not traffic_encryption_key: @@ -4154,7 +4186,9 @@ def update_connection_choice(field_name, value): session_recording=rec_val) # if not kwargs.get("silent", False): # tdag.print_tunneling_config(record_uid, record.get_typed_field('pamRemoteBrowserSettings'), config_uid) - params.sync_data = True + # Final sync ensures NSF record updates are fully propagated. (First sync occurs within + # update_pam_record for NSF updates; this is a belt-and-suspenders safety flush.) + sync_down_preserving_nsf_keys(params) class PAMSplitCommand(Command): pam_cmd_parser = argparse.ArgumentParser(prog='pam split') @@ -4229,7 +4263,8 @@ def execute(self, params, **kwargs): pam_settings = vault.TypedField.new_field('pamSettings', "", "") record.fields.append(pam_settings) - update_pam_record(params, record, command='pam-split') + was_nsf = update_pam_record(params, record, command='pam-split') + record = reload_pam_record_if_nsf_updated(params, record, record_uid, was_nsf) print(f"{bcolors.WARNING}Record {record_uid} has no data to split and " "was converted to the new format. Remember to manually add " @@ -4284,7 +4319,8 @@ def execute(self, params, **kwargs): pam_settings = vault.TypedField.new_field('pamSettings', "", "") record.fields.append(pam_settings) - update_pam_record(params, record, command='pam-split') + was_nsf = update_pam_record(params, record, command='pam-split') + record = reload_pam_record_if_nsf_updated(params, record, record_uid, was_nsf) if pam_config_uid: encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) diff --git a/keepercommander/nested_share_folder/record_api.py b/keepercommander/nested_share_folder/record_api.py index 66c1dda35..4f2227638 100644 --- a/keepercommander/nested_share_folder/record_api.py +++ b/keepercommander/nested_share_folder/record_api.py @@ -283,6 +283,14 @@ def _build_update(payload, rev): new_revision = getattr(response, 'revision', 0) if success: patch_record_revision(params, record_uid, new_revision) + nsf_data = getattr(params, 'nested_share_record_data', {}).get(record_uid) + if nsf_data is not None: + # Update NSF cache with decrypted record data (dict form) + nsf_data['data_json'] = data + cached_record = getattr(params, 'record_cache', {}).get(record_uid) + if cached_record is not None and cached_record.get('data_unencrypted') is not None: + # Update classic cache with decrypted record data (JSON string form) + cached_record['data_unencrypted'] = json.dumps(data) return { 'record_uid': record_uid, 'status': record_pb2.RecordModifyResult.Name(r.status), diff --git a/unit-tests/pam/test_pam_connection_edit_scrollback.py b/unit-tests/pam/test_pam_connection_edit_scrollback.py index 090fe1ad5..3d3a581e0 100644 --- a/unit-tests/pam/test_pam_connection_edit_scrollback.py +++ b/unit-tests/pam/test_pam_connection_edit_scrollback.py @@ -11,6 +11,30 @@ skip_tests = False skip_reason = "" + + +def mock_sync_decorator(cls): + """Decorator to add sync mocking to test classes that execute PAM commands.""" + original_setup = cls.setUp if hasattr(cls, 'setUp') else None + original_teardown = cls.tearDown if hasattr(cls, 'tearDown') else None + + def new_setup(self): + if original_setup: + original_setup(self) + self.sync_patcher = __import__('unittest.mock', fromlist=['patch']).patch('keepercommander.commands.tunnel_and_connections.sync_down_preserving_nsf_keys') + self.sync_patcher.start() + self.load_patcher = __import__('unittest.mock', fromlist=['patch']).patch('keepercommander.commands.pam_import.record_loader.load_pam_record') + self.load_patcher.start() + + def new_teardown(self): + self.sync_patcher.stop() + self.load_patcher.stop() + if original_teardown: + original_teardown(self) + + cls.setUp = new_setup + cls.tearDown = new_teardown + return cls try: from keepercommander.commands.tunnel_and_connections import PAMConnectionEditCommand from keepercommander.error import CommandError @@ -91,6 +115,7 @@ def test_all_db_protocols_present_in_choices(self): self.assertIn(proto, PAMConnectionEditCommand.protocols) +@mock_sync_decorator @unittest.skipIf(skip_tests, skip_reason) class TestPamConnectionEditScrollbackValidation(unittest.TestCase): """Validation runs before DAG / token operations, so we can drive execute() @@ -233,6 +258,7 @@ def test_protocol_change_without_connections_uses_existing(self): self.assertNotIn('not supported for protocol', str(ctx.exception)) +@mock_sync_decorator @unittest.skipIf(skip_tests, skip_reason) class TestPamConnectionEditScrollbackAllowedCombinations(unittest.TestCase): """For each allowed (record_type, protocol) pair, validation must not raise @@ -325,7 +351,7 @@ def _mock_record(self, record_type='pamMachine', protocol='ssh'): return rec @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') - @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record', return_value=False) @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', return_value=(b'st', b'tk', b'tr')) @@ -344,7 +370,7 @@ def test_scrollback_alone_skips_dag(self, mock_tdag, mock_get_config_uid, mock_update.assert_called_once() @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') - @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record', return_value=False) @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', return_value=(b'st', b'tk', b'tr')) @@ -361,7 +387,7 @@ def test_key_events_alone_skips_dag(self, mock_tdag, mock_get_config_uid, mock_tdag.assert_not_called() @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') - @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record', return_value=False) @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', return_value=(b'st', b'tk', b'tr')) diff --git a/unit-tests/pam/test_pam_connection_edit_security.py b/unit-tests/pam/test_pam_connection_edit_security.py index d4dd332d7..6b0c461c9 100644 --- a/unit-tests/pam/test_pam_connection_edit_security.py +++ b/unit-tests/pam/test_pam_connection_edit_security.py @@ -21,6 +21,30 @@ skip_reason = f"Cannot import tunnel_and_connections: {e}" +def mock_sync_decorator(cls): + """Decorator to add sync mocking to test classes that execute PAM commands.""" + original_setup = cls.setUp if hasattr(cls, 'setUp') else None + original_teardown = cls.tearDown if hasattr(cls, 'tearDown') else None + + def new_setup(self): + if original_setup: + original_setup(self) + self.sync_patcher = mock.patch('keepercommander.commands.tunnel_and_connections.sync_down_preserving_nsf_keys') + self.sync_patcher.start() + self.load_patcher = mock.patch('keepercommander.commands.pam_import.record_loader.load_pam_record') + self.load_patcher.start() + + def new_teardown(self): + self.sync_patcher.stop() + self.load_patcher.stop() + if original_teardown: + original_teardown(self) + + cls.setUp = new_setup + cls.tearDown = new_teardown + return cls + + @unittest.skipIf(skip_tests, skip_reason) class TestPamConnectionEditSecurityArgs(unittest.TestCase): def setUp(self): @@ -76,6 +100,7 @@ def test_help_includes_new_flags(self): self.assertIn('-sm', help_text) +@mock_sync_decorator @unittest.skipIf(skip_tests, skip_reason) class TestPamConnectionEditSecurityValidation(unittest.TestCase): """Validation runs before DAG / token operations, so we can drive execute() @@ -197,6 +222,7 @@ def test_protocol_change_in_same_command_validated_against_new(self): self.assertIn('not supported for protocol "ssh"', str(ctx.exception)) +@mock_sync_decorator @unittest.skipIf(skip_tests, skip_reason) class TestPamConnectionEditSecurityMutation(unittest.TestCase): """Verifies the actual JSON keys written to pamSettings.connection.""" @@ -339,7 +365,7 @@ def _mock_record(self, record_type='pamMachine', protocol='rdp'): return rec @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') - @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record', return_value=False) @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', return_value=(b'st', b'tk', b'tr')) @@ -356,7 +382,7 @@ def test_ignore_server_cert_alone_skips_dag(self, mock_tdag, mock_get_config_uid mock_update.assert_called_once() @mock.patch('keepercommander.commands.tunnel_and_connections.RecordMixin.resolve_single_record') - @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record') + @mock.patch('keepercommander.commands.tunnel_and_connections.update_pam_record', return_value=False) @mock.patch('keepercommander.commands.tunnel_and_connections.api.sync_down') @mock.patch('keepercommander.commands.tunnel_and_connections.get_keeper_tokens', return_value=(b'st', b'tk', b'tr')) diff --git a/unit-tests/pam/test_pam_debug_nsf.py b/unit-tests/pam/test_pam_debug_nsf.py index 855327b0f..edbe9d3ee 100644 --- a/unit-tests/pam/test_pam_debug_nsf.py +++ b/unit-tests/pam/test_pam_debug_nsf.py @@ -12,6 +12,10 @@ from keepercommander.commands.pam_debug.dump import PAMDebugDumpCommand from keepercommander.subfolder import NestedShareFolderNode, RootFolderNode +# Note: test_acl_uses_load_pam_record_for_nsf_uids() and test_link_uses_load_pam_record_for_nsf_resource() +# were removed because the pam_debug.acl and pam_debug.link modules do not exist in the codebase. +# These tests were testing the integration of missing modules and were blocking test collection. + def _typed(uid, title, record_type='pamUser', version=3): rec = vault.TypedRecord(version=version) diff --git a/unit-tests/pam/test_pam_nsf_config.py b/unit-tests/pam/test_pam_nsf_config.py index d3372e1d9..b149e694f 100644 --- a/unit-tests/pam/test_pam_nsf_config.py +++ b/unit-tests/pam/test_pam_nsf_config.py @@ -1,3 +1,4 @@ +import json import unittest from unittest import mock @@ -9,6 +10,7 @@ from keepercommander.commands.pam.vault_target import ( create_pam_configuration_in_folder, create_record_in_folder, place_record_in_folder, resolve_pam_folder_uid, records_in_folder) +from keepercommander.commands.pam_import.record_loader import load_pam_record from keepercommander.error import CommandError from keepercommander.subfolder import NestedShareFolderNode, RootFolderNode, SharedFolderNode @@ -27,6 +29,35 @@ def _make_params(): class TestPamVaultTarget(unittest.TestCase): + def test_load_pam_record_prefers_fresh_nsf_data_over_classic_cache(self): + params = _make_params() + record_uid = 'nsf_record_uid' + params.nested_share_records = {} + params.nested_share_record_data = {} + params.record_cache[record_uid] = { + 'version': 3, + 'data_unencrypted': json.dumps({ + 'type': 'pamMachine', + 'title': 'Stale', + 'fields': [{'type': 'text', 'value': ['before']}], + 'custom': [], + }), + } + params.nested_share_records[record_uid] = {'version': 3} + params.nested_share_record_data[record_uid] = { + 'data_json': { + 'type': 'pamMachine', + 'title': 'Fresh', + 'fields': [{'type': 'text', 'value': ['after']}], + 'custom': [], + }, + } + + record = load_pam_record(params, record_uid) + + self.assertEqual(record.title, 'Fresh') + self.assertEqual(record.fields[0].value, ['after']) + @mock.patch('keepercommander.commands.pam.vault_target.api.sync_down') @mock.patch('keepercommander.commands.pam.vault_target.move_record_v3') @mock.patch('keepercommander.commands.pam.vault_target.FolderMoveCommand') diff --git a/unit-tests/pam/test_pam_rbi_edit.py b/unit-tests/pam/test_pam_rbi_edit.py index 67e5ad155..1d8631efa 100644 --- a/unit-tests/pam/test_pam_rbi_edit.py +++ b/unit-tests/pam/test_pam_rbi_edit.py @@ -27,6 +27,31 @@ skip_reason = f"Cannot import tunnel_and_connections: {e}" +def mock_sync_decorator(cls): + """Decorator to add sync mocking to test classes that execute PAM commands.""" + original_setup = cls.setUp if hasattr(cls, 'setUp') else None + original_teardown = cls.tearDown if hasattr(cls, 'tearDown') else None + + def new_setup(self): + if original_setup: + original_setup(self) + # Mock sync and load operations + self.sync_patcher = mock.patch('keepercommander.commands.tunnel_and_connections.sync_down_preserving_nsf_keys') + self.sync_patcher.start() + self.load_patcher = mock.patch('keepercommander.commands.pam_import.record_loader.load_pam_record') + self.load_patcher.start() + + def new_teardown(self): + self.sync_patcher.stop() + self.load_patcher.stop() + if original_teardown: + original_teardown(self) + + cls.setUp = new_setup + cls.tearDown = new_teardown + return cls + + @unittest.skipIf(skip_tests, skip_reason) class TestPamRbiEditArguments(unittest.TestCase): """Tests for PAMRbiEditCommand argument parsing.""" @@ -228,6 +253,7 @@ def test_session_persistence_not_provided(self): self.assertIsNone(args.session_persistence) +@mock_sync_decorator @unittest.skipIf(skip_tests, skip_reason) class TestPamRbiEditExecute(unittest.TestCase): """Tests for PAMRbiEditCommand.execute() method.""" @@ -242,7 +268,11 @@ def setUp(self): self.mock_field.value = [self.pam_settings] self.mock_record.get_typed_field.return_value = self.mock_field self.mock_params = mock.MagicMock() + # Use REAL dicts for caches so sync/reload operations work self.mock_params.record_cache = {'test-record-uid': self.mock_record} + self.mock_params.nested_share_record_data = {} + self.mock_params.nested_share_records = {} + # Parent class decorator handles sync/load mocking in setUp def test_no_param_raises_error_with_new_settings_check(self): with self.assertRaises(CommandError) as context: @@ -376,6 +406,7 @@ def test_session_persistence_default_removes_present_but_null(self, mock_update, self.assertNotIn('sessionPersistence', self.pam_settings['connection']) +@mock_sync_decorator @unittest.skipIf(skip_tests, skip_reason) class TestPamRbiEditClipboardInversion(unittest.TestCase): """Tests for clipboard inversion logic.""" @@ -444,6 +475,7 @@ def test_clipboard_both_off(self, mock_update, mock_resolve): self.assertEqual(self.pam_settings['connection'].get('disablePaste'), True) +@mock_sync_decorator @unittest.skipIf(skip_tests, skip_reason) class TestPamRbiEditRecordUpdate(unittest.TestCase): """Tests for record update behavior.""" @@ -598,6 +630,7 @@ def test_alias_sr(self): self.assertEqual(args.audio_sample_rate, 44100) +@mock_sync_decorator @unittest.skipIf(skip_tests, skip_reason) class TestPamRbiEditAudioSettings(unittest.TestCase): """Tests for audio settings.""" From 26d0449be8c445bdc13940ca36b75676538913f3 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Mon, 31 Aug 2026 18:00:45 +0530 Subject: [PATCH 08/12] KC-1430: Deny host-path I/O in Service Mode (#2315) * Fix Service Mode Host I/O * Handle alias for file import/export * Fix python3.9 test case * Fix token check for flags * Fix review comments * Remove extra comments and doc strings * Fix directory clean up race condition --- keepercommander/service/api/command.py | 2 +- keepercommander/service/core/request_queue.py | 2 +- keepercommander/service/util/command_util.py | 15 +- .../service/util/request_validation.py | 32 +- .../service/util/verified_command.py | 291 +++++++++++++++++- unit-tests/service/test_api_routes.py | 2 +- unit-tests/service/test_auth_security.py | 9 + unit-tests/service/test_queue_concurrency.py | 8 +- .../service/test_service_mode_pam_tunnel.py | 200 ++++++++++++ 9 files changed, 535 insertions(+), 26 deletions(-) diff --git a/keepercommander/service/api/command.py b/keepercommander/service/api/command.py index c80c6b1ea..bb736bec7 100644 --- a/keepercommander/service/api/command.py +++ b/keepercommander/service/api/command.py @@ -99,7 +99,7 @@ def execute_command_direct(**kwargs) -> Tuple[Union[Response, bytes], int]: ) return response_data - response, status_code = CommandExecutor.execute(processed_command) + response, status_code = CommandExecutor.execute(processed_command, temp_files=temp_files) # If we get a busy response, add v1-specific message if (isinstance(response, dict) and diff --git a/keepercommander/service/core/request_queue.py b/keepercommander/service/core/request_queue.py index 44e387e19..175947418 100644 --- a/keepercommander/service/core/request_queue.py +++ b/keepercommander/service/core/request_queue.py @@ -325,7 +325,7 @@ def _process_request(self, request: QueuedRequest): try: # Execute the command using existing CommandExecutor - result, status_code = CommandExecutor.execute(request.command) + result, status_code = CommandExecutor.execute(request.command, temp_files=request.temp_files) # Mark as completed request.status = RequestStatus.COMPLETED diff --git a/keepercommander/service/util/command_util.py b/keepercommander/service/util/command_util.py index cb7d94bf6..0c7507f08 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -148,13 +148,12 @@ def _finalize_parsed_response(cls, response: Any) -> Tuple[Any, int]: return response, status_code @classmethod - def execute(cls, command: str) -> Tuple[Any, int]: + def execute(cls, command: str, temp_files: Optional[list] = None) -> Tuple[Any, int]: logger.debug(f"Executing command: {sanitize_command_fields(command)}") - validation_error = cls.validate_command(command) if validation_error: return validation_error - + from ..core.globals import ensure_params_loaded try: params = ensure_params_loaded() @@ -169,11 +168,19 @@ def execute(cls, command: str) -> Tuple[Any, int]: except ValueError: command_tokens = command.split() + # This request's own FILEDATA directory - the only paths Service + # Mode will treat as safe, not the whole shared OS temp root. + request_temp_dir = os.path.dirname(temp_files[0]) if temp_files else None + # Same tokens the CLI will run — do not use raw HTTP split(" ") service_mode_error = Verifycommand.validate_service_mode_restrictions( - command_tokens + command_tokens, request_temp_dir ) if service_mode_error: + logger.warning( + f"Service Mode blocked command '{command_tokens[0] if command_tokens else ''}': " + f"{service_mode_error}" + ) return {"status": "error", "error": service_mode_error}, 403 force_error = Verifycommand.validate_enterprise_user_add_role_force( diff --git a/keepercommander/service/util/request_validation.py b/keepercommander/service/util/request_validation.py index cff1051f0..b0ad33cf6 100644 --- a/keepercommander/service/util/request_validation.py +++ b/keepercommander/service/util/request_validation.py @@ -15,6 +15,7 @@ import tempfile import os import json +import shutil from ..decorators.logging import logger, sanitize_command_fields @@ -71,9 +72,11 @@ def process_file_data(request_data: Dict[str, Any], command: str) -> Tuple[str, logger.warning("filedata must be a JSON object or array") return processed_command, temp_files + request_temp_dir = None try: - # Create temporary file with the filedata content - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as temp_file: + request_temp_dir = tempfile.mkdtemp(prefix='keeper_svc_') + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, + encoding='utf-8', dir=request_temp_dir) as temp_file: json.dump(filedata, temp_file, indent=2) temp_file_path = temp_file.name temp_files.append(temp_file_path) @@ -91,23 +94,24 @@ def process_file_data(request_data: Dict[str, Any], command: str) -> Tuple[str, except Exception as e: logger.error(f"Error creating temporary file for filedata: {e}") - # Clean up any created temp files - for temp_path in temp_files: + if request_temp_dir: try: - os.unlink(temp_path) - except Exception: - pass + shutil.rmtree(request_temp_dir) + logger.debug(f"Cleaned up request temp directory: {request_temp_dir}") + except Exception as cleanup_error: + logger.warning(f"Failed to clean up request temp directory {request_temp_dir}: {cleanup_error}") return command, [] return processed_command, temp_files @staticmethod def cleanup_temp_files(temp_files: list) -> None: - """Clean up temporary files. - + """Clean up temporary files and their parent per-request directories. + Args: temp_files: List of temporary file paths to clean up """ + parent_dirs = set() for temp_path in temp_files: try: if os.path.exists(temp_path): @@ -115,6 +119,16 @@ def cleanup_temp_files(temp_files: list) -> None: logger.debug(f"Cleaned up temporary file: {temp_path}") except Exception as e: logger.warning(f"Failed to clean up temporary file {temp_path}: {e}") + parent_dirs.add(os.path.dirname(temp_path)) + + # Remove parent directories (each file has one dedicated per-request directory) + for parent_dir in parent_dirs: + try: + if os.path.exists(parent_dir): + shutil.rmtree(parent_dir) + logger.debug(f"Cleaned up request temp directory: {parent_dir}") + except Exception as e: + logger.warning(f"Failed to clean up request temp directory {parent_dir}: {e}") @staticmethod def validate_request_json() -> Optional[Tuple]: diff --git a/keepercommander/service/util/verified_command.py b/keepercommander/service/util/verified_command.py index 1e240ad05..631984afc 100644 --- a/keepercommander/service/util/verified_command.py +++ b/keepercommander/service/util/verified_command.py @@ -1,3 +1,8 @@ +import contextlib +import io +import os + + class Verifycommand: # pam tunnel aliases: start=s, list=l, stop=x, edit=e, diagnose=d # (see PAMTunnelCommand.register_command in tunnel_and_connections.py) @@ -12,8 +17,49 @@ class Verifycommand: # Aliases from record.py — CommandExecutor checks tokens before cli expands them. _RECORD_EDIT_COMMANDS = frozenset({'record-add', 'ra', 'record-update', 'ru'}) + # WARNING: everything below is a DENYLIST. Any command/flag that reads or + # writes a host file and is NOT enumerated here is allowed by default. + # Adding a new command with local file I/O? Add it here, or it silently + # bypasses Service Mode's "no host filesystem access" boundary. + # + # Commands that always read/write host files (no safe Service Mode form). + _HOST_FS_COMMANDS = frozenset({ + 'run-batch', 'run', + 'export', + 'download-membership', + 'download-record-types', + 'apply-membership', + 'load-record-types', + }) + # Positional file input; FILEDATA is rewritten to a temp path before execute. + _FILE_INPUT_COMMANDS = frozenset({'import', 'enterprise-push'}) + # import --format values that name an account/API/URL source, not a local + # file (importer/commands.py choices). Any format NOT in this set is treated as file-based by default. + _IMPORT_FORMATS_WITHOUT_FILE = frozenset({ + 'lastpass', 'manageengine', 'thycotic', 'cyberark', 'cyberark_portal', + }) + # generate's registered alias (commands/utils.py: aliases['gen'] = 'generate'). + _GENERATE_COMMAND_NAMES = frozenset({'generate', 'gen'}) + # pam's 'project' subcommand alias (discoveryrotation.py: register_command('project', ..., 'p')). + _PAM_PROJECT_ALIASES = {'p': 'project'} + # pam project's own subcommand aliases (pam_import/commands.py: register_command(...)). + _PAM_PROJECT_SUBCOMMAND_ALIASES = {'x': 'export', 'i': 'import', 'e': 'extend'} + # --output values that select a mode/format, not a host path. + _NON_PATH_OUTPUT_VALUES = frozenset({ + 'clipboard', 'stdout', 'stdouthidden', 'variable', + 'token', 'base64', 'json', 'k8s', 'text', + }) + # Extensions that indicate a local data file (avoid treating emails as paths). + _LOCAL_FILE_EXTENSIONS = frozenset({ + '.json', '.csv', '.txt', '.yaml', '.yml', '.xml', '.kdbx', '.zip', + '.pdf', '.ndjson', '.1pif', '.xlsx', '.xls', '.kdb', '.gpg', + }) + _HOST_FS_MSG = ( + 'Local filesystem access is not permitted through Service Mode' + ) + @staticmethod - def validate_service_mode_restrictions(command_tokens): + def validate_service_mode_restrictions(command_tokens, request_temp_dir=None): """Run Service Mode bans on executor tokens (shlex); error string or None.""" if not command_tokens: return None @@ -23,14 +69,17 @@ def validate_service_mode_restrictions(command_tokens): Verifycommand.validate_service_mode_download_attachment_command, Verifycommand.validate_service_mode_upload_attachment_command, Verifycommand.validate_service_mode_record_file_attachment_command, + Verifycommand.validate_service_mode_host_filesystem_command, + Verifycommand.validate_service_mode_host_path_args, + Verifycommand.validate_service_mode_file_input_command, ): - error = validator(command_tokens) + error = validator(command_tokens, request_temp_dir) if error: return error return None @staticmethod - def validate_service_mode_pam_tunnel_command(command_tokens): + def validate_service_mode_pam_tunnel_command(command_tokens, request_temp_dir=None): """Allow only pam tunnel edit in Service Mode; error string or None.""" if not command_tokens or len(command_tokens) < 2: return None @@ -49,7 +98,7 @@ def validate_service_mode_pam_tunnel_command(command_tokens): ) @staticmethod - def validate_service_mode_download_attachment_command(command_tokens): + def validate_service_mode_download_attachment_command(command_tokens, request_temp_dir=None): """Block download-attachment in Service Mode; error string or None.""" if not command_tokens: return None @@ -61,7 +110,7 @@ def validate_service_mode_download_attachment_command(command_tokens): ) @staticmethod - def validate_service_mode_upload_attachment_command(command_tokens): + def validate_service_mode_upload_attachment_command(command_tokens, request_temp_dir=None): """Block upload-attachment in Service Mode; error string or None.""" if not command_tokens: return None @@ -73,7 +122,7 @@ def validate_service_mode_upload_attachment_command(command_tokens): ) @staticmethod - def validate_service_mode_record_file_attachment_command(command_tokens): + def validate_service_mode_record_file_attachment_command(command_tokens, request_temp_dir=None): """Block record-add/update (and ra/ru) file fields in Service Mode; error or None.""" if not command_tokens: return None @@ -97,6 +146,236 @@ def _is_record_file_attachment_arg(token): name = name[2:] return name.split('.', 1)[0] == 'file' + @staticmethod + def validate_service_mode_host_filesystem_command(command_tokens, request_temp_dir=None): + """Block commands that always touch the host filesystem; error or None.""" + if not command_tokens: + return None + if command_tokens[0].lower() not in Verifycommand._HOST_FS_COMMANDS: + return None + return Verifycommand._HOST_FS_MSG + + @staticmethod + def validate_service_mode_host_path_args(command_tokens, request_temp_dir=None): + """Block host-path flags (--output, --filename, …); error or None.""" + if not command_tokens: + return None + + tokens = command_tokens + cmd0 = tokens[0].lower() + + # PDF always requires an on-disk output file. Routed through + # _option_values so abbreviations (--form, --fmt=pdf, …) are caught too. + for value in Verifycommand._option_values(tokens, '--format'): + if value.lower() == 'pdf': + return Verifycommand._HOST_FS_MSG + + for flag in ('--out-dir', '--output-dir', '--from-file', '--file-cache', + '--keepass-key-file', '-v3f'): + if Verifycommand._has_option(tokens, flag): + return Verifycommand._HOST_FS_MSG + + # credential-provision --config is a host YAML path; --config-base64 is OK. + # Do not ban --config globally — PAM uses it for configuration UIDs. + if cmd0 in ('credential-provision', 'cp') and Verifycommand._has_option(tokens, '--config'): + return Verifycommand._HOST_FS_MSG + + for value in Verifycommand._option_values(tokens, '--filename'): + if not Verifycommand._is_service_temp_path(value, request_temp_dir): + return Verifycommand._HOST_FS_MSG + + # pam project import/extend use -f as --filename (not --force). + if Verifycommand._is_pam_project_filename_cmd(tokens): + for value in Verifycommand._option_values(tokens, '-f'): + if not Verifycommand._is_service_temp_path(value, request_temp_dir): + return Verifycommand._HOST_FS_MSG + + for value in Verifycommand._option_values(tokens, '--file'): + if Verifycommand._looks_like_local_path(value): + if not Verifycommand._is_service_temp_path(value, request_temp_dir): + return Verifycommand._HOST_FS_MSG + + for value in Verifycommand._option_values(tokens, '--output'): + if value.lower() not in Verifycommand._NON_PATH_OUTPUT_VALUES: + return Verifycommand._HOST_FS_MSG + + # generate / pam project export use -o as a file path (not mode). + if cmd0 in Verifycommand._GENERATE_COMMAND_NAMES or Verifycommand._is_pam_project_export(tokens): + for value in Verifycommand._option_values(tokens, '-o'): + if value.lower() not in Verifycommand._NON_PATH_OUTPUT_VALUES: + return Verifycommand._HOST_FS_MSG + + # transfer-user (tu) @filename mapping files - scoped to that command + # only, since '@value' is a legitimate argument shape elsewhere. + if cmd0 in ('transfer-user', 'tu'): + for tok in tokens[1:]: + if tok.startswith('@') and Verifycommand._looks_like_local_path(tok[1:]): + return Verifycommand._HOST_FS_MSG + + return None + + @staticmethod + def validate_service_mode_file_input_command(command_tokens, request_temp_dir=None): + """Allow import/enterprise-push only with FILEDATA temp paths; error or None.""" + if not command_tokens: + return None + cmd0 = command_tokens[0].lower() + if cmd0 not in Verifycommand._FILE_INPUT_COMMANDS: + return None + + if cmd0 == 'import': + from ...importer.commands import import_parser + ns = Verifycommand._safe_parse(import_parser, command_tokens[1:]) + if ns is None: + # Malformed for the real parser too; it will reject this itself. + return None + fmt = (ns.format or '').lower() + if fmt in Verifycommand._IMPORT_FORMATS_WITHOUT_FILE: + return None + if ns.name and not Verifycommand._is_service_temp_path(ns.name, request_temp_dir): + return Verifycommand._HOST_FS_MSG + return None + + if cmd0 == 'enterprise-push': + from ...commands.enterprise_push import enterprise_push_parser + ns = Verifycommand._safe_parse(enterprise_push_parser, command_tokens[1:]) + if ns is None: + return None + if ns.file and not Verifycommand._is_service_temp_path(ns.file, request_temp_dir): + return Verifycommand._HOST_FS_MSG + return None + + return None + + @staticmethod + def _safe_parse(parser, tokens): + """Resolve tokens via the command's real argparse parser; None if it can't be resolved. + + Used instead of hand-scanning tokens so we get the parser's own + distinction between a positional value and a flag's value -- the + raw tokens alone don't tell you that. + """ + try: + with contextlib.redirect_stderr(io.StringIO()): + ns, _ = parser.parse_known_args(tokens) + return ns + except SystemExit: + return None + except Exception: + return None + + @staticmethod + def _pam_project_verb(command_tokens): + """Resolve 'pam ' (alias-aware) to the verb, or None.""" + if len(command_tokens) < 3: + return None + t = [x.lower() for x in command_tokens[:3]] + if t[0] != 'pam': + return None + project = Verifycommand._PAM_PROJECT_ALIASES.get(t[1], t[1]) + if project != 'project': + return None + return Verifycommand._PAM_PROJECT_SUBCOMMAND_ALIASES.get(t[2], t[2]) + + @staticmethod + def _is_pam_project_export(command_tokens): + return Verifycommand._pam_project_verb(command_tokens) == 'export' + + @staticmethod + def _is_pam_project_filename_cmd(command_tokens): + return Verifycommand._pam_project_verb(command_tokens) in ('import', 'extend') + + # Flags we separately check for. Some are literal prefixes of others + # (--output/--output-dir, --file/--filename, --file/--file-cache) - a + # token that's a *complete* match for one of these is a distinct flag in + # its own right, never an abbreviation attempt at a different one. + _KNOWN_DANGEROUS_FLAGS = frozenset({ + '--output', '--filename', '--file', '--format', + '--out-dir', '--output-dir', '--from-file', '--file-cache', + '--keepass-key-file', '--config', + }) + + @staticmethod + def _flag_matches(tok_name, flag): + """True if tok_name is exactly flag, or an unambiguous long-option + abbreviation of it (argparse's allow_abbrev default -- almost no + parser in this codebase opts out of it, so '--out' really does mean + '--output' to the command that will actually run). + + Short options ('-o', '-f') are never abbreviated by argparse, so + those only ever match exactly. + """ + if tok_name == flag: + return True + # Reject bare '--' (argparse's "end of options" marker), anything + # that isn't a '--long' option on both sides, and anything that's + # already a complete, distinct flag of its own. + if len(tok_name) <= 2 or not tok_name.startswith('--') or not flag.startswith('--'): + return False + if tok_name in Verifycommand._KNOWN_DANGEROUS_FLAGS: + return False + return flag.startswith(tok_name) + + @staticmethod + def _has_option(tokens, flag): + """True if flag, flag=value, or an abbreviation of flag appears in tokens.""" + flag_l = flag.lower() + for tok in tokens[1:]: + name = tok.lower().split('=', 1)[0] + if Verifycommand._flag_matches(name, flag_l): + return True + return False + + @staticmethod + def _option_values(tokens, flag): + """Yield values for --flag value / --flag=value (and short -o value), + matching flag itself or an unambiguous abbreviation of it.""" + flag_l = flag.lower() + i = 1 + while i < len(tokens): + tok = tokens[i] + lower = tok.lower() + name, sep, _ = lower.partition('=') + if sep and Verifycommand._flag_matches(name, flag_l): + yield tok.split('=', 1)[1] + i += 1 + continue + if not sep and Verifycommand._flag_matches(name, flag_l): + # Consume the next token as the value even if it starts with + # '-' (e.g. a path or FILEDATA placeholder) -- these flags are + # all required-argument (action='store'), so skipping a + # dash-leading value here let it slip past every check below. + if i + 1 < len(tokens): + yield tokens[i + 1] + i += 2 + continue + i += 1 + + @staticmethod + def _looks_like_local_path(value): + if not value: + return False + if value.startswith(('http://', 'https://')): + return False + if value.startswith('~') or '/' in value or '\\' in value: + return True + _, ext = os.path.splitext(value) + return ext.lower() in Verifycommand._LOCAL_FILE_EXTENSIONS + + @staticmethod + def _is_service_temp_path(path, request_temp_dir): + """True when path resolves under *this request's* own temp directory, not just anywhere under the + shared OS temp root -- other processes/users can also write there. + """ + if not path or not request_temp_dir: + return False + try: + resolved = os.path.realpath(os.path.expanduser(path)) + root = os.path.realpath(request_temp_dir) + return resolved == root or resolved.startswith(root + os.sep) + except (OSError, ValueError): + return False + @staticmethod def validate_append_command(command): """ diff --git a/unit-tests/service/test_api_routes.py b/unit-tests/service/test_api_routes.py index 758fcaec4..222d4d530 100644 --- a/unit-tests/service/test_api_routes.py +++ b/unit-tests/service/test_api_routes.py @@ -70,5 +70,5 @@ def test_v1_direct_route_keeps_legacy_execution_path(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.headers.get('X-API-Legacy'), 'true') self.assertEqual(response.get_json(), {"status": "success", "data": {"command": "ls"}}) - mock_execute.assert_called_once_with('ls') + mock_execute.assert_called_once_with('ls', temp_files=[]) mock_submit.assert_not_called() diff --git a/unit-tests/service/test_auth_security.py b/unit-tests/service/test_auth_security.py index 68913db28..9a8d3c567 100644 --- a/unit-tests/service/test_auth_security.py +++ b/unit-tests/service/test_auth_security.py @@ -169,3 +169,12 @@ def test_validate_service_mode_restrictions_attachments(self): ) self.assertIsNone(check(['record-add', '--title', 't', '-rt', 'login', 'login=user'])) self.assertIsNone(check(['record-add', '--title', 't', '-rt', 'login', 'my.file=x'])) + + def test_validate_service_mode_restrictions_host_filesystem(self): + check = Verifycommand.validate_service_mode_restrictions + ban = 'Local filesystem access' + self.assertIn(ban, check(['run-batch', '--dry-run', '/etc/passwd'])) + self.assertIn(ban, check(['export', '--format=json', '/tmp/out.json'])) + self.assertIn(ban, check(['audit-report', '--format=json', '--output=/tmp/r.json'])) + self.assertIsNone(check(['audit-report', '--format=json'])) + self.assertIsNone(check(['clipboard-copy', 'uid', '--output=stdout'])) diff --git a/unit-tests/service/test_queue_concurrency.py b/unit-tests/service/test_queue_concurrency.py index 5a2800e30..25cf04e60 100644 --- a/unit-tests/service/test_queue_concurrency.py +++ b/unit-tests/service/test_queue_concurrency.py @@ -52,7 +52,7 @@ def test_queue_manager_serializes_concurrent_submissions(self): inflight = {"count": 0, "max": 0} results = {} - def fake_execute(command): + def fake_execute(command, **kwargs): with state_lock: inflight["count"] += 1 inflight["max"] = max(inflight["max"], inflight["count"]) @@ -91,7 +91,7 @@ def test_v1_and_v2_share_single_queue_worker(self): outputs = {} start_barrier = threading.Barrier(3) - def fake_execute(command): + def fake_execute(command, **kwargs): with state_lock: inflight["count"] += 1 inflight["max"] = max(inflight["max"], inflight["count"]) @@ -148,7 +148,7 @@ def test_timed_out_v1_request_does_not_execute_after_expiration(self): executed_commands = [] executed_lock = threading.Lock() - def fake_execute(command): + def fake_execute(command, **kwargs): with executed_lock: executed_commands.append(command) @@ -189,7 +189,7 @@ def test_processing_v1_request_waits_past_queue_timeout(self): started_processing = threading.Event() - def fake_execute(command): + def fake_execute(command, **kwargs): started_processing.set() time.sleep(request_timeout + 0.15) return {"status": "success", "data": {"command": command}}, 200 diff --git a/unit-tests/service/test_service_mode_pam_tunnel.py b/unit-tests/service/test_service_mode_pam_tunnel.py index 257ca41dd..083280bff 100644 --- a/unit-tests/service/test_service_mode_pam_tunnel.py +++ b/unit-tests/service/test_service_mode_pam_tunnel.py @@ -1,5 +1,8 @@ from unittest import TestCase from html import unescape +import os +import shutil +import tempfile import shlex @@ -90,6 +93,203 @@ def test_attachment_commands_blocked_for_remote_api(self): ) self.assertIsNone(check(_tokens('record-add --title t -rt login login=user'))) + def test_host_filesystem_commands_blocked(self): + check = Verifycommand.validate_service_mode_restrictions + ban = 'Local filesystem access' + for cmd in ( + 'run-batch --dry-run /etc/passwd', + 'run --dry-run ~/.keeper/config.json', + 'export --format=json /tmp/out.json', + 'download-membership --source=keeper /tmp/m.json', + 'download-record-types --source=keeper /tmp/rt.json', + 'apply-membership /tmp/m.json', + 'load-record-types /tmp/rt.json', + ): + with self.subTest(cmd=cmd): + err = check(_tokens(cmd)) + self.assertIsNotNone(err) + self.assertIn(ban, err) + + def test_host_path_output_args_blocked(self): + check = Verifycommand.validate_service_mode_restrictions + ban = 'Local filesystem access' + for cmd in ( + 'audit-report --format=json --output=/tmp/report.json', + 'share-report --format=csv --output=out.csv', + 'generate --output /tmp/passwords.txt', + 'generate -o /tmp/passwords.txt', + 'pam project export --project-uid UID -o /tmp/proj.json', + 'ls --format=pdf --output=/tmp/x.pdf', + 'audit-report --format=pdf --output=report.pdf', + ): + with self.subTest(cmd=cmd): + err = check(_tokens(cmd)) + self.assertIsNotNone(err) + self.assertIn(ban, err) + + # Non-path --output modes remain allowed + self.assertIsNone(check(_tokens('clipboard-copy UID --output=stdout'))) + self.assertIsNone(check(_tokens('credential-provision --config-base64 dGVzdA== --output json'))) + self.assertIsNone(check(_tokens('audit-report --format=json'))) + + def test_filename_allows_temp_filedata_paths_only(self): + check = Verifycommand.validate_service_mode_restrictions + ban = 'Local filesystem access' + self.assertIsNotNone(check(_tokens('pam project import --filename=/etc/passwd'))) + self.assertIsNotNone(check(_tokens('pam project import -f /etc/passwd'))) + # Use a path outside any OS temp dir -- on Linux, tempfile.gettempdir() + # often *is* /tmp, so a literal /tmp path would be misclassified as safe. + self.assertIn(ban, check(_tokens('import --format=json /etc/vault.json'))) + + request_temp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, request_temp_dir, ignore_errors=True) + temp_path = os.path.join(request_temp_dir, 'service_filedata_test.json') + + self.assertIsNone( + check(_tokens(f'pam project import --filename={temp_path}'), request_temp_dir) + ) + self.assertIsNone(check(_tokens(f'pam project import -f {temp_path}'), request_temp_dir)) + self.assertIsNone(check(_tokens(f'import --format=json {temp_path}'), request_temp_dir)) + self.assertIsNone( + check(_tokens(f'enterprise-push {temp_path} --email user@example.com'), request_temp_dir) + ) + # PAM --config is a vault UID, not a host path + self.assertIsNone( + check(_tokens('pam project extend --config=SOME_UID -f ' + temp_path), request_temp_dir) + ) + + def test_command_aliases_do_not_bypass_host_path_checks(self): + check = Verifycommand.validate_service_mode_restrictions + ban = 'Local filesystem access' + for cmd in ( + 'gen -o /tmp/passwords.txt', + 'gen --output /tmp/passwords.txt', + 'pam p x --project-uid UID -o /tmp/proj.json', + 'pam p i -f /etc/passwd', + 'pam p i --filename=/etc/passwd', + 'pam p e -f /etc/passwd', + ): + with self.subTest(cmd=cmd): + err = check(_tokens(cmd)) + self.assertIsNotNone(err) + self.assertIn(ban, err) + + # Aliased forms of the safe cases (non-path --output, temp-path filename) stay allowed. + request_temp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, request_temp_dir, ignore_errors=True) + temp_path = os.path.join(request_temp_dir, 'service_filedata_alias_test.json') + self.assertIsNone(check(_tokens('gen --output stdout'))) + self.assertIsNone(check(_tokens(f'pam p i -f {temp_path}'), request_temp_dir)) + self.assertIsNone(check(_tokens(f'pam p e --filename={temp_path}'), request_temp_dir)) + + def test_non_request_temp_path_still_rejected(self): + """A path under a DIFFERENT request's temp dir is not automatically safe, + even though it's still somewhere under the shared OS temp root.""" + check = Verifycommand.validate_service_mode_restrictions + ban = 'Local filesystem access' + + request_temp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, request_temp_dir, ignore_errors=True) + other_request_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, other_request_dir, ignore_errors=True) + other_path = os.path.join(other_request_dir, 'someone_elses_file.json') + + self.assertIn( + ban, check(_tokens(f'pam project import -f {other_path}'), request_temp_dir) + ) + # No request_temp_dir at all (e.g. a request with no FILEDATA) trusts nothing. + self.assertIn( + ban, check(_tokens(f'pam project import -f {other_path}')) + ) + + def test_flag_abbreviations_do_not_bypass_host_path_checks(self): + """argparse's default allow_abbrev means '--out' really does mean + '--output' to the real command -- our checker has to agree.""" + check = Verifycommand.validate_service_mode_restrictions + ban = 'Local filesystem access' + for cmd in ( + 'generate --out /etc/evil', + 'audit-report --form pdf --out /etc/evil.pdf', + 'pam project import --filenam=/etc/passwd', + 'pam project import --fil /etc/passwd', + ): + with self.subTest(cmd=cmd): + err = check(_tokens(cmd)) + self.assertIsNotNone(err) + self.assertIn(ban, err) + + # A complete, distinct flag must not be misread as an abbreviation of + # a different one just because it's a literal prefix of it. + self.assertIsNone(check(_tokens('clipboard-copy UID --output=stdout'))) + + def test_import_bare_name_positional_requires_format_awareness(self): + """A plain filename with no slash/extension must still be checked -- + unless the format means `name` isn't a file at all (account/URL).""" + check = Verifycommand.validate_service_mode_restrictions + ban = 'Local filesystem access' + + # 'data' has no slash and no known extension, but json/csv always + # read it as a local file -- must not slip through on shape alone. + self.assertIn(ban, check(_tokens('import --format=json data'))) + self.assertIn(ban, check(_tokens('import --format=csv data'))) + + # lastpass/manageengine/thycotic/cyberark/cyberark_portal treat `name` + # as an account/URL, not a file -- must stay allowed even though it's + # a bare, non-path-looking value. + self.assertIsNone(check(_tokens('import --format=lastpass my-lastpass-account'))) + self.assertIsNone(check(_tokens('import --format=manageengine https://me.example.com'))) + self.assertIsNone(check(_tokens('import --format=thycotic https://thycotic.example.com'))) + self.assertIsNone(check(_tokens('import --format=cyberark pvwa.example.com'))) + self.assertIsNone(check(_tokens('import --format=cyberark_portal example-tenant'))) + + # A real per-request temp path still works normally for file-based formats. + request_temp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, request_temp_dir, ignore_errors=True) + temp_path = os.path.join(request_temp_dir, 'import_data.json') + self.assertIsNone(check(_tokens(f'import --format=json {temp_path}'), request_temp_dir)) + + def test_filedata_substitution_happens_before_validation(self): + """process_file_data must run before validate_service_mode_restrictions, + so --filename=FILEDATA resolves to a real per-request temp path by the + time the host-path checks run (see CommandExecutor.execute ordering).""" + from keepercommander.service.util.request_validation import RequestValidator + + request_data = {'filedata': {'some': 'data'}} + command = 'pam project import --filename=FILEDATA' + processed_command, temp_files = RequestValidator.process_file_data(request_data, command) + self.addCleanup(RequestValidator.cleanup_temp_files, temp_files) + + self.assertTrue(temp_files, 'expected a temp file to be created') + self.assertNotIn('FILEDATA', processed_command) + + request_temp_dir = os.path.dirname(temp_files[0]) + tokens = _tokens(processed_command) + self.assertIsNone( + Verifycommand.validate_service_mode_restrictions(tokens, request_temp_dir) + ) + + def test_option_values_yields_dash_leading_values(self): + is_file = Verifycommand._option_values + self.assertEqual(list(is_file(['generate', '--output', '-'], '--output')), ['-']) + self.assertEqual( + list(is_file(['pam', 'project', 'import', '-f', '-etc/passwd'], '-f')), + ['-etc/passwd'], + ) + + check = Verifycommand.validate_service_mode_restrictions + ban = 'Local filesystem access' + self.assertIn(ban, check(_tokens('pam project import -f -etc/passwd'))) + self.assertIn(ban, check(_tokens('pam project import --filename -etc/passwd'))) + + def test_config_file_blocked_config_base64_allowed(self): + check = Verifycommand.validate_service_mode_restrictions + self.assertIsNotNone( + check(_tokens('credential-provision --config=/tmp/cfg.yaml -c PAMUID')) + ) + self.assertIsNone( + check(_tokens('credential-provision --config-base64 dGVzdA== -c PAMUID')) + ) + def test_is_record_file_attachment_arg(self): is_file = Verifycommand._is_record_file_attachment_arg self.assertTrue(is_file('file=@/tmp/x')) From c3507917b7a4884bd8ef096d65ecfa680f5e80b8 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Tue, 1 Sep 2026 19:20:30 +0530 Subject: [PATCH 09/12] KC-1441: Restrict SailPoint integration to documented capabilities only (#2333) (#2338) * Restrict SailPoint integration to documented capabilities only * Add er to ban list for existing installs --- .../integrations/sailpoint/command_hook.py | 21 +-- .../integrations/sailpoint/command_policy.py | 82 ++++------- .../integrations/sailpoint/constants.py | 3 +- unit-tests/service/test_sailpoint_pending.py | 127 ++++++++++-------- 4 files changed, 106 insertions(+), 127 deletions(-) diff --git a/keepercommander/service/commands/integrations/sailpoint/command_hook.py b/keepercommander/service/commands/integrations/sailpoint/command_hook.py index b05b05766..30783a1ee 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_hook.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_hook.py @@ -21,12 +21,11 @@ from .command_parse import ParsedShare, SailPointCommandParser from .command_policy import SailPointCommandPolicy from .config_fields import SailPointCapabilities, read_capabilities +from .constants import SAILPOINT_BANNED_COMMANDS from .pending_store import SailPointPendingStore from .scim_guard import SailPointScimGuard from .share_targets import validate_share_targets -_ER_CMDS = frozenset({'enterprise-role', 'er'}) - class SailPointCommandHook: """Pre/post hooks around Service Mode command execution for SailPoint.""" @@ -42,6 +41,14 @@ def before_command(self, params: KeeperParams, command: str) -> Tuple[str, Optio set, do not execute the command. ``command_to_run`` may be rewritten (e.g. transfer-user target injection). """ + tokens = SailPointCommandParser.tokenize(command) + if tokens and tokens[0].lower() in SAILPOINT_BANNED_COMMANDS: + return self._reject( + command, + f'SailPoint does not allow command: {tokens[0]}', + 403 + ) + caps = read_capabilities(params, self.record_uid) scope_error = self._check_capability_gates(command, caps) @@ -49,8 +56,8 @@ def before_command(self, params: KeeperParams, command: str) -> Tuple[str, Optio return self._reject(command, scope_error, 403) policy_error = ( - SailPointCommandPolicy.validate_enterprise_role(command) - or SailPointCommandPolicy.validate_enterprise_user_delete(command) + SailPointCommandPolicy.validate_enterprise_user(command) + or SailPointCommandPolicy.validate_share_record(command) ) if policy_error: return self._reject(command, policy_error, 403) @@ -96,12 +103,6 @@ def _check_capability_gates(command: str, caps: SailPointCapabilities) -> Option tokens = SailPointCommandParser.tokenize(command) if not tokens: return None - name = tokens[0].lower() - - if name in _ER_CMDS and not caps.allow_roles: - return ( - 'SailPoint allow_roles is disabled; enterprise-role / er is not allowed.' - ) invite = SailPointCommandParser.parse_invite(command) if invite: diff --git a/keepercommander/service/commands/integrations/sailpoint/command_policy.py b/keepercommander/service/commands/integrations/sailpoint/command_policy.py index 78b95d0e4..88ec51451 100644 --- a/keepercommander/service/commands/integrations/sailpoint/command_policy.py +++ b/keepercommander/service/commands/integrations/sailpoint/command_policy.py @@ -20,30 +20,13 @@ from .command_parse import SailPointCommandParser from .constants import SAILPOINT_ALLOWED_COMMANDS, SAILPOINT_BANNED_COMMANDS -_ENTERPRISE_ROLE_CMDS = frozenset({'enterprise-role', 'er'}) _ENTERPRISE_USER_CMDS = frozenset({'enterprise-user', 'eu'}) -# Destinations SailPoint may set on enterprise-role (argparse dest names). -# Anything else that resolves on the real parser is refused — spelling-independent. -_ER_ALLOWED_DESTS = frozenset({ - 'role', - 'force', - 'verbose', - 'format', - 'output', - 'node', - 'cascade', - 'add_admin', - 'remove_admin', - 'add_privilege', - 'remove_privilege', +_EU_BANNED_DESTS = frozenset({ + 'disable_2fa', + 'expire', }) -_ER_ALLOWED_HINT = ( - '--add-admin, --remove-admin, --add-privilege, --remove-privilege ' - '(plus --node, --cascade, -f)' -) - def _arg_is_set(value: Any) -> bool: if value is None or value is False: @@ -88,45 +71,8 @@ def default_allowlist(cls) -> str: return cls.sanitize(','.join(SAILPOINT_ALLOWED_COMMANDS)) @classmethod - def validate_enterprise_role(cls, command: str) -> Optional[str]: - """ - Restrict enterprise-role to admin/privilege ops only. - - Uses Commander's ``enterprise_role_parser`` so ``--add-user``, - ``--add-user=``, ``--add-us``, and ``-au=`` are treated identically. - """ - tokens = SailPointCommandParser.tokenize(command) - if not tokens or tokens[0].lower() not in _ENTERPRISE_ROLE_CMDS: - return None - - from .....commands.enterprise import enterprise_role_parser - - parsed = SailPointCommandParser.parse_known(enterprise_role_parser, tokens[1:]) - if not parsed: - return None - ns, unknown = parsed - - for token in unknown: - if token.startswith('-'): - flag = token.split('=', 1)[0] - return ( - f'SailPoint mode does not allow enterprise-role {flag}. ' - f'Allowed: {_ER_ALLOWED_HINT}.' - ) - - for dest, value in vars(ns).items(): - if dest in _ER_ALLOWED_DESTS or not _arg_is_set(value): - continue - flag = f'--{dest.replace("_", "-")}' - return ( - f'SailPoint mode does not allow enterprise-role {flag}. ' - f'Allowed: {_ER_ALLOWED_HINT}.' - ) - return None - - @classmethod - def validate_enterprise_user_delete(cls, command: str) -> Optional[str]: - """Ban enterprise-user --delete; offboard must use transfer-user.""" + def validate_enterprise_user(cls, command: str) -> Optional[str]: + """Restrict enterprise-user from dangerous operations like --disable-2fa and --expire.""" tokens = SailPointCommandParser.tokenize(command) if not tokens or tokens[0].lower() not in _ENTERPRISE_USER_CMDS: return None @@ -137,13 +83,31 @@ def validate_enterprise_user_delete(cls, command: str) -> Optional[str]: if not parsed: return None ns, _unknown = parsed + if ns.delete: return ( 'SailPoint mode does not allow enterprise-user --delete. ' 'Use transfer-user with the configured vault transfer target instead.' ) + + for dest in _EU_BANNED_DESTS: + if _arg_is_set(getattr(ns, dest, None)): + flag = f'--{dest.replace("_", "-")}' + return f'SailPoint mode does not allow enterprise-user {flag}.' + return None + @classmethod + def validate_share_record(cls, command: str) -> Optional[str]: + """Prevent ownership transfer in share-record and nsf-share-record.""" + share = SailPointCommandParser.parse_share(command) + if share is None or share.action != 'owner': + return None + return ( + 'SailPoint mode does not allow share-record --action owner. ' + 'Record ownership transfer is not permitted.' + ) + @classmethod def prepare_transfer(cls, command: str, target_email: str) -> Tuple[str, Optional[str]]: """ diff --git a/keepercommander/service/commands/integrations/sailpoint/constants.py b/keepercommander/service/commands/integrations/sailpoint/constants.py index bff5e259e..2cdd5de38 100644 --- a/keepercommander/service/commands/integrations/sailpoint/constants.py +++ b/keepercommander/service/commands/integrations/sailpoint/constants.py @@ -37,7 +37,6 @@ 'sync-down', 'enterprise-info', 'enterprise-user', - 'enterprise-role', 'enterprise-down', 'transfer-user', 'share-folder', @@ -62,4 +61,6 @@ 'one-time-share', 'connect', 'ssh', + 'enterprise-role', + 'er', }) diff --git a/unit-tests/service/test_sailpoint_pending.py b/unit-tests/service/test_sailpoint_pending.py index f68b0c763..a1ad7fed0 100644 --- a/unit-tests/service/test_sailpoint_pending.py +++ b/unit-tests/service/test_sailpoint_pending.py @@ -224,19 +224,9 @@ def test_sanitize_strips_get(self): self.assertIn('enterprise-user', cleaned.split(',')) self.assertIn('share-record', cleaned.split(',')) - def test_sanitize_adds_enterprise_role_when_missing(self): - cleaned = SailPointCommandPolicy.sanitize( - 'whoami,sync-down,get,enterprise-info,enterprise-user,enterprise-down,' - 'share-folder,share-record,nsf-share-folder,nsf-share-record,tree' - ) - parts = cleaned.split(',') - self.assertNotIn('get', parts) - self.assertIn('enterprise-role', parts) - self.assertNotIn('er', parts) - def test_default_allowlist_matches_integration_list(self): expected = [ - 'whoami', 'sync-down', 'enterprise-info', 'enterprise-user', 'enterprise-role', + 'whoami', 'sync-down', 'enterprise-info', 'enterprise-user', 'enterprise-down', 'transfer-user', 'share-folder', 'share-record', 'nsf-share-folder', 'nsf-share-record', 'tree', @@ -252,64 +242,50 @@ def test_sanitize_adds_transfer_user_when_missing(self): self.assertIn('transfer-user', parts) self.assertNotIn('tu', parts) - def test_enterprise_role_blocks_add_delete_add_user(self): + def test_enterprise_user_delete_blocked(self): for cmd in ( - "enterprise-role --add 'New Role'", - "er 'QA Role' --delete", - "er 'QA Role' --add-user user@co.com", - "enterprise-role 'QA Role' --copy", - "er 'QA Role' --name 'Renamed'", - "er 'QA Role' --enforcement restrict_sharing:true", - # Equals / abbrev / short= forms must resolve the same as blocked long flags. - "enterprise-role 'QA Role' --add-user=user@co.com", - "enterprise-role 'QA Role' --add-us user@co.com", - "enterprise-role 'QA Role' -au=user@co.com", - "enterprise-role 'QA Role' --dele -f", - "enterprise-role 'QA Role' --nam=Pwned", - "enterprise-role 'QA Role' --enforce=restrict_sharing_all:true", + 'enterprise-user leaving@co.com --delete', + 'eu leaving@co.com --delete', ): - err = SailPointCommandPolicy.validate_enterprise_role(cmd) + err = SailPointCommandPolicy.validate_enterprise_user(cmd) self.assertIsNotNone(err, cmd) - self.assertIn('does not allow enterprise-role', err) + self.assertIn('--delete', err) + self.assertIn('transfer-user', err) - def test_enterprise_role_allows_admin_and_privilege(self): + def test_enterprise_user_disable_2fa_blocked(self): for cmd in ( - "er 'QA Role'", - "enterprise-role 'QA Role' -aa 'Metron Security' --cascade on", - "er 'QA Role' --node 'Metron Security' -ap MANAGE_USER -ap MANAGE_NODES", - "er -f 'QA Role' -ra 'Metron Security'", - "er 'QA Role' --node 'Metron Security' -rp MANAGE_TEAMS", + 'enterprise-user user@co.com --disable-2fa', + 'eu user@co.com --disable-2fa', ): - self.assertIsNone(SailPointCommandPolicy.validate_enterprise_role(cmd), cmd) - - def test_enterprise_role_gate_ignores_other_commands(self): - self.assertIsNone( - SailPointCommandPolicy.validate_enterprise_role( - 'enterprise-user user@co.com --add-role Admin' - ) - ) + err = SailPointCommandPolicy.validate_enterprise_user(cmd) + self.assertIsNotNone(err, cmd) + self.assertIn('--disable-2fa', err) - def test_enterprise_user_delete_blocked(self): + def test_enterprise_user_expire_blocked(self): for cmd in ( - 'enterprise-user leaving@co.com --delete', - 'eu leaving@co.com --delete', + 'enterprise-user user@co.com --expire', + 'eu user@co.com --expire', ): - err = SailPointCommandPolicy.validate_enterprise_user_delete(cmd) + err = SailPointCommandPolicy.validate_enterprise_user(cmd) self.assertIsNotNone(err, cmd) - self.assertIn('--delete', err) - self.assertIn('transfer-user', err) + self.assertIn('--expire', err) - def test_enterprise_user_delete_allows_other_ops(self): + def test_enterprise_user_allows_safe_ops(self): self.assertIsNone( - SailPointCommandPolicy.validate_enterprise_user_delete( + SailPointCommandPolicy.validate_enterprise_user( 'eu user@co.com --add-role Admin' ) ) self.assertIsNone( - SailPointCommandPolicy.validate_enterprise_user_delete( + SailPointCommandPolicy.validate_enterprise_user( 'enterprise-user user@co.com --delete-alias old@co.com' ) ) + self.assertIsNone( + SailPointCommandPolicy.validate_enterprise_user( + 'enterprise-user user@co.com --add-team AWS' + ) + ) def test_prepare_transfer_appends_config_email(self): cmd = "transfer-user 'leaving@co.com' -f" @@ -355,6 +331,24 @@ def test_prepare_transfer_ignores_other_commands(self): self.assertEqual(rewritten, cmd) self.assertIsNone(err) + def test_share_record_ownership_transfer_blocked(self): + for cmd in ( + 'share-record record@uid --action owner -e user@co.com', + 'share-record record@uid -a owner -e user@co.com', + ): + err = SailPointCommandPolicy.validate_share_record(cmd) + self.assertIsNotNone(err, cmd) + self.assertIn('owner', err) + + def test_share_record_grant_allowed(self): + for cmd in ( + 'share-record record@uid -e user@co.com', + 'share-record record@uid --action grant -e user@co.com', + 'nsf-share-record record@uid -e user@co.com -r viewer', + ): + err = SailPointCommandPolicy.validate_share_record(cmd) + self.assertIsNone(err, cmd) + class SailPointPendingMergeTest(unittest.TestCase): def test_merge_by_email(self): @@ -639,7 +633,7 @@ def test_apply_role_uses_force_flag(self): class SailPointCapabilityGateTest(unittest.TestCase): - def test_roles_off_blocks_invite_add_role_and_er(self): + def test_roles_off_blocks_invite_add_role(self): from keepercommander.service.commands.integrations.sailpoint.command_hook import ( SailPointCommandHook, ) @@ -654,12 +648,6 @@ def test_roles_off_blocks_invite_add_role_and_er(self): self.assertIsNotNone(err) self.assertIn('allow_roles', err) - err = SailPointCommandHook._check_capability_gates( - "er 'Demo Role' -aa 'Node'", caps - ) - self.assertIsNotNone(err) - self.assertIn('enterprise-role', err) - def test_teams_off_blocks_add_team_allows_node(self): from keepercommander.service.commands.integrations.sailpoint.command_hook import ( SailPointCommandHook, @@ -880,6 +868,31 @@ def test_before_command_injects_transfer_target(self): self.assertEqual(short[1], 403) self.assertIn('--delete', short[0]['error']) + def test_before_command_rejects_banned_commands_at_runtime(self): + """Banned commands rejected even if present in stored config (in-place upgrade scenario).""" + from keepercommander.service.commands.integrations.sailpoint.command_hook import ( + SailPointCommandHook, + ) + from keepercommander.service.commands.integrations.sailpoint.config_fields import ( + SailPointCapabilities, + ) + + hook = SailPointCommandHook('config_uid') + caps = SailPointCapabilities(allow_roles=True, allow_teams=True) + + for cmd in ( + "enterprise-role 'Role' -aa 'Node'", + "er 'Role' --add-privilege MANAGE_USERS", + ): + with mock.patch( + 'keepercommander.service.commands.integrations.sailpoint.command_hook.read_capabilities', + return_value=caps, + ): + command, short = hook.before_command(mock.Mock(), cmd) + self.assertIsNotNone(short, cmd) + self.assertEqual(short[1], 403, cmd) + self.assertIn('does not allow', short[0]['error'], cmd) + def test_after_command_skips_missing_user(self): from keepercommander.service.commands.integrations.sailpoint.command_hook import ( SailPointCommandHook, From fb21817739aed945b5bd5f86db4a120f5f9d28de Mon Sep 17 00:00:00 2001 From: John Walstra Date: Tue, 1 Sep 2026 12:18:44 -0500 Subject: [PATCH 10/12] Fixes the format of the PAM service list text output. --- keepercommander/commands/pam_service/list.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/keepercommander/commands/pam_service/list.py b/keepercommander/commands/pam_service/list.py index 7b61459a6..b7c9cc23e 100644 --- a/keepercommander/commands/pam_service/list.py +++ b/keepercommander/commands/pam_service/list.py @@ -86,14 +86,14 @@ def _by_user(self, params: KeeperParams, record_link: RecordLink, user_service: items = [] if acl.service_names is not None or acl.service_names != "": for service_name in acl.get_service_names(user_record.record_key): - text = PAMActionServiceListCommand.TITLES.get(service_name.type) for item in service_name.items: + text = PAMActionServiceListCommand.TITLES.get(service_name.type) text += f": {item.name}" if "Unknown" in item.name: text += " (from migration)" elif not item.via_discovery: text += " (manually set)" - items.append(text) + items.append(text) service_map[user_record.record_uid]["machines"].append({ "name": machine_name, @@ -163,14 +163,14 @@ def _by_machine(self, params: KeeperParams, record_link: RecordLink, user_servic items = [] if acl.service_names is not None or acl.service_names != "": for service_name in acl.get_service_names(user_record.record_key): - text = PAMActionServiceListCommand.TITLES.get(service_name.type) for item in service_name.items: + text = PAMActionServiceListCommand.TITLES.get(service_name.type) text += f": {item.name}" if "Unknown" in item.name: text += " (from migration)" elif not item.via_discovery: text += " (manually set)" - items.append(text) + items.append(text) service_map[resource_record.record_uid]["users"].append({ "name": user_name, From 221f58998cd90577d01f347ef570828131e7e10d Mon Sep 17 00:00:00 2001 From: pvagare-ks Date: Wed, 2 Sep 2026 16:56:54 +0530 Subject: [PATCH 11/12] Improve CyberArk classic import metadata and skip handling (#2342) --- .../commands/pam_import/cyberark_import.py | 42 +- keepercommander/importer/commands.py | 24 + keepercommander/importer/cyberark/cyberark.py | 331 +++++++++-- .../importer/cyberark/pam/client.py | 425 ++++++++++++-- keepercommander/importer/imp_exp.py | 60 +- tests/test_cyberark_pam_import.py | 526 +++++++++++++++++- 6 files changed, 1277 insertions(+), 131 deletions(-) diff --git a/keepercommander/commands/pam_import/cyberark_import.py b/keepercommander/commands/pam_import/cyberark_import.py index 7dbe0728e..163b4c691 100644 --- a/keepercommander/commands/pam_import/cyberark_import.py +++ b/keepercommander/commands/pam_import/cyberark_import.py @@ -562,6 +562,7 @@ def _map_single_account( # Clear it immediately after map_account copies it into the record. token = None record = None + password_failed = False try: if not opts.dry_run or opts.include_creds: token = self.client.retrieve_password( @@ -571,12 +572,15 @@ def _map_single_account( skip_all=skip_all_passwords, ) if token is None: + password_failed = True skipped.append({ "account": account.get("name", ""), "safe": safe_name, "reason": "password retrieval failed", }) - return + # Still map host/username so the account is not dropped from + # the PAM project (legacy import also keeps going after Skip). + token = "" try: record = mapper.map_account(account, token, safe_name) except StrictPolicyError as e: @@ -611,8 +615,13 @@ def _map_single_account( # can update the pamUser independently from its parent. annotate_record_with_marker(nested, account_id, safe_name) self._apply_folder_paths(record, safe_name, folder_mapper) + if password_failed: + reason = "password retrieval failed" + is_incomplete = True if is_incomplete: - record["notes"] = f"INCOMPLETE: {reason}" + note = f"INCOMPLETE: {reason}" + existing = (record.get("notes") or "").strip() + record["notes"] = f"{existing}\n{note}".strip() if existing else note incomplete.append(record) dual_fields = detect_dual_account(account) if dual_fields: @@ -1910,6 +1919,9 @@ class CyberArkPAMImportCommand(Command): # Self-hosted with SSL verification disabled pam project cyberark-import pvwa.internal.com --no-verify-ssl --name "Internal" + + # Self-hosted with mutual TLS client cert (P12) + pam project cyberark-import pvwa.internal.com --client-cert-p12 ./client.p12 --name "Internal" ''') parser.add_argument("server", action="store", help="CyberArk PVWA host (e.g. mycompany.cyberark.cloud or pvwa.example.com)") parser.add_argument("--name", "-n", required=False, dest="project_name", action="store", @@ -1956,6 +1968,10 @@ class CyberArkPAMImportCommand(Command): default="", help="Comma-separated CPM states to include (default: all)") parser.add_argument("--no-verify-ssl", required=False, dest="no_verify_ssl", action="store_true", default=False, help="Disable SSL certificate verification for self-hosted PVWA (insecure)") + parser.add_argument("--client-cert-p12", required=False, dest="client_cert_p12", action="store", + default="", help="Path to client certificate P12/PFX for self-hosted PVWA mutual TLS") + parser.add_argument("--client-cert-password", required=False, dest="client_cert_password", action="store", + default="", help="Passphrase for --client-cert-p12 (prefer env var in automation)") parser.add_argument("--include-system-safes", required=False, dest="include_system_safes", action="store_true", default=False, help="Include CyberArk system safes (VaultInternal, PVWAConfig, etc.)") @@ -2037,8 +2053,28 @@ def execute(self, params, **kwargs): f"Platform map entry '{key}' must be an object with a 'record_type' field") no_verify_ssl = kwargs.get("no_verify_ssl", False) + client_cert_p12 = kwargs.get("client_cert_p12", "") + client_cert_password = kwargs.get("client_cert_password", "") state_filter = [s.strip() for s in state_filter_str.split(",") if s.strip()] if state_filter_str else None + if client_cert_p12: + os.environ["KEEPER_CYBERARK_CLIENT_CERT_P12"] = client_cert_p12 + if client_cert_password: + os.environ["KEEPER_CYBERARK_CLIENT_CERT_PASSWORD"] = client_cert_password + + # Match legacy `import --format=cyberark` behavior for self-hosted PVWA: + # default to verify=False unless a CA bundle is explicitly provided. + verify_ssl = not no_verify_ssl + if not server.endswith(".cyberark.cloud"): + ca_bundle = ( + os.environ.get("KEEPER_CYBERARK_CA_BUNDLE") + or os.environ.get("_CYBERARK_CA_BUNDLE") + ) + if ca_bundle: + verify_ssl = ca_bundle + elif not no_verify_ssl: + verify_ssl = False + # ── Resolve gateway UID → name if needed ───────────── if gateway_name and not config_uid: try: @@ -2056,7 +2092,7 @@ def execute(self, params, **kwargs): # ── Phase 0: Authenticate ──────────────────────────── try: - client = CyberArkPVWAClient(server, verify_ssl=not no_verify_ssl) + client = CyberArkPVWAClient(server, verify_ssl=verify_ssl) except ValueError as e: raise CommandError("pam project cyberark-import", str(e)) if not client.authenticate(): diff --git a/keepercommander/importer/commands.py b/keepercommander/importer/commands.py index 32a54f7f5..223f5525b 100644 --- a/keepercommander/importer/commands.py +++ b/keepercommander/importer/commands.py @@ -44,6 +44,24 @@ def register_command_info(aliases, command_info): command_info[p.prog] = p.description +def _cyberark_skip_arg(value): + skip_targets = [] + valid_targets = {"team", "role", "user"} + aliases = {"teams": "team", "roles": "role", "users": "user"} + for target in str(value or "").split(","): + target = target.strip().lower() + if not target: + continue + target = aliases.get(target, target) + if target not in valid_targets: + raise argparse.ArgumentTypeError( + f"unsupported CyberArk skip target '{target}'. Use team, role, user" + ) + if target not in skip_targets: + skip_targets.append(target) + return ",".join(skip_targets) + + import_parser = argparse.ArgumentParser(prog='import', description='Import vault data from a local file into Keeper') import_parser.add_argument('--display-csv', '-dc', dest='display_csv', action='store_true', help='display Keeper CSV import instructions') @@ -88,6 +106,8 @@ def register_command_info(aliases, command_info): help='Comma separated list of secret IDs to fetch (Thycotic)') import_parser.add_argument('--target-node', '--node', dest='target_node', action='store', help='node name or ID for CyberArk-provisioned users, teams, and roles (default: root node)') +import_parser.add_argument('--skip', dest='skip', action='store', type=_cyberark_skip_arg, + help='CyberArk only: comma-separated targets to skip: team, role, user') import_parser.add_argument( 'name', type=str, help='file name (json, csv , keepass, 1password), account name (lastpass), or URL (ManageEngine, Thycotic). ' @@ -299,6 +319,10 @@ def execute(self, params, **kwargs): logging.warning('--target-node/--node is only used with --format=cyberark; ignoring') kwargs['target_node'] = None + if kwargs.get('skip') and import_format != 'cyberark': + logging.warning('--skip is only used with --format=cyberark; ignoring') + kwargs['skip'] = '' + logging.info('Processing... please wait.') imp_exp._import(params, import_format, import_name, manage_users=manage_users, manage_records=manage_records, can_edit=can_edit, can_share=can_share, **kwargs) diff --git a/keepercommander/importer/cyberark/cyberark.py b/keepercommander/importer/cyberark/cyberark.py index 2873bf209..83aa9815b 100644 --- a/keepercommander/importer/cyberark/cyberark.py +++ b/keepercommander/importer/cyberark/cyberark.py @@ -266,6 +266,7 @@ class CyberArkImporter(BaseImporter): # Delay between requests to avoid hitting the API rate limits DELAY = 0.025 + _PLATFORM_NAME_KEYS = ("platformName", "platformId") # CyberArk REST API endpoints (relative to the base URL) ENDPOINTS = { "accounts": "Accounts", @@ -287,6 +288,155 @@ def __init__(self, *args, **kwargs): self._tmp_cert_files = [] # ``verify`` value used for every PVWA request. Defaults to False (self-hosted PVWAs typically use a private CA), but can be overridden by the ``_CYBERARK_CA_BUNDLE`` env var to point at a CA file/dir. self._verify_tls = False + timeout_env = environ.get("_CYBERARK_TIMEOUT") + if timeout_env: + try: + self.TIMEOUT = int(timeout_env) + except ValueError: + pass + + @classmethod + def _normalize_property_key(cls, key): + return re.sub(r"[^a-z0-9]", "", str(key).casefold()) + + @classmethod + def _platform_properties(cls, account): + if not isinstance(account, dict): + return {} + properties = account.get("platformAccountProperties") + return properties if isinstance(properties, dict) else {} + + @classmethod + def _get_platform_property(cls, account, *names): + properties = cls._platform_properties(account) + if not properties: + return None + normalized_names = {cls._normalize_property_key(name) for name in names} + for key, value in properties.items(): + if cls._normalize_property_key(key) in normalized_names: + return value + return None + + @classmethod + def _add_account_metadata(cls, record, account): + """Add CyberArk platform account properties as Keeper custom fields.""" + if not isinstance(account, dict): + return + + platform_properties = cls._platform_properties(account) + properties = dict(platform_properties) if isinstance(platform_properties, dict) else {} + for key in cls._PLATFORM_NAME_KEYS: + value = account.get(key) + if value not in (None, ""): + properties.setdefault("Platform Name", value) + break + for key in ("deviceType", "device type"): + value = account.get(key) + if value not in (None, ""): + properties.setdefault("Device Type", value) + break + if not properties: + return + + existing_labels = { + str(field.label).casefold() + for field in getattr(record, "fields", []) + if getattr(field, "label", None) + } + host_values = set() + port_values = set() + for field in getattr(record, "fields", []): + if getattr(field, "type", None) != "host": + continue + host_value = getattr(field, "value", None) + if isinstance(host_value, dict): + for host_key in ("hostName", "host"): + if host_value.get(host_key): + host_values.add(str(host_value[host_key])) + if host_value.get("port"): + port_values.add(str(host_value["port"])) + elif host_value: + host_values.add(str(host_value)) + + def field_value_to_text(value): + if isinstance(value, str): + return value + return json.dumps( + value, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), + ) + + def is_standard_mapped_value(label, field_value): + label_key = label.casefold() + normalized_label_key = cls._normalize_property_key(label) + if normalized_label_key in {"name", "itemname"}: + return bool(record.title) and field_value == record.title + if normalized_label_key == "url": + return bool(record.login_url) and field_value == record.login_url + if normalized_label_key == "logondomain": + return bool(record.login) and record.login.casefold().startswith( + f"{field_value}\\".casefold() + ) + if normalized_label_key in {"address", "host", "hostname"}: + return field_value in host_values + if normalized_label_key in {"port", "portnumber"}: + return field_value in port_values or any( + getattr(field, "type", None) == "port" and str(getattr(field, "value", "")) == field_value + for field in getattr(record, "fields", []) + ) + if normalized_label_key in {"username", "accountname"}: + return bool(record.login) and ( + field_value == record.login or record.login.endswith(f"\\{field_value}") + ) + return False + + def field_label_to_text(label): + parts = [] + for part in str(label).replace("_", " ").split("."): + part = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", " ", part) + part = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", part) + parts.append(" ".join(part.split())) + return ".".join(parts) + + def add_value(label, value): + raw_label = str(label) + label = field_label_to_text(raw_label) + label_key = label.casefold() + if not label or label_key in existing_labels: + return + field_value = field_value_to_text(value) + if (is_standard_mapped_value(raw_label, field_value) + or is_standard_mapped_value(label, field_value)): + return + record.fields.append(RecordField( + "text", label=label, value=field_value, + )) + existing_labels.add(label_key) + + def flatten(value, label): + if isinstance(value, dict) and value: + for child_key, child_value in value.items(): + child_label = f"{label}.{child_key}" if label else str(child_key) + flatten(child_value, child_label) + else: + add_value(label, value) + + for property_key, property_value in properties.items(): + property_label = str(property_key) + flatten(property_value, property_label) + + @classmethod + def _account_title(cls, account): + """Return the Keeper title for a CyberArk account.""" + if not isinstance(account, dict): + return "CyberArk Account" + properties = account.get("platformAccountProperties") + if not isinstance(properties, dict): + properties = {} + for value in (account.get("name"), properties.get("ItemName"), account.get("id")): + if value: + return str(value) + return "CyberArk Account" @classmethod def get_url(cls, pvwa_host, endpoint): @@ -302,6 +452,33 @@ def _request(method, url, **kwargs): return requests.request(method, url, **kwargs) return requests.request(method, url, **kwargs) + @staticmethod + def _connection_error_hint(pvwa_host, error): + """Return user-facing guidance for a PVWA connection failure.""" + err = str(error).lower() + host = _esc(pvwa_host) + if "getaddrinfo failed" in err or "failed to resolve" in err or "name resolution" in err: + return ( + f"Could not resolve hostname {host}.\n" + "For self-hosted PVWA, use the exact hostname or IP from your CyberArk admin, " + "connect to VPN, or add an entry to your hosts file." + ) + if "timed out" in err or "timeout" in err: + return ( + f"Connection to {host} timed out.\n" + "Ensure VPN is connected, the PVWA is running, and the port is reachable. " + "If PVWA uses a non-standard port, specify it as hostname:port." + ) + if "connection refused" in err: + return ( + f"Connection to {host} was refused.\n" + "Verify the PVWA service is running and the port is correct." + ) + return ( + f"Could not connect to {host}.\n" + "Verify the PVWA hostname is correct and reachable from this machine." + ) + def _load_p12_client_cert(self, p12_path, p12_password): """Convert a PKCS#12 bundle to a temporary PEM cert+key pair for ``requests``. @@ -471,7 +648,7 @@ def get_response(self, url, authorization_token, query_params): ) except requests.exceptions.RequestException as e: print_formatted_text( - HTML(f"Request to {url} failed: {e}") + HTML(f"Request to {_esc(url)} failed: {_esc(e)}") ) return None @@ -771,14 +948,16 @@ def _authenticate_pvwa(self, filename): except requests.exceptions.ConnectionError as e: print_formatted_text( HTML( - f"CyberArk Log on failed: could not connect to {pvwa_host}.\n" - "Verify the PVWA hostname is correct and reachable from this machine." + f"CyberArk Log on failed: " + f"{self._connection_error_hint(pvwa_host, e)}" ) ) - print_formatted_text(HTML(f"Details: {e}")) + print_formatted_text(HTML(f"Details: {_esc(e)}")) return None except requests.exceptions.RequestException as e: - print_formatted_text(HTML(f"CyberArk Log on failed: {e}")) + print_formatted_text( + HTML(f"CyberArk Log on failed: {_esc(e)}") + ) return None if response.status_code != 200: print_formatted_text( @@ -943,13 +1122,22 @@ def _do_import_inner(self, filename, **kwargs): use_nsf = bool(kwargs.get("use_nsf")) params = kwargs.get("params") - will_teams = environ.get("_CYBERARK_SKIP_TEAMS", "").lower() not in ("1", "true", "yes") - will_create_users = environ.get("_CYBERARK_SKIP_CREATE_USERS", "").lower() not in ("1", "true", "yes") - will_print_users = environ.get("_CYBERARK_SKIP_USERS_LIST", "").lower() not in ("1", "true", "yes") + skip_teams = (bool(kwargs.get("skip_team")) + or environ.get("_CYBERARK_SKIP_TEAMS", "").lower() in ("1", "true", "yes")) + skip_roles = (bool(kwargs.get("skip_role")) + or environ.get("_CYBERARK_SKIP_ROLES", "").lower() in ("1", "true", "yes")) + skip_users = (bool(kwargs.get("skip_user")) + or environ.get("_CYBERARK_SKIP_CREATE_USERS", "").lower() in ("1", "true", "yes")) + will_teams = not skip_teams + will_roles = not skip_roles + will_create_users = not skip_users + will_provision = will_teams or will_roles or will_create_users + will_print_users = (will_create_users + and environ.get("_CYBERARK_SKIP_USERS_LIST", "").lower() not in ("1", "true", "yes")) target_node = kwargs.get("target_node") provision_node_id = None - if will_teams: + if will_provision: provision_node_id = self._resolve_provisioning_node_id(params, target_node) safes = self._resolve_safes(pvwa_host, authorization_token) @@ -997,10 +1185,11 @@ def _do_import_inner(self, filename, **kwargs): # Gather the CyberArk identities (groups + users) that will become Keeper # teams, roles and users so they can be previewed before the import. The # fetched users are reused after the import (no second fetch). - group_names = self._preview_user_group_names(pvwa_host, authorization_token) if will_teams else [] + group_names = self._preview_user_group_names(pvwa_host, authorization_token) if will_provision else [] cyberark_users = [] - if will_print_users or will_create_users: - print_formatted_text(HTML("\nFetching CyberArk Users...")) + if will_create_users: + if will_print_users: + print_formatted_text(HTML("\nFetching CyberArk Users...")) cyberark_users = self.fetch_cyberark_users( pvwa_host, authorization_token, fetch_groups_membership=will_create_users, @@ -1024,7 +1213,7 @@ def _do_import_inner(self, filename, **kwargs): tabulate(account_rows, headers="keys"), end="\n\n", ) - if group_names: + if group_names and will_teams: team_rows = [ {"Team": g, "Status": "exists" if g.lower() in existing_teams else "new"} for g in group_names @@ -1034,6 +1223,7 @@ def _do_import_inner(self, filename, **kwargs): tabulate(team_rows, headers="keys"), end="\n\n", ) + if group_names and will_roles: role_rows = [ {"Role": g, "Status": "exists" if g.lower() in existing_roles else "new"} for g in group_names @@ -1066,24 +1256,33 @@ def _do_import_inner(self, filename, **kwargs): summary_lines.append( " - Safes as Nested Share Folders (--nsf); records created with the NSF API" ) - if group_names: - summary_lines.append(f" - {len(group_names)} user group(s) as Keeper teams and roles") + if group_names and will_teams: + summary_lines.append(f" - {len(group_names)} user group(s) as Keeper teams") + if group_names and will_roles: + summary_lines.append(f" - {len(group_names)} user group(s) as Keeper roles") if eligible_users: summary_lines.append(f" - {len(eligible_users)} user(s) provisioned as Keeper users") - if will_teams and provision_node_id is not None: + provision_labels = [ + label for enabled, label in ( + (will_teams, "teams"), (will_roles, "roles"), + (will_create_users, "users"), + ) if enabled + ] + provision_description = ", ".join(provision_labels) + if will_provision and provision_node_id is not None: if target_node: summary_lines.append( - f' - provision teams, roles, and users into node {_esc(target_node)} ' + f' - provision {provision_description} into node {_esc(target_node)} ' f'(id {provision_node_id})' ) else: summary_lines.append( - f' - provision teams, roles, and users into the default root node ' + f' - provision {provision_description} into the default root node ' f'(id {provision_node_id})' ) - elif will_teams: + elif will_provision: summary_lines.append( - ' - teams/roles/users will be skipped (no provisioning node)' + f' - {provision_description} will be skipped (no provisioning node)' ) if not self._confirm_import(pvwa_host, summary="\n".join(summary_lines)): print_formatted_text(HTML("\nImport cancelled by user")) @@ -1092,9 +1291,7 @@ def _do_import_inner(self, filename, **kwargs): # Import the accounts we already gathered above. for safe, accounts in safe_accounts.items(): print_formatted_text( - HTML(f"Importing {len(accounts)} accounts from safe {safe}:\n"), - tabulate([{"ID": x["id"], "Safe": x["safeName"], "Account": x["name"]} for x in accounts], headers="keys"), - end="\n\n", + HTML(f"\nImporting {len(accounts)} accounts from safe {_esc(safe)}...\n") ) if use_nsf: # Explicit NSF folder so prepare_nsf_folders has a SharedFolder target; @@ -1115,20 +1312,26 @@ def _do_import_inner(self, filename, **kwargs): folder.domain = r["safeName"].replace(PathDelimiter, 2 * PathDelimiter) record = Record() record.folders = [folder] - record.title = re.sub(rf"^.*{re.escape(r['platformId'])}[\-_ ]", "", r["name"]) + record.title = self._account_title(r) record.type = "Password" if "userName" in r: record.type = "login" record.login = r["userName"] if "address" in r: record.type = "serverCredentials" - if r["platformAccountProperties"].get("LogonDomain"): - record.login = r["platformAccountProperties"]["LogonDomain"] + "\\" + r["userName"] + logon_domain = self._get_platform_property(r, "LogonDomain", "Logon Domain") + if logon_domain: + record.login = str(logon_domain) + "\\" + r["userName"] if "address" in r: - record.fields.append(RecordField("host", value={"hostName": r["address"]})) - if r["platformAccountProperties"].get("URL"): - record.title = r["platformAccountProperties"]["ItemName"] - record.login_url = r["platformAccountProperties"]["URL"] + host_value = {"hostName": r["address"]} + port = self._get_platform_property(r, "Port", "PortNumber", "Port Number") + if port not in (None, ""): + host_value["port"] = str(port) + record.fields.append(RecordField("host", value=host_value)) + url = self._get_platform_property(r, "URL") + if url: + record.login_url = str(url) + self._add_account_metadata(record, r) retry = True while retry is True: try: @@ -1139,7 +1342,7 @@ def _do_import_inner(self, filename, **kwargs): "Authorization": authorization_token, "Content-Type": "application/json", }, - json={"reason": "test"}, + json={"reason": "Keeper Commander Import"}, timeout=self.TIMEOUT, verify=True if pvwa_host.endswith(".cyberark.cloud") else self._verify_tls, cert=None if pvwa_host.endswith(".cyberark.cloud") else self._client_cert, @@ -1216,12 +1419,15 @@ def _do_import_inner(self, filename, **kwargs): # Import CyberArk User Groups as Keeper Enterprise Teams + Roles, then optionally # create Keeper users (using their real CyberArk business emails) and # assign them to the matching Keeper Roles. - if will_teams and provision_node_id is not None: + if will_provision and provision_node_id is not None: self.import_user_groups( pvwa_host, authorization_token, params, cyberark_users=cyberark_users, target_node=target_node, node_id=provision_node_id, + skip_teams=skip_teams, + skip_roles=skip_roles, + skip_users=skip_users, ) print_formatted_text(HTML("\nImport completed")) @@ -1301,7 +1507,8 @@ def _resolve_provisioning_node_id(self, params, target_node=None): return None def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_users=None, - target_node=None, node_id=None): + target_node=None, node_id=None, skip_teams=False, + skip_roles=False, skip_users=False): """Fetch CyberArk User Groups and create them as Keeper Enterprise Teams. This mirrors the ``enterprise-team --add`` command flow: for each @@ -1393,28 +1600,35 @@ def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_us if node_id is None: return if target_node: + selected_objects = ", ".join( + label for skipped, label in ( + (skip_teams, "teams"), (skip_roles, "roles"), + (skip_users, "users"), + ) if not skipped + ) print_formatted_text( HTML( - f"Provisioning teams, roles, and users into node " + f"Provisioning {selected_objects} into node " f"{_esc(target_node)} (id {node_id})" ) ) - print_formatted_text( - HTML(f"Importing {len(groups)} user groups as Keeper Teams (members not provisioned):\n"), - tabulate( - [ - { - "ID": g.get("id"), - "Name": g.get("groupName") or g.get("name"), - "CyberArk Members": len(g.get("members") or []), - } - for g in groups - ], - headers="keys", - ), - end="\n\n", - ) + if not skip_teams: + print_formatted_text( + HTML(f"Importing {len(groups)} user groups as Keeper Teams (members not provisioned):\n"), + tabulate( + [ + { + "ID": g.get("id"), + "Name": g.get("groupName") or g.get("name"), + "CyberArk Members": len(g.get("members") or []), + } + for g in groups + ], + headers="keys", + ), + end="\n\n", + ) request_batch = [] request_team_names = [] # parallel list for reporting per-batch results @@ -1455,6 +1669,9 @@ def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_us (": " + ", ".join(member_names)) if member_names else "", ) + if skip_teams: + continue + if group_name.lower() in existing_team_names: skipped_existing.append(group_name) continue @@ -1494,7 +1711,9 @@ def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_us # Track locally so duplicates within the same run are also skipped existing_team_names.add(group_name.lower()) - if skipped_existing: + if skip_teams: + print_formatted_text(HTML("\nSkipping Keeper Team creation (--skip=team).")) + elif skipped_existing: print_formatted_text( HTML( f"\nSkipped {len(skipped_existing)} group(s) that already exist as " @@ -1502,7 +1721,9 @@ def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_us ) ) - if not request_batch: + if skip_teams: + pass + elif not request_batch: print_formatted_text(HTML("\nNo new Keeper Teams to create.")) else: try: @@ -1537,18 +1758,22 @@ def import_user_groups(self, pvwa_host, authorization_token, params, cyberark_us ) # Also create a Keeper Enterprise Role for each user group (mirrors enterprise-role --add) - if environ.get("_CYBERARK_SKIP_ROLES", "").lower() not in ("1", "true", "yes"): + if not skip_roles: self._create_keeper_roles(groups, params, node_id) + else: + print_formatted_text(HTML("\nSkipping Keeper Role creation (--skip=role).")) # Provision Keeper users (using their real CyberArk business emails) # and assign them to matching Roles. - if environ.get("_CYBERARK_SKIP_CREATE_USERS", "").lower() not in ("1", "true", "yes"): + if not skip_users: if cyberark_users is None: print_formatted_text(HTML("\nFetching CyberArk Users for provisioning...")) cyberark_users = self.fetch_cyberark_users( pvwa_host, authorization_token, fetch_groups_membership=True, ) self._create_keeper_users_and_assign_roles(groups, cyberark_users, params, node_id) + else: + print_formatted_text(HTML("\nSkipping Keeper User provisioning (--skip=user).")) def _create_keeper_roles(self, groups, params, node_id): """Create one Keeper Enterprise Role per CyberArk user group. diff --git a/keepercommander/importer/cyberark/pam/client.py b/keepercommander/importer/cyberark/pam/client.py index 4f85a73dc..3a916219a 100644 --- a/keepercommander/importer/cyberark/pam/client.py +++ b/keepercommander/importer/cyberark/pam/client.py @@ -14,14 +14,19 @@ import json import logging import math +import os import re +import stat import sys +import tempfile import webbrowser +import warnings from os import environ, path from typing import Any, Dict, List, Optional, Tuple from urllib.parse import parse_qsl, quote, unquote, urljoin, urlparse import requests as _requests_module +from urllib3.exceptions import InsecureRequestWarning from .constants import ( MAX_FETCH_RECORDS, @@ -89,6 +94,8 @@ def __init__(self, pvwa_host, verify_ssl=True): host, query_params = self._normalize_host(pvwa_host) self.pvwa_host = host self.query_params = query_params + self.client_cert = None + self._tmp_cert_files: List[str] = [] # SSL verification: always True for Privilege Cloud. # For self-hosted: default True, caller can disable with verify_ssl=False. if self.pvwa_host.endswith(".cyberark.cloud"): @@ -99,8 +106,210 @@ def __init__(self, pvwa_host, verify_ssl=True): logging.warning("SSL certificate verification is disabled for self-hosted PVWA. " "This is insecure and vulnerable to man-in-the-middle attacks.") self.auth_token = None + timeout_env = environ.get("KEEPER_CYBERARK_TIMEOUT") or environ.get("_CYBERARK_TIMEOUT") + if timeout_env: + try: + self.TIMEOUT = int(timeout_env) + except ValueError: + pass self._validate_host(self.pvwa_host) + def _pvwa_request(self, method: str, url: str, **kwargs): + """Send a PVWA request the same way as ``import --format=cyberark``. + + Applies mTLS client cert, timeout, TLS verify, and suppresses the + urllib3 warning when verification is intentionally disabled. + GET/POST go through ``requests.get``/``requests.post`` so unit tests + that patch those helpers still intercept PVWA traffic. + """ + kwargs.setdefault("timeout", self.TIMEOUT) + kwargs.setdefault("verify", self.verify_ssl) + if self.client_cert and "cert" not in kwargs: + kwargs["cert"] = self.client_cert + method_u = method.upper() + + def _do(): + if method_u == "GET": + return requests.get(url, **kwargs) + if method_u == "POST": + return requests.post(url, **kwargs) + return requests.request(method_u, url, **kwargs) + + if kwargs.get("verify") is False: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", InsecureRequestWarning) + return _do() + return _do() + + @staticmethod + def _connection_error_hint(pvwa_host, error): + """Return user-facing guidance for a PVWA connection failure.""" + err = str(error).lower() + host = _esc(pvwa_host) + if "getaddrinfo failed" in err or "failed to resolve" in err or "name resolution" in err: + return ( + f"Could not resolve hostname {host}.\n" + "For self-hosted PVWA, use the exact hostname or IP from your CyberArk admin, " + "connect to VPN, or add an entry to your hosts file." + ) + if "ssl" in err or "certificate" in err: + return ( + f"TLS handshake to {host} failed.\n" + "If this is a private-CA PVWA, the server certificate chain may be incomplete, " + "or the P12 client certificate may not be trusted by IIS. " + "Retry with the same host used by import --format=cyberark." + ) + if "timed out" in err or "timeout" in err: + return ( + f"Connection to {host} timed out.\n" + "Ensure VPN is connected, the PVWA is running, and the port is reachable. " + "If PVWA uses a non-standard port, specify it as hostname:port." + ) + if "connection refused" in err: + return ( + f"Connection to {host} was refused.\n" + "Verify the PVWA service is running and the port is correct." + ) + return ( + f"Could not connect to {host}.\n" + "Verify the PVWA hostname is correct and reachable from this machine." + ) + + def _load_p12_client_cert(self, p12_path: str, p12_password: Optional[str]) -> Optional[Tuple[str, str]]: + """Convert a PKCS#12 bundle to temporary PEM cert+key files for requests.""" + try: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.serialization import pkcs12 + except ImportError as e: + print_formatted_text( + HTML( + "cryptography package is required for P12 client certificates: " + f"{_esc(e)}" + ) + ) + return None + + try: + with open(p12_path, "rb") as fh: + p12_bytes = fh.read() + except OSError as e: + print_formatted_text( + HTML( + f"Could not read P12 file {_esc(p12_path)}: {_esc(e)}" + ) + ) + return None + + password_bytes = p12_password.encode("utf-8") if p12_password else None + try: + private_key, cert, additional_certs = pkcs12.load_key_and_certificates( + p12_bytes, password_bytes + ) + except ValueError as e: + print_formatted_text( + HTML( + "Failed to decode P12 bundle — check the file and passphrase: " + f"{_esc(e)}" + ) + ) + return None + + if private_key is None or cert is None: + print_formatted_text( + HTML( + "P12 bundle is missing a private key or certificate; " + "client-certificate authentication requires both." + ) + ) + return None + + cert_fd, cert_path = tempfile.mkstemp(prefix="ca_pvwa_", suffix=".pem") + key_fd, key_path = tempfile.mkstemp(prefix="ca_pvwa_", suffix=".key.pem") + try: + with os.fdopen(cert_fd, "wb") as cert_file: + cert_file.write(cert.public_bytes(serialization.Encoding.PEM)) + for extra in additional_certs or []: + cert_file.write(extra.public_bytes(serialization.Encoding.PEM)) + with os.fdopen(key_fd, "wb") as key_file: + key_file.write( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + try: + os.chmod(cert_path, stat.S_IRUSR | stat.S_IWUSR) + os.chmod(key_path, stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + except OSError as e: + print_formatted_text( + HTML(f"Failed to materialize P12 to PEM: {_esc(e)}") + ) + for p in (cert_path, key_path): + try: + os.remove(p) + except OSError: + pass + return None + + self._tmp_cert_files.extend([cert_path, key_path]) + return cert_path, key_path + + def _cleanup_tmp_cert_files(self): + """Delete temporary PEM files materialized from a P12 bundle.""" + for p in self._tmp_cert_files: + try: + os.remove(p) + except OSError: + pass + self._tmp_cert_files = [] + self.client_cert = None + + def _maybe_configure_client_cert(self) -> bool: + """Load a self-hosted PVWA client cert from P12 when configured.""" + if self.pvwa_host.endswith(".cyberark.cloud"): + return True + if self.client_cert: + return True + + p12_path = ( + environ.get("KEEPER_CYBERARK_CLIENT_CERT_P12") + or environ.get("_CYBERARK_CLIENT_CERT_P12") + ) + if p12_path is None: + try: + entered = prompt( + "CyberArk PVWA client certificate P12 path (leave empty if none): " + ).strip() + except (EOFError, KeyboardInterrupt): + return False + p12_path = entered or None + + if not p12_path: + return True + if not path.isfile(p12_path): + print_formatted_text( + HTML(f"P12 file not found: {_esc(p12_path)}") + ) + return False + + p12_password = ( + environ.get("KEEPER_CYBERARK_CLIENT_CERT_PASSWORD") + or environ.get("_CYBERARK_CLIENT_CERT_PASSWORD") + ) + if p12_password is None: + p12_password = prompt("P12 passphrase: ", is_password=True) + cert_tuple = self._load_p12_client_cert(p12_path, p12_password) + if cert_tuple is None: + return False + self.client_cert = cert_tuple + print_formatted_text( + HTML("Loaded PVWA client certificate from P12 — using mutual TLS.") + ) + return True + @staticmethod def _normalize_host(filename): """Normalize PVWA host — same logic as existing CyberArkImporter (PR #1423). @@ -166,30 +375,29 @@ def _get_url(self, endpoint): def logoff(self): """Log off from CyberArk PVWA. Best-effort — failures are silently ignored.""" if not self.auth_token: + self._cleanup_tmp_cert_files() return try: base = self._get_url("safes").rsplit("/Safes", 1)[0] - requests.post( + self._pvwa_request( + "POST", f"{base}/Auth/Logoff", headers={"Authorization": self.auth_token, "Content-Type": "application/json"}, - timeout=self.TIMEOUT, - verify=self.verify_ssl, - allow_redirects=False, ) except Exception: pass # Best-effort logoff self.auth_token = None + self._cleanup_tmp_cert_files() def _get(self, url, params=None): """GET with automatic retry on HTTP 429 (rate limit). Redirects disabled.""" response = None for attempt in range(self.MAX_RETRIES): - response = requests.get( + response = self._pvwa_request( + "GET", url, headers={"Authorization": self.auth_token, "Content-Type": "application/json"}, params=params, - timeout=self.TIMEOUT, - verify=self.verify_ssl, allow_redirects=False, ) if response.status_code != 429: @@ -781,8 +989,14 @@ def _post(body: dict) -> Optional[dict]: return result def _auth_self_hosted(self) -> bool: - login_type = environ.get("KEEPER_CYBERARK_LOGON_TYPE") or prompt( + if not self._maybe_configure_client_cert(): + return False + login_type = ( + environ.get("KEEPER_CYBERARK_LOGON_TYPE") + or environ.get("_CYBERARK_LOGON_TYPE") + or prompt( "CyberArk logon type (Cyberark, LDAP, RADIUS or Windows): " + ) ) # Validate login_type to prevent URL path injection (case-insensitive) if login_type.lower() not in VALID_LOGON_TYPES: @@ -790,31 +1004,71 @@ def _auth_self_hosted(self) -> bool: f"Invalid logon type: must be one of Cyberark, LDAP, RADIUS, Windows" )) return False - username = environ.get("KEEPER_CYBERARK_USERNAME") or prompt("CyberArk username: ") - password = environ.get("KEEPER_CYBERARK_PASSWORD") or prompt("CyberArk password: ", is_password=True) + username = ( + environ.get("KEEPER_CYBERARK_USERNAME") + or environ.get("_CYBERARK_USERNAME") + or prompt("CyberArk username: ") + ) + password = ( + environ.get("KEEPER_CYBERARK_PASSWORD") + or environ.get("_CYBERARK_PASSWORD") + or prompt("CyberArk password: ", is_password=True) + ) try: - response = requests.post( + response = self._pvwa_request( + "POST", self._get_url("logon").format(type=login_type), json={"username": username, "password": password}, - timeout=self.TIMEOUT, - verify=self.verify_ssl, ) + except _requests_module.ConnectionError as e: + print_formatted_text( + HTML( + f"CyberArk Log on failed: " + f"{self._connection_error_hint(self.pvwa_host, e)}" + ) + ) + print_formatted_text(HTML(f"Details: {_esc(e)}")) + logging.warning("Logon connection error: %s", e) + return False except _requests_module.RequestException as e: - print_formatted_text(HTML(f"CyberArk Log on failed: connection error")) - logging.debug(f"Logon connection error: {type(e).__name__}") + print_formatted_text( + HTML(f"CyberArk Log on failed: {_esc(e)}") + ) + logging.warning("Logon request error: %s", e) return False if response.status_code != 200: print_formatted_text(HTML( f"CyberArk Log on failed with status code {response.status_code}" )) + try: + body = (response.text or "")[:500] + if body: + print_formatted_text(HTML(f"Response: {_esc(body)}")) + except Exception: + pass return False - # CyberArk's Logon endpoint returns the token as a JSON string. - # Use json() for proper quote/escape handling rather than strip('"'). + # Match ``import --format=cyberark``: Logon returns a JSON-quoted string. try: - token = response.json() + parsed = response.json() except ValueError: - token = response.text.strip('"') - self.auth_token = token if isinstance(token, str) else "" + parsed = None + if isinstance(parsed, str) and parsed.strip(): + token = parsed.strip() + elif isinstance(parsed, dict): + token = ( + parsed.get("CyberArkLogonResult") + or parsed.get("token") + or parsed.get("sessionToken") + or "" + ) + else: + token = (response.text or "").strip().strip('"') + self.auth_token = str(token).strip().strip('"') + if not self.auth_token: + print_formatted_text(HTML( + "CyberArk Log on succeeded but no session token was returned" + )) + return False print_formatted_text(HTML("Log on successful")) return True @@ -832,8 +1086,24 @@ def _paginate(self, url: str, params: Optional[dict] = None, while True: time.sleep(self.DELAY) response = self._get(next_url, params=page_params) + if response is None: + logging.warning('Paginated fetch failed: %s (no response)', next_url) + break if response.status_code != 200: - logging.debug('Paginated fetch failed: %s status %d', next_url, response.status_code) + print_formatted_text(HTML( + f"Request to {_esc(next_url)} failed " + f"with status {response.status_code}" + )) + try: + body = (response.text or "")[:300] + if body: + print_formatted_text(HTML(f"Response: {_esc(body)}")) + except Exception: + pass + logging.warning( + 'Paginated fetch failed: %s status %d', + next_url, response.status_code, + ) break try: data = response.json() @@ -901,6 +1171,11 @@ def fetch_safes(self) -> List[dict]: # Fetch from server — now with pagination print_formatted_text(HTML("Getting safes from the server...")) safes = self._paginate(self._get_url("safes"), limit=200) + if not safes: + safes = self._paginate( + f"https://{self.pvwa_host}/PasswordVault/api/Safes", + limit=200, + ) if not safes: print_formatted_text(HTML(f"No Safes on server {_esc(self.pvwa_host)}")) return safes @@ -1393,67 +1668,115 @@ def fetch_account_details(self, account_id: str) -> Optional[dict]: account_id, type(e).__name__) return None + @staticmethod + def _safe_account_id(account_id) -> Optional[str]: + """Return a path-safe CyberArk account id, or None if it looks malicious.""" + s = str(account_id or "").strip() + if not s or len(s) > 128: + return None + if any(ch in s for ch in ("/", "\\", "?", "#", "\x00")) or ".." in s: + return None + return s + def retrieve_password(self, account_id: str, account_name: str = "", safe_name: str = "", skip_all: Optional[dict] = None) -> Optional[str]: """Retrieve password for an account. Returns password string or None.""" if skip_all is None: skip_all = {} - # Validate account_id format before URL interpolation - if not re.match(r'^[a-zA-Z0-9_]+$', str(account_id)): + account_id_raw = account_id + account_id = self._safe_account_id(account_id) + if not account_id: logging.warning('Invalid account ID for password retrieval: %s', - re.sub(r'[^a-zA-Z0-9_]', '?', str(account_id))) + re.sub(r'[^a-zA-Z0-9_.\-]', '?', str(account_id_raw))) return None + payload = {"reason": "Keeper Commander Import"} + if "KEEPER_CYBERARK_TICKETING_SYSTEM" in environ: + payload["TicketingSystemName"] = environ["KEEPER_CYBERARK_TICKETING_SYSTEM"] + if "KEEPER_CYBERARK_TICKET_ID" in environ: + payload["TicketId"] = environ["KEEPER_CYBERARK_TICKET_ID"] + retrieve_urls = [ + self._get_url("account_password").format(account_id=account_id), + f"https://{self.pvwa_host}/PasswordVault/api/Accounts/{account_id}/Password/Retrieve", + ] retry = True + url_index = 0 while retry is True: + url = retrieve_urls[url_index] try: - response = requests.post( - self._get_url("account_password").format(account_id=account_id), - headers={"Authorization": self.auth_token, "Content-Type": "application/json"}, - json={ - "reason": "test", - **({"TicketingSystemName": environ["KEEPER_CYBERARK_TICKETING_SYSTEM"]} - if "KEEPER_CYBERARK_TICKETING_SYSTEM" in environ else {}), - **({"TicketId": environ["KEEPER_CYBERARK_TICKET_ID"]} - if "KEEPER_CYBERARK_TICKET_ID" in environ else {}), + response = self._pvwa_request( + "POST", + url, + headers={ + "Authorization": self.auth_token, + "Content-Type": "application/json", }, - timeout=self.TIMEOUT, - verify=self.verify_ssl, + json=payload, + allow_redirects=False, ) except _requests_module.RequestException as e: - logging.debug('Password retrieval network error for %s: %s', - account_id, type(e).__name__) + print_formatted_text(HTML( + f"Password retrieval failed for " + f"{_esc(account_name)}: {_esc(e)}" + )) + logging.warning('Password retrieval network error for %s: %s', + account_id, e) return None if response.status_code == 200: - # Password endpoint returns a JSON string; parse properly - # to avoid edge cases in embedded quotes or escapes. try: pw = response.json() except ValueError: pw = response.text.strip('"') return pw if isinstance(pw, str) else None - elif 400 <= response.status_code < 500: + if response.status_code in (401, 404, 405) and url_index < len(retrieve_urls) - 1: + url_index += 1 + logging.debug( + 'Password retrieve %s at %s — trying fallback URL', + response.status_code, url, + ) + continue + if 400 <= response.status_code < 500: try: error = response.json() + if not isinstance(error, dict): + error = {"ErrorCode": "UNKNOWN", "ErrorMessage": str(error)} except ValueError: - error = {"ErrorCode": "UNKNOWN", "ErrorMessage": "Non-JSON error response"} + error = { + "ErrorCode": "UNKNOWN", + "ErrorMessage": (response.text or "Non-JSON error response")[:300], + } error_code = error.get("ErrorCode") + error_message = error.get("ErrorMessage") or "" + print_formatted_text(HTML( + f"Password retrieval failed " + f"(HTTP {response.status_code}) for {_esc(account_name)} " + f"in safe {_esc(safe_name)}: " + f"{_esc(error_code)} {_esc(error_message)}" + )) if error_code in skip_all: return None - retry = button_dialog( - title=f"{response.status_code}", - text=HTML( - f"Error {_esc(error_code)}: {_esc(error.get('ErrorMessage', ''))}\n" - f"Account {_esc(account_name)} with ID {_esc(account_id)} in Safe {_esc(safe_name)}" - ), - buttons=[("Retry", True), ("Skip", False), ("Skip All", None)], - style=Style.from_dict({"dialog": "bg:ansiblack"}), - ).run() + try: + retry = button_dialog( + title=f"{response.status_code}", + text=HTML( + f"Error {_esc(error_code)}: {_esc(error_message)}\n" + f"Account {_esc(account_name)} with ID " + f"{_esc(account_id)} in Safe {_esc(safe_name)}" + ), + buttons=[("Retry", True), ("Skip", False), ("Skip All", None)], + style=Style.from_dict({"dialog": "bg:ansiblack"}), + ).run() + except Exception: + return None if retry is None: skip_all[error_code] = True return None if retry is False: return None + url_index = 0 else: - print_formatted_text(HTML(f"Password retrieval aborted (status {response.status_code})")) + print_formatted_text(HTML( + f"Password retrieval aborted " + f"(status {response.status_code})" + )) return None return None diff --git a/keepercommander/importer/imp_exp.py b/keepercommander/importer/imp_exp.py index fcaa869dd..e59421cf0 100644 --- a/keepercommander/importer/imp_exp.py +++ b/keepercommander/importer/imp_exp.py @@ -752,6 +752,14 @@ def _import(params, file_format, filename, **kwargs): show_skipped = kwargs.get('show_skipped') is True secret_ids = kwargs.get('secret_ids') target_node = kwargs.get('target_node') + cyberark_skip = { + x.strip().lower() + for x in str(kwargs.get('skip') or '').split(',') + if x.strip() + } + skip_team = kwargs.get('skip_team') is True or 'team' in cyberark_skip + skip_role = kwargs.get('skip_role') is True or 'role' in cyberark_skip + skip_user = kwargs.get('skip_user') is True or 'user' in cyberark_skip import_into_raw = kwargs.get('import_into') or '' import_into = import_into_raw @@ -779,7 +787,8 @@ def _import(params, file_format, filename, **kwargs): 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, secret_ids=secret_ids, - dry_run=dry_run, target_node=target_node, use_nsf=use_nsf): + dry_run=dry_run, target_node=target_node, use_nsf=use_nsf, + skip_team=skip_team, skip_role=skip_role, skip_user=skip_user): if isinstance(x, ImportRecord): if filter_folder and not importer.support_folder_filter(): if not x.folders: @@ -2083,12 +2092,14 @@ def tokenize_record_key(record, folder): # type: (ImportRecord, str) -> Iterat yield hash_value -def tokenize_full_import_record(record): # type: (ImportRecord) -> Iterator[str] +def tokenize_full_import_record(record, folder=None): # type: (ImportRecord, Optional[str]) -> Iterator[str] """ Turn a record-to-import into an iterable of str's for hashing. Examine the entire record. """ + if folder is not None: + yield f'$folder:{folder.casefold()}' yield f'$type:{record.type or ""}' yield f'$title:{record.title or ""}' yield f'$login:{record.login or ""}' @@ -2104,6 +2115,19 @@ def tokenize_full_import_record(record): # type: (ImportRecord) -> Iterator[st yield hash_key +def get_import_record_folder_paths(params, record): # type: (KeeperParams, ImportRecord) -> List[str] + folders = [] + for folder in record.folders or []: + if folder.uid and folder.uid in params.folder_cache: + folders.append(get_folder_path(params, folder.uid)) + else: + folders.append(folder.get_folder_path()) + folders = [x for x in folders if x] + if not folders: + folders.append('') + return folders + + def _construct_record_v2(rec_to_import, orig_extra=None): # type: (ImportRecord, Optional[dict]) -> (dict, dict) totp = None custom_fields = [] @@ -2327,8 +2351,13 @@ def prepare_record_add_or_update(update_flag, no_shortcuts, params, records): for record_uid in params.record_cache: import_record = convert_keeper_record(params.record_cache[record_uid]) if import_record: - record_hash = build_record_hash(tokenize_full_import_record(import_record)) - preexisting_entire_record_hash[record_hash] = record_uid + folders = [get_folder_path(params, x) for x in find_folders(params, record_uid)] + folders = [x for x in folders if x] + if len(folders) == 0: + folders.append('') + for folder in folders: + record_hash = build_record_hash(tokenize_full_import_record(import_record, folder)) + preexisting_entire_record_hash[record_hash] = record_uid if update_flag: folders = [get_folder_path(params, x) for x in find_folders(params, record_uid)] folders = [x for x in folders if x] @@ -2368,14 +2397,21 @@ def prepare_record_add_or_update(update_flag, no_shortcuts, params, records): import_record.attachments.append(atta) f.value = LARGE_FIELD_MSG.format(atta.name) - record_hash = build_record_hash(tokenize_full_import_record(import_record)) - if no_shortcuts is False and record_hash in preexisting_entire_record_hash: - record_uid = preexisting_entire_record_hash[record_hash] - if import_record.uid: - external_lookup[import_record.uid] = record_uid - import_record.uid = record_uid - record_exists.append(import_record) - continue + if no_shortcuts is False: + record_uid = next(( + preexisting_entire_record_hash[record_hash] + for record_hash in ( + build_record_hash(tokenize_full_import_record(import_record, folder)) + for folder in get_import_record_folder_paths(params, import_record) + ) + if record_hash in preexisting_entire_record_hash + ), None) + if record_uid: + if import_record.uid: + external_lookup[import_record.uid] = record_uid + import_record.uid = record_uid + record_exists.append(import_record) + continue if import_record.uid and import_record.uid in params.record_cache: record_uid_to_update.add(import_record.uid) diff --git a/tests/test_cyberark_pam_import.py b/tests/test_cyberark_pam_import.py index 433944469..0a2a2655e 100644 --- a/tests/test_cyberark_pam_import.py +++ b/tests/test_cyberark_pam_import.py @@ -836,6 +836,74 @@ def test_selfhosted_default_verify_true(self, mock_dns): assert client.verify_ssl is True +class TestSelfHostedClientCert: + + @patch("keepercommander.importer.cyberark.cyberark_pam.print_formatted_text") + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.prompt") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_auth_self_hosted_sends_client_cert(self, mock_dns, mock_prompt, mock_requests, mock_print): + mock_prompt.side_effect = ["Cyberark", "admin", "pass"] + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = "token123" + mock_requests.post.return_value = mock_resp + + client = CyberArkPVWAClient("pvwa.example.com") + client.client_cert = ("cert.pem", "key.pem") + + assert client._auth_self_hosted() is True + _, kwargs = mock_requests.post.call_args + assert kwargs.get("cert") == ("cert.pem", "key.pem") + assert kwargs.get("verify") is True + assert kwargs.get("allow_redirects") is not False + + @patch("keepercommander.importer.cyberark.cyberark_pam.print_formatted_text") + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_retrieve_password_sends_client_cert(self, mock_dns, mock_requests, mock_print): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = "secret" + mock_requests.post.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.client_cert = ("cert.pem", "key.pem") + client.auth_token = "session-token" + assert client.retrieve_password("12_3", "acct", "Safe") == "secret" + _, kwargs = mock_requests.post.call_args + assert kwargs.get("cert") == ("cert.pem", "key.pem") + assert kwargs.get("json") == {"reason": "test"} + assert kwargs.get("allow_redirects") is False + + @patch("keepercommander.importer.cyberark.cyberark_pam.print_formatted_text") + @patch("keepercommander.importer.cyberark.pam.client.CyberArkPVWAClient._load_p12_client_cert", + return_value=("cert.pem", "key.pem")) + @patch("keepercommander.importer.cyberark.pam.client.path.isfile", return_value=True) + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_maybe_configure_client_cert_from_env(self, mock_dns, mock_isfile, mock_load, mock_print, monkeypatch): + monkeypatch.setenv("KEEPER_CYBERARK_CLIENT_CERT_P12", "client.p12") + monkeypatch.setenv("KEEPER_CYBERARK_CLIENT_CERT_PASSWORD", "p12-secret") + client = CyberArkPVWAClient("pvwa.example.com") + assert client._maybe_configure_client_cert() is True + assert client.client_cert == ("cert.pem", "key.pem") + + @patch("keepercommander.importer.cyberark.cyberark_pam.print_formatted_text") + @patch("keepercommander.importer.cyberark.pam.client.CyberArkPVWAClient._load_p12_client_cert", + return_value=("cert.pem", "key.pem")) + @patch("keepercommander.importer.cyberark.pam.client.path.isfile", return_value=True) + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_maybe_configure_client_cert_from_legacy_env(self, mock_dns, mock_isfile, mock_load, mock_print, monkeypatch): + monkeypatch.setenv("_CYBERARK_CLIENT_CERT_P12", "legacy-client.p12") + monkeypatch.setenv("_CYBERARK_CLIENT_CERT_PASSWORD", "legacy-secret") + client = CyberArkPVWAClient("pvwa.example.com") + assert client._maybe_configure_client_cert() is True + assert client.client_cert == ("cert.pem", "key.pem") + + # ── CyberArkPAMImportCommand Tests ────────────────────────── @@ -867,6 +935,8 @@ def test_all_flags_parse(self): "--platform-map", "map.json", "--state-filter", "active,inactive", "--no-verify-ssl", + "--client-cert-p12", "client.p12", + "--client-cert-password", "secret", ]) assert args.server == "pvwa.example.com" assert args.project_name == "My Project" @@ -891,6 +961,8 @@ def test_all_flags_parse(self): assert args.platform_map == "map.json" assert args.state_filter == "active,inactive" assert args.no_verify_ssl is True + assert args.client_cert_p12 == "client.p12" + assert args.client_cert_password == "secret" def test_minimal_args(self): cmd = CyberArkPAMImportCommand() @@ -1417,6 +1489,132 @@ def test_original_endpoints_unchanged(self): assert expected_endpoints.items() <= CyberArkImporter.ENDPOINTS.items() +class TestClassicCyberArkMetadataImport: + """Metadata preservation for ``import --format=cyberark``.""" + + def test_account_name_becomes_keeper_title(self): + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + + account = { + "id": "account-1", + "name": "demo account", + "platformId": "GenericPlatform", + "platformAccountProperties": { + "ItemName": "friendly name", + }, + } + + assert CyberArkImporter._account_title(account) == "demo account" + + def test_imports_relevant_platform_fields_only(self): + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + from keepercommander.importer.importer import Record, RecordField + + account = { + "id": "account-2", + "name": "demo account", + "platformName": "Generic Platform", + "deviceType": "Server", + "safeName": "Source Safe", + "address": "host.example.com", + "userName": "admin", + "createdTime": 1756374692000, + "modifiedTime": 1756374700000, + "secretManagement": {"status": "success"}, + "platformAccountProperties": { + "OwnerName": "service-owner", + "Environment": {"Name": "prod"}, + "Enabled": False, + "Aliases": ["primary", "legacy"], + "Protocol": "SSH", + "Address": "host.example.com", + "Username": "admin", + }, + } + record = Record() + record.title = account["name"] + record.login = account["userName"] + record.fields.append(RecordField( + "host", value={"hostName": account["address"]}, + )) + + CyberArkImporter._add_account_metadata(record, account) + + fields = {field.label: field.value for field in record.fields if field.label} + assert fields["Platform Name"] == "Generic Platform" + assert fields["Device Type"] == "Server" + assert fields["Owner Name"] == "service-owner" + assert fields["Environment.Name"] == "prod" + assert fields["Enabled"] == "false" + assert fields["Aliases"] == '["primary","legacy"]' + assert fields["Protocol"] == "SSH" + assert "id" not in fields + assert "safeName" not in fields + assert "createdTime" not in fields + assert "modifiedTime" not in fields + assert "secretManagement" not in fields + assert "Address" not in fields + assert "Username" not in fields + + def test_standard_mapped_platform_properties_are_not_duplicated(self): + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + from keepercommander.importer.importer import Record, RecordField + + account = { + "platformAccountProperties": { + "ItemName": "server-title", + "URL": "https://server.example.com", + "LogonDomain": "CORP", + "Account Name": "sample-user", + "Address": "server.example.com", + "Port": "3389", + "Protocol": "RDP", + "Device Type": "Generic Device", + }, + } + record = Record() + record.title = "server-title" + record.login = "CORP\\sample-user" + record.login_url = "https://server.example.com" + record.fields.append(RecordField( + "host", value={"hostName": "server.example.com", "port": "3389"}, + )) + + CyberArkImporter._add_account_metadata(record, account) + + custom_fields = {field.label: field.value for field in record.fields if field.label} + assert custom_fields == { + "Protocol": "RDP", + "Device Type": "Generic Device", + } + + def test_metadata_is_serialized_as_keeper_custom_fields(self): + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + from keepercommander.importer.importer import Record + from keepercommander.importer.imp_exp import _construct_record_v3_data + + record = Record() + record.title = "server-account" + record.type = "login" + CyberArkImporter._add_account_metadata(record, { + "platformId": "GenericPlatform", + "platformAccountProperties": { + "Protocol": "SSH", + "Device Type": "Server", + }, + }) + + data = _construct_record_v3_data(record) + custom = { + field.get("label"): field.get("value", [None])[0] + for field in data["custom"] if field.get("label") + } + + assert custom["Platform Name"] == "GenericPlatform" + assert custom["Protocol"] == "SSH" + assert custom["Device Type"] == "Server" + + # ── Phase 2 Tests: System Safe Exclusion + Safe Filtering ───── class TestSystemSafeExclusion: @@ -1907,6 +2105,310 @@ def test_kept_for_cyberark_format(self): assert mock_import.call_args.kwargs.get("target_node") == "Eng" +class TestClassicCyberArkSkipProvisioningArgs: + """Skip flags for ``import --format=cyberark``.""" + + @pytest.mark.parametrize("skip_value,expected", [ + ("team", "team"), + ("role", "role"), + ("user", "user"), + ("team,role,user", "team,role,user"), + ("teams,roles,users", "team,role,user"), + ]) + def test_parser_accepts_skip_targets(self, skip_value, expected): + from keepercommander.importer.commands import import_parser + + ns = import_parser.parse_args([ + "--format", "cyberark", f"--skip={skip_value}", "https://pvwa", + ]) + + assert ns.skip == expected + + @pytest.mark.parametrize("flag", [ + "--skipt-team", + "--skip-team", + "--skip-role", + "--skip-user", + ]) + def test_parser_rejects_old_skip_flags(self, flag): + from keepercommander.importer.commands import import_parser + from keepercommander.commands.base import ParseError + + with pytest.raises(ParseError): + import_parser.parse_args([ + "--format", "cyberark", flag, "https://pvwa", + ]) + + def test_parser_rejects_unknown_skip_target(self): + from keepercommander.importer.commands import import_parser + from keepercommander.commands.base import ParseError + + with pytest.raises(ParseError): + import_parser.parse_args([ + "--format", "cyberark", "--skip=folder", "https://pvwa", + ]) + + def test_skip_targets_are_forwarded_for_cyberark(self): + from keepercommander.importer.commands import RecordImportCommand + + params = MagicMock(enforcements=None) + with patch("keepercommander.importer.commands.imp_exp._import") as mock_import: + RecordImportCommand().execute( + params, format="cyberark", name="https://pvwa", + skip="team,role,user", + ) + + forwarded = mock_import.call_args.kwargs + assert forwarded["skip"] == "team,role,user" + + def test_generic_import_engine_forwards_skip_targets_to_cyberark_importer(self): + from keepercommander.importer import imp_exp + + class StopAfterCapture(Exception): + pass + + captured = {} + + class CapturingImporter: + verbose_import_summary = False + + def execute(self, _filename, **kwargs): + captured.update(kwargs) + raise StopAfterCapture() + + params = MagicMock() + params.record_cache = {} + + with patch.object(imp_exp, "importer_for_format", return_value=CapturingImporter): + with pytest.raises(StopAfterCapture): + imp_exp._import( + params, "cyberark", "https://pvwa", + skip="team,role,user", + ) + + assert captured["skip_team"] is True + assert captured["skip_role"] is True + assert captured["skip_user"] is True + + def test_skip_targets_are_ignored_for_other_formats(self): + from keepercommander.importer.commands import RecordImportCommand + + params = MagicMock(enforcements=None) + with patch("keepercommander.importer.commands.imp_exp._import") as mock_import: + RecordImportCommand().execute( + params, format="json", name="vault.json", + skip="team,role,user", + ) + + forwarded = mock_import.call_args.kwargs + assert forwarded["skip"] == "" + + @staticmethod + def _group_response(): + response = MagicMock(status_code=200) + response.json.return_value = { + "value": [{ + "id": "group-1", + "groupName": "Operations", + "members": [{"username": "operator"}], + }], + } + return response + + @staticmethod + def _account_response(): + response = MagicMock(status_code=200) + response.json.return_value = { + "value": [{ + "id": "account-1", + "safeName": "SourceSafe", + "name": "demo account", + "platformId": "GenericPlatform", + "address": "host.example.com", + "userName": "admin", + "platformAccountProperties": {"Protocol": "SSH", "port": "2222"}, + }], + } + return response + + @patch("keepercommander.importer.cyberark.cyberark.sleep") + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + @patch("keepercommander.importer.cyberark.cyberark.ProgressBar") + def test_skip_user_does_not_fetch_cyberark_users_or_reprint_account_table(self, progress_bar, mock_print, _sleep): + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + + class FakeProgressBar: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def __call__(self, iterable, total=None): + return iterable + + progress_bar.side_effect = FakeProgressBar + + importer = CyberArkImporter() + importer._authenticate_pvwa = MagicMock(return_value=("pvwa.example.com", "token", {})) + importer._resolve_safes = MagicMock(return_value=["SourceSafe"]) + importer.get_response = MagicMock(return_value=self._account_response()) + importer.fetch_cyberark_users = MagicMock(return_value=[]) + importer._enterprise_existing = MagicMock(return_value=(set(), set(), {})) + importer._confirm_import = MagicMock(return_value=True) + password_response = MagicMock(status_code=200) + password_response.text = '"secret"' + importer._request = MagicMock(return_value=password_response) + + records = list(importer._do_import_inner( + "https://pvwa.example.com", params=MagicMock(), + skip_team=True, skip_role=True, skip_user=True, + )) + + importer.fetch_cyberark_users.assert_not_called() + assert len(records) == 1 + typed_fields = {field.type: field.value for field in records[0].fields if not field.label} + custom_fields = {field.label: field.value for field in records[0].fields if field.label} + assert typed_fields["host"] == {"hostName": "host.example.com", "port": "2222"} + assert custom_fields == { + "Protocol": "SSH", + "Platform Name": "GenericPlatform", + } + table_prints = [ + call for call in mock_print.call_args_list + if len(call.args) > 1 and "demo account" in str(call.args) + ] + assert len(table_prints) == 1 + + @patch("keepercommander.importer.cyberark.cyberark.api.execute_batch") + @patch("keepercommander.importer.cyberark.cyberark.api.query_enterprise") + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + @patch("keepercommander.importer.cyberark.cyberark.path.isfile", return_value=False) + def test_skip_team_still_allows_roles_and_users(self, _isfile, _print, _query, execute_batch): + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + + importer = CyberArkImporter() + importer.get_response = MagicMock(return_value=self._group_response()) + importer._create_keeper_roles = MagicMock() + importer._create_keeper_users_and_assign_roles = MagicMock() + params = MagicMock() + params.enterprise = {"teams": [], "queued_teams": []} + + importer.import_user_groups( + "pvwa.example.com", "token", params, + cyberark_users=[], node_id=1, skip_teams=True, + ) + + execute_batch.assert_not_called() + importer._create_keeper_roles.assert_called_once() + importer._create_keeper_users_and_assign_roles.assert_called_once() + + @patch("keepercommander.importer.cyberark.cyberark.api.execute_batch") + @patch("keepercommander.importer.cyberark.cyberark.api.query_enterprise") + @patch("keepercommander.importer.cyberark.cyberark.print_formatted_text") + @patch("keepercommander.importer.cyberark.cyberark.path.isfile", return_value=False) + def test_all_skip_flags_prevent_all_enterprise_writes(self, _isfile, _print, _query, execute_batch): + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + + importer = CyberArkImporter() + importer.get_response = MagicMock(return_value=self._group_response()) + importer._create_keeper_roles = MagicMock() + importer._create_keeper_users_and_assign_roles = MagicMock() + params = MagicMock() + params.enterprise = {"teams": [], "queued_teams": []} + + importer.import_user_groups( + "pvwa.example.com", "token", params, + cyberark_users=[], node_id=1, skip_teams=True, + skip_roles=True, skip_users=True, + ) + + execute_batch.assert_not_called() + importer._create_keeper_roles.assert_not_called() + importer._create_keeper_users_and_assign_roles.assert_not_called() + + +class TestImportDuplicateDetection: + """Duplicate detection should be scoped to the target folder.""" + + @staticmethod + def _params(existing_folder_uid): + def folder(name, parent_uid): + node = MagicMock() + node.name = name + node.parent_uid = parent_uid + node.type = "user_folder" + return node + + params = MagicMock() + params.record_cache = { + "existing_uid": { + "record_uid": "existing_uid", + "version": 3, + "data_unencrypted": json.dumps({ + "title": "demo account", + "type": "serverCredentials", + "notes": "", + "fields": [ + {"type": "login", "value": ["admin"]}, + {"type": "password", "value": ["secret"]}, + {"type": "host", "value": [{"hostName": "host.example.com"}]}, + ], + "custom": [], + }), + }, + } + params.record_type_cache = {} + params.subfolder_record_cache = { + existing_folder_uid: {"existing_uid"}, + } + params.folder_cache = { + "migration": folder("TargetRoot", ""), + "target": folder("TargetFolder", "migration"), + "other": folder("OtherFolder", ""), + } + return params + + @staticmethod + def _record(): + from keepercommander.importer.importer import Folder, Record, RecordField + + record = Record() + record.title = "demo account" + record.type = "serverCredentials" + record.login = "admin" + record.password = "secret" + record.fields.append(RecordField( + "host", value={"hostName": "host.example.com"}, + )) + folder = Folder() + folder.path = "TargetRoot\\TargetFolder" + record.folders = [folder] + return record + + def test_matching_record_in_different_folder_is_imported(self): + from keepercommander.importer.imp_exp import prepare_record_add_or_update + + records_to_import, record_exists, _ = prepare_record_add_or_update( + False, False, self._params("other"), [self._record()], + ) + + assert len(records_to_import) == 1 + assert record_exists == [] + assert records_to_import[0].uid != "existing_uid" + + def test_matching_record_in_same_folder_is_skipped(self): + from keepercommander.importer.imp_exp import prepare_record_add_or_update + + records_to_import, record_exists, _ = prepare_record_add_or_update( + False, False, self._params("target"), [self._record()], + ) + + assert records_to_import == [] + assert len(record_exists) == 1 + assert record_exists[0].uid == "existing_uid" + + class TestListSafesDetailed: """Tests for _list_safes_detailed.""" @@ -3330,12 +3832,12 @@ def test_missing_platform_id_uses_fallback(self): assert result is not None assert result["type"] == "pamMachine" - def test_palo_alto_maps_to_ssh(self): + def test_generic_network_account_maps_to_ssh(self): mapper = AccountMapper() account = { - "id": "25_28", "name": "Network Device-PaloAltoNetworks-10.8.8.8-palo", - "platformId": "PaloAltoNetworks", - "address": "10.8.8.8", "userName": "palo", + "id": "network-account-1", "name": "network-device-sample", + "platformId": "UnixSSH", + "address": "192.0.2.10", "userName": "network-user", } result = mapper.map_account(account, "pass") assert result["type"] == "pamMachine" @@ -3568,11 +4070,11 @@ def test_default_without_policy(self): "secretManagement": {"automaticManagementEnabled": True}, "createdTime": 1670950940}, - # Network device — PaloAlto - {"id": "25_28", - "name": "Network Device-PaloAltoNetworks-10.8.8.8-palo", - "platformId": "PaloAltoNetworks", "safeName": "Test", - "address": "10.8.8.8", "userName": "palo", "secretType": "password", + # Generic network device account + {"id": "network-account-1", + "name": "network-device-sample", + "platformId": "UnixSSH", "safeName": "Test", + "address": "192.0.2.10", "userName": "network-user", "secretType": "password", "platformAccountProperties": {}, "secretManagement": {"automaticManagementEnabled": True}, "createdTime": 1692833795}, @@ -3810,11 +4312,11 @@ def test_cpm_failure_annotated(self): assert "FAILURE" in notes assert u["rotation_settings"]["enabled"] == "off" - def test_palo_alto_network_device(self): + def test_generic_network_device(self): data, _, _, _ = _run_full_pipeline() - r = self._find_resource(data, "palo") + r = self._find_resource(data, "network-device-sample") assert r["type"] == "pamMachine" - assert r["host"] == "10.8.8.8" + assert r["host"] == "192.0.2.10" assert r["pam_settings"]["connection"]["protocol"] == "ssh" def test_empty_platform_id_handled(self): From 6a47744a2a5badce2b96766efd7fa22646788943 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Wed, 2 Sep 2026 09:19:22 -0700 Subject: [PATCH 12/12] Release 18.1.4 --- keepercommander/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index 08e4620b5..d0a79fe14 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: commander@keepersecurity.com # -__version__ = '18.1.3' +__version__ = '18.1.4'