Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7aab005
Updated krouter endpoint changes for GitHub USS configuration. Regen…
mfordkeeper Aug 14, 2026
b677e08
epm scim: use objectSid for Active Directory groups
sk-keeper Aug 17, 2026
c65d84c
KC-1407: Add terraform-app-setup with Min-Commander-Version gate (#2302)
amangalampalli-ks Aug 18, 2026
8662850
Fix pykeepass bug when using quotes in record titles
lthievenaz-keeper Aug 19, 2026
f6f3ceb
KC-1403: Fix NSF record update "This object no longer exists" after P…
sshrushanth-ks Aug 19, 2026
8343f80
KC-1385, KC-1405: Add Range Support to CyberArk PAM Import and --targ…
pvagare-ks Aug 19, 2026
ab44c55
updated changes
pvagare-ks Aug 19, 2026
ac47719
KC-1412: Fix email-config title lookup so shared-in records cannot re…
sshrushanth-ks Aug 20, 2026
463bff1
KC-1417, KC-1418: Handle NSF record fields the same way as classic ad…
amangalampalli-ks Aug 20, 2026
71aa40c
Add support for export --ownled-only flag
lthievenaz-keeper Aug 17, 2026
4a8ad2b
Add support for only exporting owned records
lthievenaz-keeper Aug 17, 2026
732fd31
KC-1419: Fix pam project export by discovering from project folders (…
amangalampalli-ks Aug 21, 2026
cf0694d
NSF fixes for pam project import/extend: KSM app grants and root fold…
idimov-keeper Aug 21, 2026
6afa78c
Release 18.1.2
sk-keeper Aug 21, 2026
c285ca2
added nsf support for cyberark import
pvagare-ks Jul 17, 2026
441ad7c
fix merge conflict
pvagare-ks Jul 20, 2026
50c80cf
updated record loader
pvagare-ks Jul 20, 2026
7627b04
fix cyberark cleanup for nsf
pvagare-ks Jul 20, 2026
ffb16cb
resolve review comments
pvagare-ks Jul 27, 2026
02aaa07
fix tests
pvagare-ks Jul 27, 2026
b766abc
added login mapping
pvagare-ks Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions docs/cyberark-pam-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,13 @@ Keeper Vault/
| PaloAltoNetworks, CiscoIOS, CiscoASA, JuniperJunos, F5BigIP, CheckPointGAIA | pamMachine | ssh | 22 |
| CyberArk (internal) | pamMachine | ssh | 22 |
| BusinessWebsite | login | — | — |
| (empty platformId) | pamMachine | ssh | 22 |
| (unknown platformId) | pamMachine | ssh | 22 |
| Custom/renamed platform resolved by CyberArk metadata or keyword matching | inferred PAM type | inferred | inferred |
| Empty/unknown platform with no metadata or keyword match | login | — | — |

For an unresolved platform, fields not already represented by the login's title,
login, password, URL, or CyberArk notes are preserved as text custom fields.
Nested account metadata is flattened using dotted field names. Raw secret-bearing
payload keys are excluded from custom fields.

### Field Mapping

Expand Down Expand Up @@ -167,6 +172,9 @@ pam project cyberark-import pvwa.company.com --dry-run --output import.json --in
# Filter specific safes
pam project cyberark-import pvwa.company.com --safes "Production,Staging" --exclude-safes "Archive*"

# Import into Nested Share Folders (folders, records, rotation, PAM config)
pam project cyberark-import pvwa.company.com --name "CyberArk Migration" --gateway "My Gateway" --nsf

# Extend existing project
pam project cyberark-import pvwa.company.com --config <pam-config-uid>

Expand Down Expand Up @@ -196,7 +204,8 @@ pam project cyberark-cleanup --name "CyberArk Migration" --dry-run
| `--name`, `-n` | Project name |
| `--config`, `-c` | Extend existing PAM config UID |
| `--gateway`, `-g` | Gateway name or UID |
| `--folder-mode` | flat, exact, ksm (default) |
| `--folder-mode` | flat, exact, ksm, safe (default: safe) |
| `--nsf` | Create project folders/records/PAM config in Nested Share Folders |
| `--safes` | Include only these safes (comma/glob) |
| `--exclude-safes` | Exclude safes (comma/glob) |
| `--list-safes` | List safes and exit |
Expand Down Expand Up @@ -270,5 +279,3 @@ Before building the import JSON, the importer warns about:
- Rate limit handling: automatic retry on HTTP 429 with exponential backoff
- Pagination cap: MAX_FETCH_RECORDS (50,000) prevents OOM attacks
---


2 changes: 1 addition & 1 deletion keepercommander/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
# Contact: commander@keepersecurity.com
#

__version__ = '18.1.1'
__version__ = '18.1.2'
3 changes: 2 additions & 1 deletion keepercommander/command_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@
# Service Mode REST API
'Service Mode REST API': {
'service-create', 'service-add-config', 'service-start', 'service-stop', 'service-status',
'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup',
'service-config-add', 'service-docker-setup', 'terraform-app-setup',
'slack-app-setup', 'teams-app-setup',
'sailpoint-app-setup', 'gchat-app-setup'
},

Expand Down
21 changes: 16 additions & 5 deletions keepercommander/commands/discoveryrotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2566,6 +2566,7 @@ class PamConfigurationEditMixin(RecordEditMixin):
PAM_CONFIG_RECORD_TYPES = frozenset({
'pamAwsConfiguration', 'pamAzureConfiguration', 'pamGcpConfiguration',
'pamDomainConfiguration', 'pamNetworkConfiguration', 'pamOciConfiguration',
'pamGitHubConfiguration',
})
PAM_RESOURCE_RECORD_TYPES = frozenset({
'pamDatabase', 'pamDirectory', 'pamMachine', 'pamRemoteBrowser',
Expand Down Expand Up @@ -2758,7 +2759,7 @@ def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, va
extra_properties.append(f'text.pamGitHubId={github_id}')
personal_access_token = kwargs.get('personal_access_token')
if personal_access_token:
extra_properties.append(f'secret.personalAccessToken={personal_access_token}')
extra_properties.append(f'secret.pamGitHubPersonalAccessToken={personal_access_token}')
github_base_url = kwargs.get('github_base_url')
if github_base_url:
extra_properties.append(f'text.pamGitHubBaseUrl={github_base_url}')
Expand Down Expand Up @@ -2870,7 +2871,8 @@ def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, va
# Fields that the backend previously required but now treats as optional for pamAzureConfiguration.
AZURE_OPTIONAL_FIELDS = frozenset({'clientId', 'clientSecret'})

def verify_required(self, record): # type: (vault.TypedRecord) -> None
def verify_required(self, record, command=''): # type: (vault.TypedRecord, str) -> None
missing_fields = []
for field in record.fields:
if field.required:
if len(field.value) == 0:
Expand All @@ -2882,10 +2884,15 @@ def verify_required(self, record): # type: (vault.TypedRecord) -> None
and field.label in self.AZURE_OPTIONAL_FIELDS):
pass
else:
self.warnings.append(f'Empty required field: "{field.get_field_name()}"')
missing_fields.append(field.get_field_name())
for custom in record.custom:
if custom.required:
custom.required = False
if missing_fields:
if len(missing_fields) == 1:
raise CommandError(command, f'Empty required field: "{missing_fields[0]}"')
fields_text = ', '.join(f'"{x}"' for x in missing_fields)
raise CommandError(command, f'Empty required fields: {fields_text}')


class PAMConfigurationNewCommand(Command, PamConfigurationEditMixin):
Expand Down Expand Up @@ -2986,7 +2993,7 @@ def execute(self, params, **kwargs):
if not gateway_uid:
logging.warning(f'Gateway "{gw_name}" not found.')

self.verify_required(record)
self.verify_required(record, command='pam-config-new')

create_pam_configuration_in_folder(params, record, shared_folder_uid, command='pam-config-new')

Expand Down Expand Up @@ -3119,7 +3126,7 @@ def execute(self, params, **kwargs):
orig_admin_cred_ref = value.get('adminCredentialRef') or ''

self.parse_properties(params, configuration, config_edit=True, **kwargs)
self.verify_required(configuration)
self.verify_required(configuration, command='pam-config-edit')

update_pam_record(params, configuration, command='pam-config-edit')

Expand Down Expand Up @@ -3812,6 +3819,10 @@ def execute(self, params, **kwargs):
# Find and load email config to validate provider and dependencies
try:
config_uid = find_email_config_record(params, self.email_config)
if not config_uid:
raise CommandError(
'pam action rotate',
f'Email configuration "{self.email_config}" not found')
email_config_obj = load_email_config_from_record(params, config_uid)

# Check if required dependencies are installed for this provider
Expand Down
14 changes: 10 additions & 4 deletions keepercommander/commands/discoveryrotation_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,8 @@ def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, va
if extra_properties:
self.assign_typed_fields(record, [RecordEditMixin.parse_field(x) for x in extra_properties])

def verify_required(self, record): # type: (vault.TypedRecord) -> None
def verify_required(self, record, command=''): # type: (vault.TypedRecord, str) -> None
missing_fields = []
for field in record.fields:
if field.required:
if len(field.value) == 0:
Expand All @@ -981,10 +982,15 @@ def verify_required(self, record): # type: (vault.TypedRecord) -> None
'tz': 'Etc/UTC',
}]
else:
self.warnings.append(f'Empty required field: "{field.get_field_name()}"')
missing_fields.append(field.get_field_name())
for custom in record.custom:
if custom.required:
custom.required = False
if missing_fields:
if len(missing_fields) == 1:
raise CommandError(command, f'Empty required field: "{missing_fields[0]}"')
fields_text = ', '.join(f'"{x}"' for x in missing_fields)
raise CommandError(command, f'Empty required fields: {fields_text}')


class PAMConfigurationNewCommand(Command, PamConfigurationEditMixin):
Expand Down Expand Up @@ -1037,7 +1043,7 @@ def execute(self, params, **kwargs):
if not shared_folder_uid:
raise CommandError('pam-config-new', '--shared_folder parameter is required to create a PAM configuration')

self.verify_required(record)
self.verify_required(record, command='pam-config-new')

pam_configuration_create_record_v6(params, record, shared_folder_uid)

Expand Down Expand Up @@ -1126,7 +1132,7 @@ def execute(self, params, **kwargs):
orig_shared_folder_uid = value.get('folderUid') or ''

self.parse_properties(params, configuration, **kwargs)
self.verify_required(configuration)
self.verify_required(configuration, command='pam-config-edit')

record_management.update_record(params, configuration)

Expand Down
75 changes: 59 additions & 16 deletions keepercommander/commands/email_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,28 @@
# Helper Functions
# =============================================================================

def _is_owned_email_config_record(params: KeeperParams, record_uid: str) -> bool:
"""True if the current account owns this record.

Uses ``record_owner_cache`` first, then ``meta_data_cache`` from
recordMetaData (server-asserted). Unknown ownership fails closed.
"""
owner = (params.record_owner_cache or {}).get(record_uid)
if owner and owner.owner:
return True
meta = (getattr(params, 'meta_data_cache', None) or {}).get(record_uid) or {}
return bool(meta.get('owner'))


def find_email_config_record(params: KeeperParams, name: str) -> Optional[str]:
"""
Find email config record by name.
Find an owned email config record by name.

Only records owned by the current account are eligible. Shared or
non-owned records (even with a matching title and ``__email_config__``
marker) are ignored so SMTP/provider settings cannot be supplied by
another user. A missing or unknown ownership entry is treated the same
way (not owned by the current account, or ownership unknown).

Args:
params: KeeperParams session
Expand All @@ -169,15 +188,26 @@ def find_email_config_record(params: KeeperParams, name: str) -> Optional[str]:
if record.record_type != 'login':
continue

# Check if this is an email config by looking for custom field
try:
record_dict = vault_extensions.extract_typed_record_data(record)
custom_fields = record_dict.get('custom', [])
for field in custom_fields:
if field.get('type') == 'text' and field.get('label') == '__email_config__':
if record.title == name:
return record_uid
except:
is_email_config = any(
field.get('type') == 'text' and field.get('label') == '__email_config__'
for field in custom_fields
)
if not is_email_config:
continue
if record.title != name:
continue
if not _is_owned_email_config_record(params, record_uid):
logging.warning(
'Ignoring email configuration "%s" (%s): '
'not owned by the current account (or ownership unknown)',
name, record_uid)
continue
return record_uid
except Exception as e:
logging.debug('Skipping record %s: %s', record_uid, e)
continue

return None
Expand Down Expand Up @@ -591,13 +621,17 @@ def execute(self, params: KeeperParams, **kwargs):


class EmailConfigListCommand(Command):
"""List all email configurations."""
"""List owned email configurations."""

def get_parser(self):
return email_config_list_parser

def execute(self, params: KeeperParams, **kwargs):
"""Execute email-config list command."""
"""Execute email-config list command.

Only configurations owned by the current account are listed, matching
``find_email_config_record`` eligibility used by test/delete/--send-email.
"""
configs = []

# Find all email config records
Expand Down Expand Up @@ -629,13 +663,22 @@ def execute(self, params: KeeperParams, **kwargs):
if values:
from_address = values[0]

if is_email_config:
configs.append({
'name': record.title,
'record_uid': record_uid,
'provider': provider or 'unknown',
'from_address': from_address or ''
})
if not is_email_config:
continue

if not _is_owned_email_config_record(params, record_uid):
logging.debug(
'Skipping email configuration "%s" (%s) in list: '
'not owned by the current account (or ownership unknown)',
record.title, record_uid)
continue

configs.append({
'name': record.title,
'record_uid': record_uid,
'provider': provider or 'unknown',
'from_address': from_address or ''
})
except Exception as e:
logging.debug(f'Error loading record {record_uid}: {e}')
continue
Expand Down
28 changes: 18 additions & 10 deletions keepercommander/commands/nested_share_folder/display_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ def execute(self, params, **kwargs):
class NestedShareGetCommand(Command):
"""Show details of a Nested Share Record or folder."""

_MASKED_TYPES = frozenset({'password', 'secret', 'pinCode', 'pin_code'})
_MASKED_TYPES = frozenset({
'password', 'secret', 'pinCode', 'pin_code', 'oneTimeCode', 'otp',
'note', 'json',
})

def get_parser(self):
return nested_share_get_parser
Expand Down Expand Up @@ -196,10 +199,19 @@ def _record_detail(self, params, record_uid, verbose, unmask):
if url_val:
print('{0:>20s}: {1:<20s}'.format('URL', url_val))

shown_types = {'login', 'password', 'url'}
for f in meta['fields']:
self._print_typed_fields(meta['fields'], unmask, skip_types={'login', 'password', 'url'})
self._print_typed_fields(meta.get('custom') or [], unmask)

if meta['notes']:
for i, line in enumerate(meta['notes'].split('\n')):
print('{0:>21s} {1}'.format('Notes:' if i == 0 else '', line.strip()))

self._print_record_permissions(params, record_uid, verbose)

def _print_typed_fields(self, fields, unmask, skip_types=()):
for f in fields or []:
ftype = f.get('type', '')
if ftype in shown_types:
if ftype in skip_types:
continue
label = f.get('label') or ftype.replace('_', ' ').title()
values = f.get('value', [])
Expand All @@ -216,12 +228,6 @@ def _record_detail(self, params, record_uid, verbose, unmask):
dval = str(val)
print('{0:>20s}: {1:<s}'.format(label, dval))

if meta['notes']:
for i, line in enumerate(meta['notes'].split('\n')):
print('{0:>21s} {1}'.format('Notes:' if i == 0 else '', line.strip()))

self._print_record_permissions(params, record_uid, verbose)

@staticmethod
def _extract_field_value(fields, field_type):
"""Extract the first non-empty value for a given field type."""
Expand All @@ -247,6 +253,8 @@ def _record_json(self, params, record_uid, verbose, _unmask=False, include_dag=F
ro['folder'] = meta['folder_location']
if meta['fields']:
ro['fields'] = meta['fields']
if meta.get('custom'):
ro['custom'] = meta['custom']
if meta['notes']:
ro['notes'] = meta['notes']

Expand Down
11 changes: 9 additions & 2 deletions keepercommander/commands/nested_share_folder/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,14 +705,15 @@ def load_record_metadata(params, record_uid):
"""Load record metadata from cache, falling back to the v3 details API.

Returns a dict with keys:
``title``, ``type``, ``fields``, ``notes``,
``title``, ``type``, ``fields``, ``custom``, ``notes``,
``revision``, ``version``, ``folder_location``
"""
from ... import nested_share_folder as _nsf

title = record_uid
rec_type = ''
fields = []
custom = []
notes = ''
revision = 0
version = 0
Expand All @@ -724,7 +725,12 @@ def load_record_metadata(params, record_uid):
dj = data_obj['data_json']
title = dj.get('title', record_uid)
rec_type = dj.get('type', '')
fields = dj.get('fields', [])
fields = dj.get('fields') or []
custom = dj.get('custom') or []
if not isinstance(fields, list):
fields = []
if not isinstance(custom, list):
custom = []
notes = dj.get('notes', '') or ''

nsf_records = getattr(params, 'nested_share_records', {})
Expand All @@ -749,6 +755,7 @@ def load_record_metadata(params, record_uid):
'title': title,
'type': rec_type,
'fields': fields,
'custom': custom,
'notes': notes,
'revision': revision,
'version': version,
Expand Down
Loading