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'
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/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py
index c8c3bd13b..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, \
@@ -75,13 +76,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 +462,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(),
@@ -3128,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/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
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/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_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_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/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/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..b7c9cc23e 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,20 +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 = ""
- 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"
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,
@@ -159,20 +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 = ""
- 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"
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,
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/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/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/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/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
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 c66a2bd9f..83aa9815b 100644
--- a/keepercommander/importer/cyberark/cyberark.py
+++ b/keepercommander/importer/cyberark/cyberark.py
@@ -28,6 +28,7 @@
BaseDownloadMembership,
BaseImporter,
Folder,
+ PathDelimiter,
Permission,
Record,
RecordField,
@@ -265,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",
@@ -286,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):
@@ -301,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``.
@@ -470,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
@@ -770,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(
@@ -942,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)
@@ -996,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,
@@ -1023,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
@@ -1033,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
@@ -1065,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"))
@@ -1091,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;
@@ -1114,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:
@@ -1138,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,
@@ -1215,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"))
@@ -1300,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
@@ -1392,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
@@ -1454,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
@@ -1493,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 "
@@ -1501,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:
@@ -1536,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/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/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/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.
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/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/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/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/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/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):
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 b68666192..edbe9d3ee 100644
--- a/unit-tests/pam/test_pam_debug_nsf.py
+++ b/unit-tests/pam/test_pam_debug_nsf.py
@@ -9,11 +9,13 @@
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
+# 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)
@@ -74,47 +76,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/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."""
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_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,
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'))
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_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()
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()