diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 19b265193..91ffad491 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -159,38 +159,31 @@ process_ksm_config() { # DEVICE SETUP AND AUTHENTICATION FUNCTIONS # ============================================================================= -# Setup device registration and persistent login +# Setup device registration and persistent login. +# All three steps run in a single Commander process so login + vault sync +# only happens once instead of three times. setup_device() { local user="$1" local password="$2" local server="$3" - - # Step 1: Register device - log "Registering device..." - if ! python3 keeper.py --user "${user}" --password "${password}" \ - --server "${server}" this-device register; then - log "ERROR: Device registration failed" - exit 1 - fi - - # Step 2: Enable persistent login - log "Enabling persistent login..." - if ! python3 keeper.py --user "${user}" --password "${password}" \ - --server "${server}" this-device persistent-login on; then - log "ERROR: Persistent login setup failed" - exit 1 - fi - # Step 3: Set timeout - log "Setting device logout timeout to 30 Days..." + log "Running device setup (register, persistent login, timeout)..." + local setup_script + setup_script=$(mktemp /tmp/keeper_setup_XXXXXX.cmd) + cat > "${setup_script}" < /dev/null; then - log "ERROR: Timeout setup failed" + --server "${server}" "${setup_script}"; then + log "ERROR: Device setup failed" + rm -f "${setup_script}" exit 1 fi - - log "Device Logout Timeout set successfully" + + rm -f "${setup_script}" log "Device setup completed successfully" } diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 031665ac2..a6eb2c399 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -17,8 +17,8 @@ import re import time from datetime import datetime +from typing import Dict, Optional, Any, Set, List from urllib.parse import urlparse, urlunparse -from typing import Optional, List import requests from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -29,6 +29,7 @@ from .folder import FolderMoveCommand from .ksm import KSMCommand from .pam import gateway_helper, router_helper +from .pam.recording_commands import PAMGetRecordingsForUsersCommand, PAMDownloadRecordingsCommand from .pam.config_facades import PamConfigurationRecordFacade from .pam.config_helper import configuration_controller_get, \ pam_configurations_get_all, pam_configuration_remove, \ @@ -93,6 +94,7 @@ from .pam_saas.config import PAMActionSaasConfigCommand from .pam_saas.update import PAMActionSaasUpdateCommand from .tunnel_and_connections import PAMTunnelCommand, PAMConnectionCommand, PAMRbiCommand, PAMSplitCommand +from .pam.cnapp_commands import PAMCnappCommand from .universalsecretsync import ( PAMUniversalSyncConfigCommand, PAMUniversalSyncRunCommand @@ -287,8 +289,11 @@ def __init__(self): self.register_command('workflow', PAMWorkflowCommand(), 'Manage PAM Workflows', 'w') self.register_command('access', PAMPrivilegedAccessCommand(), 'Manage privileged cloud access operations', 'ac') + self.register_command('cnapp', PAMCnappCommand(), + 'Manage Cloud-Native Application Protection Platform integration', 'cn') self.register_command('universal-sync-config', PAMUniversalSyncConfigCommand(), 'Manage Universal Sync Configurations', 'usc') self.register_command('universal-sync-run', PAMUniversalSyncRunCommand(), 'Run Universal Sync', 'usr') + self.register_command('recording', PAMRecordingCommand(), 'Manage PAM Session Recordings', 'rec') class PAMGatewayCommand(GroupCommand): @@ -328,6 +333,17 @@ def __init__(self): self.default_verb = 'list' +class PAMRecordingCommand(GroupCommand): + + def __init__(self): + super(PAMRecordingCommand, self).__init__() + self.register_command('list-by-user', PAMGetRecordingsForUsersCommand(), + 'List session recordings for one or more users', 'lbu') + self.register_command('download', PAMDownloadRecordingsCommand(), + 'Download recording files to a local directory', 'dl') + self.default_verb = 'list-by-user' + + class PAMDiscoveryCommand(GroupCommand): def __init__(self): diff --git a/keepercommander/commands/nested_share_folder/helpers.py b/keepercommander/commands/nested_share_folder/helpers.py index 5c990d04a..57ab381e5 100644 --- a/keepercommander/commands/nested_share_folder/helpers.py +++ b/keepercommander/commands/nested_share_folder/helpers.py @@ -309,12 +309,13 @@ def walk(fuid): # Expiration parsing # ═══════════════════════════════════════════════════════════════════════════ -def validate_share_expiration_timestamp(expiration_ms, cmd_name): +def validate_share_expiration_timestamp(expiration_ms, cmd_name, *, now_ms=None): """Reject finite expirations that are less than one minute.""" if expiration_ms is None or expiration_ms == -1: return - min_allowed = int(datetime.datetime.now(timezone.utc).timestamp() * 1000) + MIN_SHARE_EXPIRATION_MS - if expiration_ms < min_allowed: + if now_ms is None: + now_ms = int(datetime.datetime.now(timezone.utc).timestamp() * 1000) + if expiration_ms < now_ms + MIN_SHARE_EXPIRATION_MS: raise CommandError( cmd_name, 'Share expiration must be at least 1 minute.', diff --git a/keepercommander/commands/pam/cnapp_commands.py b/keepercommander/commands/pam/cnapp_commands.py new file mode 100644 index 000000000..bce1d5d16 --- /dev/null +++ b/keepercommander/commands/pam/cnapp_commands.py @@ -0,0 +1,650 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bytes|None + """Encrypter keys are typically `openssl rand -base64 32`. Try standard base64 first, + then base64url (legacy notes). 32 bytes only (AES-256) — anything else is rejected so we + don't pass garbage to AES-GCM.""" + if not raw or not isinstance(raw, str): + return None + candidate = raw.strip() + for decoder in (base64.b64decode, base64.urlsafe_b64decode): + try: + padding = '=' * (-len(candidate) % 4) + data = decoder(candidate + padding) + except (binascii.Error, ValueError): + continue + if len(data) == 32: + return data + return None + + +def _load_encrypter_key(params, config_record_uid): + """Resolve the AES key from the CNAPP encrypter vault record. Returns None when the + record can't be loaded or doesn't carry a recognizable key — callers should fall back + to showing the encrypted payload as-is.""" + if not config_record_uid: + return None + try: + record = vault.KeeperRecord.load(params, config_record_uid) + except Exception as e: + logger.debug('CNAPP: failed to load encrypter record %s: %s', config_record_uid, e) + return None + if not isinstance(record, vault.TypedRecord): + return None + # Match vault/cloudSecurityUtils.ts: prefer `secret` then `note` labeled "Encryption Key", + # then the first unlabeled `note` field only when no labeled key field exists. + labeled_raws = [] + secret_field = record.get_typed_field('secret', CNAPP_ENCRYPTION_KEY_LABEL) + if secret_field and secret_field.value: + labeled_raws.append(secret_field.value[0]) + note_labeled = record.get_typed_field('note', CNAPP_ENCRYPTION_KEY_LABEL) + if note_labeled and note_labeled.value: + labeled_raws.append(note_labeled.value[0]) + for raw in labeled_raws: + key = _decode_aes_key(raw) + if key: + return key + if labeled_raws: + logger.warning( + 'CNAPP: "%s" field is present on encrypter record %s but is not a valid AES-256 key; ' + 'not using other note fields.', + CNAPP_ENCRYPTION_KEY_LABEL, config_record_uid, + ) + return None + first_note = record.get_typed_field('note') + if first_note and first_note.value: + key = _decode_aes_key(first_note.value[0]) + if key: + return key + return None + + +def _decrypt_cnapp_payload(payload_bytes, key): + """Decrypt a CNAPP queue payload using the Encrypter's AES-256-GCM key. + + Wire format (matches vault's `decryptCnappQueueItem` in cloudSecurityUtils.ts): + payload_bytes (proto field, base64url-decoded by us) is UTF-8 base64url text + of a JSON envelope `{"encrypted_payload":"","alg":"AES-256-GCM","version":"1"}`. + encrypted_payload base64url-decodes to `nonce(12) || ciphertext || tag(16)` — + the standard layout AESGCM.decrypt expects. + + Returns a dict on success; raises Exception on bad envelope / wrong key / bad alg + so the caller can surface a meaningful warning.""" + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + envelope_b64 = payload_bytes.decode('utf-8') + envelope_json = base64.urlsafe_b64decode(envelope_b64 + '=' * (-len(envelope_b64) % 4)) + envelope = json.loads(envelope_json) + alg = envelope.get('alg') + if alg != 'AES-256-GCM': + raise ValueError(f"Unsupported or missing CNAPP payload algorithm: {alg!r}") + ciphertext_b64 = envelope.get('encrypted_payload') or '' + ciphertext = base64.urlsafe_b64decode(ciphertext_b64 + '=' * (-len(ciphertext_b64) % 4)) + if len(ciphertext) < 12 + 16: + raise ValueError('CNAPP ciphertext shorter than nonce+tag — corrupt payload') + nonce, body = ciphertext[:12], ciphertext[12:] + plaintext = AESGCM(key).decrypt(nonce, body, None) + return json.loads(plaintext.decode('utf-8')) + + +def _resolve_status(value, allow_all=True): # type: (str|int|None, bool) -> int + """Accept either the numeric status id or its case-insensitive name.""" + if value is None or value == '': + status_id = 0 + elif isinstance(value, int): + status_id = value + else: + s = str(value).strip().lower() + if s.lstrip('-').isdigit(): + status_id = int(s) + elif s in QUEUE_STATUS_BY_NAME: + status_id = QUEUE_STATUS_BY_NAME[s] + else: + raise CommandError( + 'pam cnapp', + f"Unknown status '{value}'. Valid: {', '.join(QUEUE_STATUS_BY_NAME)} or 0 for ALL.", + ) + if status_id == 0: + if allow_all: + return 0 + raise CommandError('pam cnapp', 'A specific status is required (cannot be 0/ALL).') + if status_id not in QUEUE_STATUS_BY_ID: + raise CommandError( + 'pam cnapp', + f"Unknown status id {status_id}. Valid ids: {', '.join(str(i) for i in sorted(QUEUE_STATUS_BY_ID))}.", + ) + return status_id + + +def _format_timestamp(epoch_ms): + """krouter emits epoch-millis for received/resolved timestamps; render as UTC ISO.""" + if not epoch_ms: + return '' + try: + return datetime.fromtimestamp(int(epoch_ms) / 1000, tz=timezone.utc).isoformat() + except (ValueError, TypeError, OSError): + return f'' + + +class PAMCnappCommand(GroupCommand): + """Root for the `pam cnapp ...` command tree.""" + + def __init__(self): + super(PAMCnappCommand, self).__init__() + self.register_command('config', PAMCnappConfigCommand(), + 'Manage CNAPP provider configuration', 'c') + self.register_command('queue', PAMCnappQueueCommand(), + 'Manage CNAPP issue queue', 'q') + self.default_verb = 'queue' + + +# --------------------------------------------------------------------------- +# Configuration sub-tree +# --------------------------------------------------------------------------- + +class PAMCnappConfigCommand(GroupCommand): + + def __init__(self): + super(PAMCnappConfigCommand, self).__init__() + self.register_command('set', PAMCnappConfigSetCommand(), + 'Create or update CNAPP provider configuration') + self.register_command('test', PAMCnappConfigTestCommand(), + 'Validate CNAPP provider credentials without saving') + self.register_command('test-encrypter', PAMCnappConfigTestEncrypterCommand(), + 'Health-check the customer Encrypter at /health') + self.register_command('read', PAMCnappConfigReadCommand(), + 'Read the persisted CNAPP configuration for a network') + self.register_command('delete', PAMCnappConfigDeleteCommand(), + 'Delete the CNAPP configuration on a network') + self.default_verb = '' + + +def _add_configuration_args(parser, require_secret=True, optional_secret_on_set=False): + parser.add_argument('--network-uid', '-n', required=True, dest='network_uid', + help='Network record UID (base64url).') + parser.add_argument('--provider', '-p', required=True, dest='provider', + help='CNAPP provider keyword: wiz (case-insensitive).') + parser.add_argument('--client-id', required=True, dest='client_id', + help='Provider API client ID / app ID.') + if optional_secret_on_set: + parser.add_argument('--client-secret', required=False, default=None, dest='client_secret', + help='Provider API client secret. Omit on `config set` to keep the existing secret.') + else: + parser.add_argument('--client-secret', required=require_secret, dest='client_secret', + help='Provider API client secret.') + parser.add_argument('--api-endpoint', required=True, dest='api_endpoint_url', + help='Provider API endpoint URL (e.g. https://api.us1.app.wiz.io/graphql).') + parser.add_argument('--auth-endpoint', required=True, dest='auth_endpoint_url', + help='Provider OAuth2 token endpoint URL (e.g. https://auth.app.wiz.io/oauth/token).') + + +class PAMCnappConfigSetCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp config set') + _add_configuration_args(parser, optional_secret_on_set=True) + parser.add_argument('--config-record', required=True, dest='cnapp_config_record_uid', + help='UID of the vault record holding the Encrypter URL + key.') + + def get_parser(self): + return PAMCnappConfigSetCommand.parser + + def execute(self, params, **kwargs): + provider = cnapp_helper.provider_from_name(kwargs.get('provider')) + response = cnapp_helper.set_cnapp_configuration( + params, + network_uid=kwargs.get('network_uid'), + provider=provider, + client_id=kwargs.get('client_id'), + client_secret='' if kwargs.get('client_secret') is None else kwargs.get('client_secret'), + api_endpoint_url=kwargs.get('api_endpoint_url'), + cnapp_config_record_uid=kwargs.get('cnapp_config_record_uid'), + auth_endpoint_url=kwargs.get('auth_endpoint_url'), + ) + print(f"{bcolors.OKGREEN}CNAPP configuration saved.{bcolors.ENDC}") + if response is not None: + _print_configuration(response) + return None + + +class PAMCnappConfigTestCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp config test') + _add_configuration_args(parser, require_secret=True) + + def get_parser(self): + return PAMCnappConfigTestCommand.parser + + def execute(self, params, **kwargs): + provider = cnapp_helper.provider_from_name(kwargs.get('provider')) + cnapp_helper.test_cnapp_configuration( + params, + network_uid=kwargs.get('network_uid'), + provider=provider, + client_id=kwargs.get('client_id'), + client_secret=kwargs.get('client_secret'), + api_endpoint_url=kwargs.get('api_endpoint_url'), + auth_endpoint_url=kwargs.get('auth_endpoint_url'), + ) + print(f"{bcolors.OKGREEN}CNAPP credentials validated successfully.{bcolors.ENDC}") + + +class PAMCnappConfigTestEncrypterCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp config test-encrypter') + parser.add_argument('--url', '-u', required=True, dest='url', + help='Base URL of the Encrypter. krouter probes /health.') + + def get_parser(self): + return PAMCnappConfigTestEncrypterCommand.parser + + def execute(self, params, **kwargs): + cnapp_helper.test_cnapp_encrypter(params, url_base_encrypter=kwargs.get('url')) + print(f"{bcolors.OKGREEN}Encrypter is reachable.{bcolors.ENDC}") + + +class PAMCnappConfigReadCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp config read') + parser.add_argument('--network-uid', '-n', required=True, dest='network_uid', + help='Network record UID (base64url).') + parser.add_argument('--provider', '-p', required=True, dest='provider', + help='CNAPP provider keyword: wiz.') + parser.add_argument('--format', dest='format', choices=['table', 'json'], default='table', + help='Output format.') + + def get_parser(self): + return PAMCnappConfigReadCommand.parser + + def execute(self, params, **kwargs): + provider = cnapp_helper.provider_from_name(kwargs.get('provider')) + response = cnapp_helper.read_cnapp_configuration( + params, + network_uid=kwargs.get('network_uid'), + provider=provider, + ) + if response is None: + logger.warning('No CNAPP configuration returned.') + return None + if kwargs.get('format') == 'json': + print(json.dumps(_configuration_to_dict(response), indent=2)) + return None + _print_configuration(response) + return None + + +class PAMCnappConfigDeleteCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp config delete') + parser.add_argument('--network-uid', '-n', required=True, dest='network_uid', + help='Network record UID (base64url).') + + def get_parser(self): + return PAMCnappConfigDeleteCommand.parser + + def execute(self, params, **kwargs): + cnapp_helper.delete_cnapp_configuration(params, network_uid=kwargs.get('network_uid')) + print(f"{bcolors.OKGREEN}CNAPP configuration deleted.{bcolors.ENDC}") + + +# --------------------------------------------------------------------------- +# Queue sub-tree +# --------------------------------------------------------------------------- + +class PAMCnappQueueCommand(GroupCommand): + + def __init__(self): + super(PAMCnappQueueCommand, self).__init__() + self.register_command('list', PAMCnappQueueListCommand(), 'List CNAPP queue items', 'l') + self.register_command('associate', PAMCnappQueueAssociateCommand(), + 'Attach a vault record to a queue item', 'a') + self.register_command('remediate', PAMCnappQueueRemediateCommand(), + 'Trigger a remediation action against the gateway', 'r') + self.register_command('set-status', PAMCnappQueueSetStatusCommand(), + 'Update local queue item status (notifies provider best-effort)', 's') + self.register_command('delete', PAMCnappQueueDeleteCommand(), 'Delete a queue item', 'd') + self.default_verb = 'list' + + +class PAMCnappQueueListCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp queue list') + parser.add_argument('--network-uid', '-n', required=True, dest='network_uid', + help='Network record UID (base64url).') + parser.add_argument('--status', '-s', required=False, dest='status', default=0, + help='Filter by status name or id (pending/in_progress/resolved/failed/cancelled). Default: all.') + parser.add_argument('--provider', '-p', required=False, dest='provider', default='wiz', + help='CNAPP provider keyword for the config lookup (default: wiz).') + parser.add_argument('--config-record', required=False, dest='config_record_uid', + help='Explicit encrypter vault record UID. Overrides the lookup via `config read`.') + parser.add_argument('--no-decrypt', dest='no_decrypt', action='store_true', + help="Skip payload decryption — show the raw encrypted envelope's metadata only.") + parser.add_argument('--format', dest='format', choices=['table', 'json'], default='table', + help='Output format. Table and JSON are mutually exclusive.') + + def get_parser(self): + return PAMCnappQueueListCommand.parser + + def _resolve_encrypter_key(self, params, kwargs): + """Resolve the AES key: --config-record wins; otherwise fetch `config read` to get + the cnappConfigRecordUid and load the encrypter record from the local vault.""" + if kwargs.get('no_decrypt'): + return None, None + config_record_uid = kwargs.get('config_record_uid') + if not config_record_uid: + try: + provider = cnapp_helper.provider_from_name(kwargs.get('provider') or 'wiz') + config = cnapp_helper.read_cnapp_configuration( + params, network_uid=kwargs.get('network_uid'), provider=provider) + except Exception as e: + logger.debug('CNAPP: could not read configuration for decryption: %s', e) + return None, None + if config is None or not config.cnappConfigRecordUid: + return None, None + config_record_uid = bytes_to_base64(config.cnappConfigRecordUid) + key = _load_encrypter_key(params, config_record_uid) + return key, config_record_uid + + @staticmethod + def _decrypted_summary(decrypted): + """Compact human-readable summary for the table column. Mirrors the columns the + vault Cloud Security view shows: severity, title, resource.""" + if not isinstance(decrypted, dict): + return '' + issue = decrypted.get('issue') or {} + resource = decrypted.get('resource') or {} + control = decrypted.get('control') or {} + bits = [] + sev = issue.get('severity') + if sev: + bits.append(str(sev).upper()) + title = control.get('name') or issue.get('id') + if title: + bits.append(str(title)) + resource_name = resource.get('name') or resource.get('id') + if resource_name: + bits.append(f"on {resource_name}") + return ' · '.join(bits) + + def execute(self, params, **kwargs): + status_filter = _resolve_status(kwargs.get('status')) + response = cnapp_helper.list_cnapp_queue( + params, + network_uid=kwargs.get('network_uid'), + status_filter=status_filter, + ) + items = list(response.items) if response is not None else [] + has_more = bool(response.hasMore) if response is not None else False + + encrypter_key, encrypter_uid = self._resolve_encrypter_key(params, kwargs) + decrypted_by_id = {} + decrypt_errors = {} # type: dict[int, str] + if encrypter_key: + for item in items: + if not item.payload: + continue + try: + decrypted_by_id[item.cnappQueueId] = _decrypt_cnapp_payload(item.payload, encrypter_key) + except Exception as e: + decrypt_errors[item.cnappQueueId] = str(e) + + if kwargs.get('format') == 'json': + json_items = [] + for item in items: + d = _queue_item_to_dict(item) + d.pop('payload', None) + if item.cnappQueueId in decrypted_by_id: + d['decryptedPayload'] = decrypted_by_id[item.cnappQueueId] + elif item.cnappQueueId in decrypt_errors: + d['decryptError'] = decrypt_errors[item.cnappQueueId] + json_items.append(d) + payload = {'items': json_items, 'hasMore': has_more} + print(json.dumps(payload, indent=2, default=str)) + return None + + if not items: + print('No CNAPP queue items.') + return None + + if encrypter_key is None and not kwargs.get('no_decrypt'): + print(f"{bcolors.WARNING}No encrypter key resolved — payloads will be shown as 'encrypted'. " + f"Pass --config-record or run after `pam cnapp config read` succeeds.{bcolors.ENDC}") + + headers = ['Queue ID', 'Provider', 'Status', 'Received (UTC)', 'Resolved (UTC)', 'Record UID', 'Issue'] + rows = [] + for item in items: + if item.cnappQueueId in decrypted_by_id: + issue_cell = self._decrypted_summary(decrypted_by_id[item.cnappQueueId]) + elif not item.payload: + issue_cell = '' + elif kwargs.get('no_decrypt'): + issue_cell = '' + else: + issue_cell = f"{bcolors.WARNING}{bcolors.ENDC}" + rows.append([ + item.cnappQueueId, + cnapp_helper.CnappProvider.Name(item.cnappProviderId), + QUEUE_STATUS_BY_ID.get(item.cnappQueueStatusId, str(item.cnappQueueStatusId)), + _format_timestamp(item.receivedAt), + _format_timestamp(item.resolvedAt), + bytes_to_base64(item.recordUid) if item.recordUid else '', + issue_cell, + ]) + dump_report_data(rows, headers, fmt='table', filename='', row_number=False) + for queue_id, msg in decrypt_errors.items(): + print(f"{bcolors.WARNING}Queue item {queue_id}: failed to decrypt payload ({msg}).{bcolors.ENDC}") + if has_more: + print(f"{bcolors.WARNING}More queue items exist (hasMore=true). " + f"CLI paging is not available yet — resolve or delete returned items to see more.{bcolors.ENDC}") + return None + + +class PAMCnappQueueAssociateCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp queue associate') + parser.add_argument('--queue-id', '-q', required=True, type=int, dest='cnapp_queue_id', + help='Queue item ID (from `pam cnapp queue list`).') + parser.add_argument('--record-uid', '-r', required=True, dest='record_uid', + help='Vault record UID to associate (base64url).') + + def get_parser(self): + return PAMCnappQueueAssociateCommand.parser + + def execute(self, params, **kwargs): + cnapp_helper.associate_cnapp_record( + params, + cnapp_queue_id=kwargs.get('cnapp_queue_id'), + record_uid=kwargs.get('record_uid'), + ) + print(f"{bcolors.OKGREEN}Record associated with queue item {kwargs.get('cnapp_queue_id')}.{bcolors.ENDC}") + + +class PAMCnappQueueRemediateCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp queue remediate') + parser.add_argument('--queue-id', '-q', required=True, type=int, dest='cnapp_queue_id', + help='Queue item ID.') + parser.add_argument('--action', '-a', required=True, dest='action_type', + help='Remediation action: rotate_credentials, manage_access, jit_access, remove_standing_privilege.') + parser.add_argument('--provider', '-p', required=False, dest='provider', + help='Provider keyword (wiz). Optional — krouter resolves from queue item if omitted.') + parser.add_argument('--config-record', required=False, dest='cnapp_config_record_uid', + help='Configuration record UID (only required for some action types).') + parser.add_argument('--resource-ref', required=False, dest='resource_ref', + help='Resource reference UID for the action.') + parser.add_argument('--pwd-complexity', required=False, dest='pwd_complexity', + help='Password complexity JSON (rotate_credentials).') + parser.add_argument('--controller-uid', required=False, dest='controller_uid', + help='Override gateway UID.') + parser.add_argument('--message-uid', required=False, dest='message_uid', + help='Client-generated conversation UID for streaming responses.') + parser.add_argument('--group-name', required=False, dest='group_name', + help='Group name (remove_standing_privilege only).') + + def get_parser(self): + return PAMCnappQueueRemediateCommand.parser + + def execute(self, params, **kwargs): + action = cnapp_helper.action_from_name(kwargs.get('action_type')) + provider = None + if kwargs.get('provider'): + provider = cnapp_helper.provider_from_name(kwargs.get('provider')) + response = cnapp_helper.remediate_cnapp_queue_item( + params, + cnapp_queue_id=kwargs.get('cnapp_queue_id'), + action_type=action, + provider=provider, + cnapp_config_record_uid=kwargs.get('cnapp_config_record_uid'), + resource_ref=kwargs.get('resource_ref'), + pwd_complexity=kwargs.get('pwd_complexity'), + controller_uid=kwargs.get('controller_uid'), + message_uid=kwargs.get('message_uid'), + group_name=kwargs.get('group_name'), + ) + if response is None: + print(f"{bcolors.OKGREEN}Remediation dispatched.{bcolors.ENDC}") + return None + action_name = cnapp_helper.CnappRemediationAction.Name(response.actionType) + status_name = QUEUE_STATUS_BY_ID.get(response.cnappQueueStatusId, str(response.cnappQueueStatusId)) + print(f"{bcolors.OKGREEN}Remediation dispatched.{bcolors.ENDC}") + print(f" Action: {action_name}") + print(f" Status: {status_name}") + if response.result: + print(f" Result: {response.result}") + return None + + +class PAMCnappQueueSetStatusCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp queue set-status') + parser.add_argument('--queue-id', '-q', required=True, type=int, dest='cnapp_queue_id', + help='Queue item ID.') + parser.add_argument('--status', '-s', required=True, dest='status', + help='New status: pending/in_progress/resolved/failed/cancelled, or its numeric id.') + parser.add_argument('--reason', required=False, dest='reason', + help='Free-form reason (forwarded to provider notification).') + + def get_parser(self): + return PAMCnappQueueSetStatusCommand.parser + + def execute(self, params, **kwargs): + status_id = _resolve_status(kwargs.get('status'), allow_all=False) + response = cnapp_helper.set_cnapp_queue_status( + params, + cnapp_queue_id=kwargs.get('cnapp_queue_id'), + cnapp_queue_status_id=status_id, + reason=kwargs.get('reason'), + ) + applied = response.cnappQueueStatusId if response is not None else status_id + print(f"{bcolors.OKGREEN}Status applied: {QUEUE_STATUS_BY_ID.get(applied, applied)}.{bcolors.ENDC}") + return None + + +class PAMCnappQueueDeleteCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp queue delete') + parser.add_argument('--queue-id', '-q', required=True, type=int, dest='cnapp_queue_id', + help='Queue item ID to delete.') + + def get_parser(self): + return PAMCnappQueueDeleteCommand.parser + + def execute(self, params, **kwargs): + cnapp_helper.delete_cnapp_queue_item(params, cnapp_queue_id=kwargs.get('cnapp_queue_id')) + print(f"{bcolors.OKGREEN}Queue item {kwargs.get('cnapp_queue_id')} deleted.{bcolors.ENDC}") + + +# --------------------------------------------------------------------------- +# Formatting helpers +# --------------------------------------------------------------------------- + +def _configuration_to_dict(config): + return { + 'networkUid': bytes_to_base64(config.networkUid) if config.networkUid else '', + 'provider': cnapp_helper.CnappProvider.Name(config.provider), + 'clientId': config.clientId, + 'apiEndpointUrl': config.apiEndpointUrl, + 'authEndpointUrl': config.authEndpointUrl, + 'cnappConfigRecordUid': bytes_to_base64(config.cnappConfigRecordUid) if config.cnappConfigRecordUid else '', + } + + +def _queue_item_to_dict(item): + return { + 'cnappQueueId': item.cnappQueueId, + 'cnappProviderId': cnapp_helper.CnappProvider.Name(item.cnappProviderId), + 'cnappQueueStatusId': item.cnappQueueStatusId, + 'cnappQueueStatusName': QUEUE_STATUS_BY_ID.get(item.cnappQueueStatusId, str(item.cnappQueueStatusId)), + 'receivedAt': item.receivedAt, + 'resolvedAt': item.resolvedAt, + 'networkId': bytes_to_base64(item.networkId) if item.networkId else '', + 'recordUid': bytes_to_base64(item.recordUid) if item.recordUid else '', + } + + +def _uid_display(uid_bytes): + return bytes_to_base64(uid_bytes) if uid_bytes else '(none)' + + +def _print_configuration(config): + print(f"{bcolors.OKBLUE}CNAPP Configuration{bcolors.ENDC}") + print(f" Network UID : {_uid_display(config.networkUid)}") + print(f" Provider : {cnapp_helper.CnappProvider.Name(config.provider)}") + print(f" Client ID : {config.clientId or '(none)'}") + print(f" API Endpoint : {config.apiEndpointUrl or '(none)'}") + print(f" Auth Endpoint : {config.authEndpointUrl or '(none)'}") + print(f" Config Record : {_uid_display(config.cnappConfigRecordUid)}") diff --git a/keepercommander/commands/pam/cnapp_helper.py b/keepercommander/commands/pam/cnapp_helper.py new file mode 100644 index 000000000..c1f0302d6 --- /dev/null +++ b/keepercommander/commands/pam/cnapp_helper.py @@ -0,0 +1,265 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' set_cnapp_configuration + configuration/test -> test_cnapp_configuration + configuration/test-encrypter -> test_cnapp_encrypter + configuration/read -> read_cnapp_configuration + configuration/delete -> delete_cnapp_configuration + + Queue: + queue -> list_cnapp_queue + queue/associate -> associate_cnapp_record + queue/remediate -> remediate_cnapp_queue_item + queue/set-status -> set_cnapp_queue_status + queue/delete -> delete_cnapp_queue_item + +Failures from the helper layer bubble up as Python exceptions raised by the underlying +HTTP/proto plumbing; callers convert them to user-readable output. +""" + +from typing import Optional + +from keeper_secrets_manager_core.utils import url_safe_str_to_bytes + +from ...params import KeeperParams +from ...proto import cnapp_pb2 + + +# NOTE: `router_helper` is imported lazily inside `_post_request_to_router` below. +# Importing it at module top creates this import-time chain: +# cnapp_helper -> router_helper -> gateway_helper +# -> keepercommander.commands.utils -> commands.ksm +# -> commands.record -> commands.ksm (ksm still partially loaded — crash) +# That `record <-> ksm` cycle is pre-existing and only works because production +# code paths load `record` first. Tests that import `cnapp_helper` cold hit the +# cycle directly. TODO(KC-1290): break the record↔ksm cycle so this wrapper can be removed. +def _post_request_to_router(params, endpoint, **kwargs): + """Lazy proxy to `router_helper._post_request_to_router`. + + Defined as a module-level function so callers (and `unittest.mock.patch.object`) + can keep referring to `cnapp_helper._post_request_to_router` as if it were the + original symbol.""" + from .router_helper import _post_request_to_router as _real_post + return _real_post(params, endpoint, **kwargs) + + +# Public re-exports — let commands/tests reach proto types via the helper module so they +# don't need to know the on-disk proto path. +CnappProvider = cnapp_pb2.CnappProvider +CnappRemediationAction = cnapp_pb2.CnappRemediationAction + + +# --------------------------------------------------------------------------- +# Conversion utilities +# --------------------------------------------------------------------------- + +def _to_uid_bytes(uid): # type: (Optional[str]) -> bytes + """Convert a base64url-encoded UID string to bytes; empty/None -> empty bytes.""" + if not uid: + return b'' + if isinstance(uid, bytes): + return uid + return url_safe_str_to_bytes(uid) + + +def provider_from_name(name): # type: (str) -> int + """Resolve a human-typed provider name (e.g. "wiz") to a CnappProvider enum value. + + Accepts the bare provider keyword ("wiz") or the full proto symbol + ("CNAPP_PROVIDER_WIZ"); case-insensitive. Raises ValueError on unknown input.""" + if not name: + return cnapp_pb2.CNAPP_PROVIDER_UNSPECIFIED + normalized = name.strip().upper() + if not normalized.startswith('CNAPP_PROVIDER_'): + normalized = 'CNAPP_PROVIDER_' + normalized + try: + return cnapp_pb2.CnappProvider.Value(normalized) + except ValueError as e: + valid = [n for n in cnapp_pb2.CnappProvider.keys() if n != 'CNAPP_PROVIDER_UNSPECIFIED'] + raise ValueError(f"Unknown CNAPP provider '{name}'. Valid options: {', '.join(valid)}") from e + + +def action_from_name(name): # type: (str) -> int + """Resolve a remediation action name to its enum int. Case-insensitive; accepts the + short keyword (e.g. "rotate_credentials") or the full proto symbol.""" + if not name: + return cnapp_pb2.UNSPECIFIED + normalized = name.strip().upper().replace('-', '_') + try: + return cnapp_pb2.CnappRemediationAction.Value(normalized) + except ValueError as e: + valid = [n for n in cnapp_pb2.CnappRemediationAction.keys() if n != 'UNSPECIFIED'] + raise ValueError(f"Unknown remediation action '{name}'. Valid options: {', '.join(valid)}") from e + + +# --------------------------------------------------------------------------- +# Configuration endpoints +# --------------------------------------------------------------------------- + +def _build_configuration(network_uid, provider, client_id=None, client_secret=None, + api_endpoint_url=None, cnapp_config_record_uid=None, + auth_endpoint_url=None): + # type: (str, int, Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]) -> cnapp_pb2.CnappConfiguration + rq = cnapp_pb2.CnappConfiguration() + rq.networkUid = _to_uid_bytes(network_uid) + rq.provider = provider + if client_id: + rq.clientId = client_id + if client_secret: + rq.clientSecret = client_secret + if api_endpoint_url: + rq.apiEndpointUrl = api_endpoint_url + if cnapp_config_record_uid: + rq.cnappConfigRecordUid = _to_uid_bytes(cnapp_config_record_uid) + if auth_endpoint_url: + rq.authEndpointUrl = auth_endpoint_url + return rq + + +def set_cnapp_configuration(params, network_uid, provider, client_id, client_secret, + api_endpoint_url, cnapp_config_record_uid, auth_endpoint_url=None): + # type: (KeeperParams, str, int, str, str, str, str, Optional[str]) -> cnapp_pb2.CnappConfiguration + """Create or update the CNAPP provider configuration on a network. + + krouter validates the credentials against the provider before persisting; an empty + `client_secret` tells krouter to keep the previously stored value (useful for edits + that only change the endpoint or record UID). + + `auth_endpoint_url` is the provider's OAuth2 token endpoint, letting customers point + at their own tenant/region (e.g. EU vs US Wiz auth host) without a code change.""" + rq = _build_configuration(network_uid, provider, client_id, client_secret, + api_endpoint_url, cnapp_config_record_uid, auth_endpoint_url) + return _post_request_to_router(params, 'cnapp/configuration/set', rq_proto=rq, + rs_type=cnapp_pb2.CnappConfiguration) + + +def test_cnapp_configuration(params, network_uid, provider, client_id, client_secret, + api_endpoint_url, auth_endpoint_url=None): + # type: (KeeperParams, str, int, str, str, str, Optional[str]) -> None + """Probe the provider with the supplied credentials without persisting anything. + + Returns None on success; raises on validation failure (RRC_BAD_REQUEST with the + provider's reason in the message).""" + rq = _build_configuration(network_uid, provider, client_id, client_secret, + api_endpoint_url, cnapp_config_record_uid=None, + auth_endpoint_url=auth_endpoint_url) + return _post_request_to_router(params, 'cnapp/configuration/test', rq_proto=rq) + + +def test_cnapp_encrypter(params, url_base_encrypter): + # type: (KeeperParams, str) -> None + """Issue a `GET /health` against the customer-deployed Encrypter via krouter. + + Used by the UI/CLI to check that the Encrypter URL is reachable before saving a + configuration that references it. Raises on non-200 or transport error.""" + rq = cnapp_pb2.CnappTestEncrypterRequest() + rq.urlBaseEncrypter = url_base_encrypter + return _post_request_to_router(params, 'cnapp/configuration/test-encrypter', rq_proto=rq) + + +def read_cnapp_configuration(params, network_uid, provider): + # type: (KeeperParams, str, int) -> cnapp_pb2.CnappConfiguration + """Read the persisted CNAPP configuration for a network. Note: krouter never returns + the `clientSecret` field — only the endpoint, client id and config record UID.""" + rq = _build_configuration(network_uid, provider) + return _post_request_to_router(params, 'cnapp/configuration/read', rq_proto=rq, + rs_type=cnapp_pb2.CnappConfiguration) + + +def delete_cnapp_configuration(params, network_uid): + # type: (KeeperParams, str) -> None + """Remove the CNAPP configuration on a network. Raises RRC_BAD_STATE if none exists.""" + rq = cnapp_pb2.CnappDeleteConfigurationRequest() + rq.networkUid = _to_uid_bytes(network_uid) + return _post_request_to_router(params, 'cnapp/configuration/delete', rq_proto=rq) + + +# --------------------------------------------------------------------------- +# Queue endpoints +# --------------------------------------------------------------------------- + +def list_cnapp_queue(params, network_uid, status_filter=0): + # type: (KeeperParams, str, int) -> cnapp_pb2.CnappQueueListResponse + """List queued CNAPP issues for a network. `status_filter=0` returns all statuses.""" + rq = cnapp_pb2.CnappQueueListRequest() + rq.networkUid = _to_uid_bytes(network_uid) + rq.statusFilter = int(status_filter) if status_filter is not None else 0 + return _post_request_to_router(params, 'cnapp/queue', rq_proto=rq, + rs_type=cnapp_pb2.CnappQueueListResponse) + + +def associate_cnapp_record(params, cnapp_queue_id, record_uid): + # type: (KeeperParams, int, str) -> None + """Attach a vault record to a queue item — required before remediation.""" + rq = cnapp_pb2.CnappAssociateRequest() + rq.cnappQueueId = int(cnapp_queue_id) + rq.recordUid = _to_uid_bytes(record_uid) + return _post_request_to_router(params, 'cnapp/queue/associate', rq_proto=rq) + + +def remediate_cnapp_queue_item(params, cnapp_queue_id, action_type, provider=None, + cnapp_config_record_uid=None, resource_ref=None, + pwd_complexity=None, controller_uid=None, + message_uid=None, group_name=None): + # type: (KeeperParams, int, int, Optional[int], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]) -> cnapp_pb2.CnappRemediateResponse + """Trigger a remediation action against the gateway for a queued issue. + + Currently krouter only dispatches `ROTATE_CREDENTIALS`; other actions return + RRC_BAD_REQUEST. The optional fields are forwarded as-is so this helper stays + forward-compatible with new action types.""" + rq = cnapp_pb2.CnappRemediateRequest() + rq.cnappQueueId = int(cnapp_queue_id) + rq.actionType = int(action_type) + if provider is not None: + rq.provider = int(provider) + if cnapp_config_record_uid: + rq.cnappConfigurationRecordUid = _to_uid_bytes(cnapp_config_record_uid) + if resource_ref: + rq.resourceRef = _to_uid_bytes(resource_ref) + if pwd_complexity: + rq.pwdComplexity = pwd_complexity + if controller_uid: + rq.controllerUid = controller_uid + if message_uid: + rq.messageUid = _to_uid_bytes(message_uid) + if group_name: + rq.groupName = group_name + return _post_request_to_router(params, 'cnapp/queue/remediate', rq_proto=rq, + rs_type=cnapp_pb2.CnappRemediateResponse) + + +def set_cnapp_queue_status(params, cnapp_queue_id, cnapp_queue_status_id, reason=None): + # type: (KeeperParams, int, int, Optional[str]) -> cnapp_pb2.CnappSetStatusResponse + """Set the local status on a queue item; krouter best-effort notifies the provider.""" + rq = cnapp_pb2.CnappSetStatusRequest() + rq.cnappQueueId = int(cnapp_queue_id) + rq.cnappQueueStatusId = int(cnapp_queue_status_id) + if reason: + rq.reason = reason + return _post_request_to_router(params, 'cnapp/queue/set-status', rq_proto=rq, + rs_type=cnapp_pb2.CnappSetStatusResponse) + + +def delete_cnapp_queue_item(params, cnapp_queue_id): + # type: (KeeperParams, int) -> None + """Remove a queue item entirely. Raises RRC_BAD_REQUEST if the queue id is unknown.""" + rq = cnapp_pb2.CnappDeleteQueueItemRequest() + rq.cnappQueueId = int(cnapp_queue_id) + return _post_request_to_router(params, 'cnapp/queue/delete', rq_proto=rq) diff --git a/keepercommander/commands/pam/recording_commands.py b/keepercommander/commands/pam/recording_commands.py new file mode 100644 index 000000000..0f1ccf553 --- /dev/null +++ b/keepercommander/commands/pam/recording_commands.py @@ -0,0 +1,440 @@ +import argparse +import base64 +import datetime +import json +import logging +import os +import struct +from concurrent.futures import ThreadPoolExecutor, as_completed + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from ... import crypto, utils +from ...proto import pam_pb2 +from .router_helper import router_get_recordings_for_users, router_download_recording +from ..base import Command, dump_report_data + +RISK_LEVEL_NAMES = { + pam_pb2.PRR_UNSPECIFIED: 'unspecified', + pam_pb2.PRR_LOW: 'low', + pam_pb2.PRR_MEDIUM: 'medium', + pam_pb2.PRR_HIGH: 'high', + pam_pb2.PRR_CRITICAL: 'critical', +} + +RECORDING_TYPE_NAMES = { + pam_pb2.PRT_SESSION: 'session', + pam_pb2.PRT_TYPESCRIPT: 'typescript', + pam_pb2.PRT_TIME: 'time', + pam_pb2.PRT_SUMMARY: 'summary', +} + +RECORDING_TYPE_VALUES = {v: k for k, v in RECORDING_TYPE_NAMES.items()} +RISK_LEVEL_VALUES = {v: k for k, v in RISK_LEVEL_NAMES.items() if v != 'unspecified'} + +# PAMRecordingType → file extension used in the download URL +_TYPE_EXT = { + pam_pb2.PRT_SESSION: 'ses', + pam_pb2.PRT_TYPESCRIPT: 'tys', + pam_pb2.PRT_TIME: 'tim', + pam_pb2.PRT_SUMMARY: 'sum', +} + + +def _get_record_key(params, record_uid_bytes): + """Return the plaintext AES record key for a record UID, or None if not cached.""" + uid_str = utils.base64_url_encode(record_uid_bytes) + rec = params.record_cache.get(uid_str) + if rec is None: + return None + return rec.get('record_key_unencrypted') + + +def _decrypt_recording_file(raw_bytes, record_key): + """Decrypt a PAM recording file. + + Wire format (from vault session-recordings-util.ts): + [4 bytes big-endian: AD length][AD JSON bytes][0x3B ';'][12-byte nonce][AES-GCM ciphertext] + + Key derivation: + salt = AD.resourceKeysSalt (base64) if present, else nonce (legacy) + info = b"{AD.conversationUid}_RECORDING-SESSION_AES-GCM-256" + resourceKey = HKDF-SHA256(ikm=record_key, salt=salt, info=info, length=32) + + Then: + privateMeta = AES-GCM-decrypt(AD.resourceData_base64, resourceKey) → JSON + plaintext = AES-GCM-decrypt(ciphertext, privateMeta.recordingSecret, nonce=nonce, aad=AD_bytes) + """ + # --- parse wire format --- + ad_len = struct.unpack('>I', raw_bytes[:4])[0] + ad_bytes = raw_bytes[4:4 + ad_len] + term_pos = 4 + ad_len + if raw_bytes[term_pos:term_pos + 1] != b';': + raise ValueError('Recording format error: expected ";" terminator after Associated Data') + nonce = raw_bytes[term_pos + 1:term_pos + 13] # 12 bytes + ciphertext = raw_bytes[term_pos + 13:] + + ad = json.loads(ad_bytes.decode('utf-8')) + + # --- derive resource key --- + if ad.get('resourceKeysSalt'): + salt = base64.b64decode(ad['resourceKeysSalt']) + else: + salt = nonce # legacy recordings: nonce was used as salt + + info = f"{ad['conversationUid']}_RECORDING-SESSION_AES-GCM-256".encode('utf-8') + resource_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=info).derive(record_key) + + # --- decrypt AD.resourceData → PrivateMeta JSON (contains recordingSecret) --- + resource_data_enc = base64.b64decode(ad['resourceData']) + # stored as nonce(12) + ciphertext — same layout as crypto.decrypt_aes_v2 + private_meta_bytes = AESGCM(resource_key).decrypt( + resource_data_enc[:12], resource_data_enc[12:], None) + private_meta = json.loads(private_meta_bytes.decode('utf-8')) + + # --- decrypt recording data using recordingSecret --- + recording_secret = base64.b64decode(private_meta['recordingSecret']) + plaintext = AESGCM(recording_secret).decrypt(nonce, ciphertext, ad_bytes) + return plaintext + + +def _parse_dt(s): + for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d'): + try: + return datetime.datetime.strptime(s, fmt) + except ValueError: + pass + raise ValueError(f'Cannot parse datetime: {s!r} (use YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)') + + +def _fmt_ts(epoch_ms): + if not epoch_ms: + return '' + return datetime.datetime.utcfromtimestamp(epoch_ms / 1000).strftime('%Y-%m-%d %H:%M:%S UTC') + + +def _build_user_request(kwargs): + """Build a PAMRecordingsForUsersRequest from command kwargs.""" + rq = pam_pb2.PAMRecordingsForUsersRequest() + rq.usernames.extend(kwargs.get('usernames') or []) + rq.maxCount = kwargs.get('max_count') or 0 + + if kwargs.get('range_start'): + rq.rangeStart = int(_parse_dt(kwargs['range_start']).timestamp() * 1000) + if kwargs.get('range_end'): + rq.rangeEnd = int(_parse_dt(kwargs['range_end']).timestamp() * 1000) + + for t in (kwargs.get('types') or []): + rq.types.append(RECORDING_TYPE_VALUES[t]) + for r in (kwargs.get('risks') or []): + rq.risks.append(RISK_LEVEL_VALUES[r]) + rq.protocols.extend(kwargs.get('protocols') or []) + return rq + + +def _add_filter_args(parser): + """Add the shared filter arguments to a parser.""" + parser.add_argument('--types', nargs='+', metavar='TYPE', + choices=list(RECORDING_TYPE_VALUES.keys()), + help='filter by type: session typescript time summary') + parser.add_argument('--risks', nargs='+', metavar='RISK', + choices=list(RISK_LEVEL_VALUES.keys()), + help='filter by AI risk: low medium high critical') + parser.add_argument('--protocols', nargs='+', metavar='PROTOCOL', + help='filter by protocol, e.g. SSH RDP') + parser.add_argument('--range-start', metavar='DATE', + help='recordings on or after this date (YYYY-MM-DD)') + parser.add_argument('--range-end', metavar='DATE', + help='recordings on or before this date (YYYY-MM-DD)') + parser.add_argument('--max-count', type=int, default=0, metavar='N', + help='max results to fetch (default: server max of 1000)') + + +# KRouter rate limit: 50 requests / 5 seconds per user (HTTP.kt). +# We keep workers at 4 so a burst of 4 files lands well inside that budget, +# leaving headroom for any other concurrent API calls in the same session. +_DOWNLOAD_WORKERS = 4 + + +def _download_one(params, conn_uid_bytes, ext, output_dir, record_key): + """Download, decrypt, and save a single recording file. Returns (filename, bytes_written, error).""" + uid_b64 = base64.urlsafe_b64encode(conn_uid_bytes).rstrip(b'=').decode() + filename = f'{uid_b64}.{ext}' + dest = os.path.join(output_dir, filename) + try: + raw = router_download_recording(params, conn_uid_bytes, ext) + plaintext = _decrypt_recording_file(raw, record_key) + with open(dest, 'wb') as f: + f.write(plaintext) + return filename, len(plaintext), None + except Exception as e: + return filename, 0, e + + +def _download_recording_files(params, conn_uid_bytes, exts, output_dir, record_key): + """Download and decrypt one session's recording files (parallel across types).""" + tasks = [(conn_uid_bytes, ext, output_dir, record_key) for ext in exts] + with ThreadPoolExecutor(max_workers=min(len(tasks), _DOWNLOAD_WORKERS)) as ex: + futures = {ex.submit(_download_one, params, *t): t[1] for t in tasks} + for fut in as_completed(futures): + filename, nbytes, err = fut.result() + if err: + logging.warning(' ✗ %s — %s', filename, err) + else: + print(f' ✓ {filename} ({nbytes:,} bytes)') + + +def _download_recordings(params, recordings, type_filter, output_dir): + """Download and decrypt all recordings in parallel, fetching keys from vault cache.""" + os.makedirs(output_dir, exist_ok=True) + print(f'Downloading to {os.path.abspath(output_dir)} ...') + + # Build the full flat task list: (conn_uid_bytes, ext, record_key) + tasks = [] + for rec in recordings: + if not rec.connectionUid: + continue + record_key = _get_record_key(params, rec.recordUid) if rec.recordUid else None + if record_key is None: + logging.warning(' skipping %s — record key not in vault cache (run sync-down first)', + rec.connectionUid.hex()[:12]) + continue + if type_filter: + exts = [_TYPE_EXT[t] for t in type_filter] + else: + ext = _TYPE_EXT.get(rec.recordingType) + exts = [ext] if ext else [] + for ext in exts: + tasks.append((rec.connectionUid, ext, record_key)) + + if not tasks: + print('Nothing to download.') + return + + with ThreadPoolExecutor(max_workers=_DOWNLOAD_WORKERS) as ex: + futures = { + ex.submit(_download_one, params, conn_uid, ext, output_dir, key): (conn_uid, ext) + for conn_uid, ext, key in tasks + } + for fut in as_completed(futures): + filename, nbytes, err = fut.result() + if err: + logging.warning(' ✗ %s — %s', filename, err) + else: + print(f' ✓ {filename} ({nbytes:,} bytes)') + + +class PAMGetRecordingsForUsersCommand(Command): + """List PAM session recordings for one or more users, with optional download.""" + + def get_parser(self): + parser = argparse.ArgumentParser( + prog='pam recording list-by-user', + description='List PAM session recordings for one or more users.') + parser.add_argument('usernames', nargs='+', metavar='USERNAME', + help='one or more Keeper email addresses') + _add_filter_args(parser) + parser.add_argument('--format', dest='output_format', + choices=['table', 'json', 'csv'], default='table', + help='display format (default: table)') + parser.add_argument('--output', metavar='FILE', + help='save listing to a file; format inferred from extension ' + '(.json, .csv, .md/.txt = markdown table)') + parser.add_argument('--download-dir', metavar='DIR', + help='download and decrypt recording files into this directory ' + '(implies download; created if it does not exist)') + return parser + + def execute(self, params, **kwargs): + usernames = kwargs.get('usernames') or [] + if not usernames: + logging.error('At least one username is required.') + return + + rq = _build_user_request(kwargs) + + try: + rs = router_get_recordings_for_users(params, rq) + except Exception as e: + logging.error('get_recordings_for_users failed: %s', e) + return + + if rs is None: + print('No response from router.') + return + + recordings = list(rs.recordings) + has_more = rs.hasMore + + # --- listing output --- + output_file = kwargs.get('output') + output_format = kwargs.get('output_format', 'table') + if output_file: + file_ext = output_file.rsplit('.', 1)[-1].lower() if '.' in output_file else '' + if file_ext == 'json': + output_format = 'json' + elif file_ext == 'csv': + output_format = 'csv' + else: + output_format = 'grid' # markdown table for .md / .txt + + if output_format == 'json': + _print_json(recordings, has_more, output_file) + else: + _print_table(recordings, has_more, output_format, output_file) + + # --- optional download --- + dl_dir = kwargs.get('download_dir') + if dl_dir and recordings: + type_filter = [RECORDING_TYPE_VALUES[t] for t in (kwargs.get('types') or [])] + _download_recordings(params, recordings, type_filter, dl_dir) + + +class PAMDownloadRecordingsCommand(Command): + """Download and decrypt PAM session recording files. + + Two modes: + --usernames List recordings for those users then download all of them. + --connection-uid Download specific session(s) by connection UID + (requires --record-uid so the file can be decrypted). + """ + + def get_parser(self): + parser = argparse.ArgumentParser( + prog='pam recording download', + description='Download and decrypt PAM recording files to a local directory.') + src = parser.add_mutually_exclusive_group(required=True) + src.add_argument('--usernames', nargs='+', metavar='USERNAME', + help='download all recordings for these users') + src.add_argument('--connection-uid', nargs='+', metavar='HEX', + help='one or more connection UIDs (hex) to download directly') + parser.add_argument('--record-uid', metavar='HEX', + help='record UID (hex) — required with --connection-uid for decryption') + parser.add_argument('--output-dir', metavar='DIR', default='recordings', + help='directory to save files into (default: ./recordings)') + _add_filter_args(parser) + return parser + + def execute(self, params, **kwargs): + output_dir = kwargs.get('output_dir') or 'recordings' + type_filter = [RECORDING_TYPE_VALUES[t] for t in (kwargs.get('types') or [])] + + conn_uid_hexes = kwargs.get('connection_uid') or [] + if conn_uid_hexes: + record_uid_hex = kwargs.get('record_uid') + if not record_uid_hex: + logging.error( + '--record-uid is required with --connection-uid so the file can be decrypted.\n' + ' Find the record UID with: pam rec lbu --format json') + return + try: + record_uid_bytes = bytes.fromhex(record_uid_hex) + except ValueError: + logging.error('Invalid --record-uid (expected hex): %s', record_uid_hex) + return + + record_key = _get_record_key(params, record_uid_bytes) + if record_key is None: + logging.error('Record %s not found in vault cache — run sync-down first.', record_uid_hex) + return + + os.makedirs(output_dir, exist_ok=True) + print(f'Downloading to {os.path.abspath(output_dir)} ...') + exts = [_TYPE_EXT[t] for t in (type_filter or list(_TYPE_EXT.keys()))] + for conn_uid_hex in conn_uid_hexes: + try: + conn_uid_bytes = bytes.fromhex(conn_uid_hex) + except ValueError: + logging.error('Invalid --connection-uid (expected hex): %s', conn_uid_hex) + continue + _download_recording_files(params, conn_uid_bytes, exts, output_dir, record_key) + return + + # --- username mode --- + usernames = kwargs.get('usernames') or [] + rq = _build_user_request(kwargs) + + try: + rs = router_get_recordings_for_users(params, rq) + except Exception as e: + logging.error('Failed to list recordings: %s', e) + return + + if rs is None: + print('No response from router.') + return + + recordings = list(rs.recordings) + if not recordings: + print('No recordings found.') + return + + if rs.hasMore: + print(f'Found {len(recordings)} recording(s) (server has more — narrow filters or increase --max-count).') + else: + print(f'Found {len(recordings)} recording(s).') + + _download_recordings(params, recordings, type_filter, output_dir) + + +def _print_table(recordings, has_more, fmt='table', output_file=None): + if not recordings: + print('No recordings found.') + return + + rows = [] + for rec in recordings: + conn_uid = rec.connectionUid.hex() if rec.connectionUid else '' + record_uid = rec.recordUid.hex() if rec.recordUid else '' + rec_type = RECORDING_TYPE_NAMES.get(rec.recordingType, str(rec.recordingType)) + risk = RISK_LEVEL_NAMES.get(rec.aiOverallRiskLevel, '') + rows.append([ + conn_uid[:16] + '…' if len(conn_uid) > 16 else conn_uid, + record_uid[:16] + '…' if len(record_uid) > 16 else record_uid, + rec.userName, + rec_type, + rec.protocol or '', + _fmt_ts(rec.startedOn) if rec.startedOn else '', + str(rec.length) + 's' if rec.length else '', + risk, + ]) + + headers = ['ConnUID', 'RecordUID', 'User', 'Type', 'Protocol', 'Started', 'Duration', 'Risk'] + dump_report_data(rows, headers, fmt=fmt, filename=output_file or '', row_number=False) + if output_file: + print(f'Wrote {len(recordings)} recording(s) to {output_file}') + else: + suffix = ' (more available — narrow filters or increase --max-count)' if has_more else '' + print(f'\nTotal: {len(recordings)}{suffix}') + + +def _print_json(recordings, has_more, output_file=None): + import json + from base64 import b64encode + + out = [] + for rec in recordings: + out.append({ + 'connectionUid': rec.connectionUid.hex() if rec.connectionUid else None, + 'recordUid': rec.recordUid.hex() if rec.recordUid else None, + 'userName': rec.userName, + 'recordingType': RECORDING_TYPE_NAMES.get(rec.recordingType, rec.recordingType), + 'protocol': rec.protocol or None, + 'startedOn': rec.startedOn, + 'createdOn': rec.createdOn, + 'length': rec.length, + 'fileSize': rec.fileSize, + 'closeReason': rec.closeReason or None, + 'recordingDuration': rec.recordingDuration or None, + 'aiOverallRiskLevel': RISK_LEVEL_NAMES.get(rec.aiOverallRiskLevel, None), + 'aiOverallSummary': b64encode(rec.aiOverallSummary).decode() if rec.aiOverallSummary else None, + }) + payload = json.dumps({'recordings': out, 'hasMore': has_more}, indent=2) + if output_file: + with open(output_file, 'w') as f: + f.write(payload) + print(f'Wrote {len(recordings)} recording(s) to {output_file}') + else: + print(payload) diff --git a/keepercommander/commands/pam/router_helper.py b/keepercommander/commands/pam/router_helper.py index cf0594cef..7c40f030e 100644 --- a/keepercommander/commands/pam/router_helper.py +++ b/keepercommander/commands/pam/router_helper.py @@ -122,6 +122,27 @@ def router_get_rotation_schedules(params, proto_request): return _post_request_to_router(params, 'get_rotation_schedules', rq_proto=proto_request, rs_type=pam_pb2.PAMRotationSchedulesResponse) +def router_get_recordings_for_users(params, proto_request): + return _post_request_to_router(params, 'get_recordings_for_users', rq_proto=proto_request, rs_type=pam_pb2.PAMRecordingsResponse) + + +def router_download_recording(params, connection_uid_bytes, recording_type_ext): + # type: (KeeperParams, bytes, str) -> bytes + """Download a single recording file. Returns raw file bytes.""" + import base64 + uid_b64 = base64.urlsafe_b64encode(connection_uid_bytes).rstrip(b'=').decode() + filename = f'{uid_b64}.{recording_type_ext}' + rs = _post_request_to_router(params, f'pam_recordings/{filename}', method='get', + raw_without_status_check_response=True) + if rs.status_code == 404: + raise KeeperApiError(404, f'Recording not found: {filename}') + if rs.status_code == 403: + raise KeeperApiError(403, f'Access denied to recording: {filename}') + if rs.status_code >= 400: + raise KeeperApiError(rs.status_code, f'Download failed ({rs.status_code}): {filename}') + return rs.content + + def router_get_relay_access_creds(params, expire_sec=None): query_params = { 'expire-sec': expire_sec diff --git a/keepercommander/commands/register.py b/keepercommander/commands/register.py index 5aac059db..b8de28b37 100644 --- a/keepercommander/commands/register.py +++ b/keepercommander/commands/register.py @@ -20,7 +20,7 @@ import re import time import urllib.parse -from typing import Optional, Dict, Iterable, Any, Set, List, Union +from typing import Optional, Dict, Iterable, Any, Set, List, Union, Tuple from urllib.parse import urlunparse from tabulate import tabulate @@ -93,37 +93,59 @@ def register_command_info(aliases, command_info): '(not "never") and a pamUser record with rotation configured.') share_record_parser.add_argument('record', nargs='?', type=str, action='store', help='record/shared folder path/UID') -share_folder_parser = argparse.ArgumentParser(prog='share-folder', description='Change the permissions of a shared folder') -share_folder_parser.add_argument('-a', '--action', dest='action', choices=['grant','remove'], - default='grant', action='store', help='shared folder action. \'grant\' if omitted') -share_folder_parser.add_argument('-e', '--email', dest='user', action='append', - help='account email, team, @existing for all users and teams in the folder, ' - 'or \'*\' as default folder permission') -share_folder_parser.add_argument('-r', '--record', dest='record', action='append', - help='record name, record UID, @existing for all records in the folder,' - ' or \'*\' as default folder permission') -share_folder_parser.add_argument('-p', '--manage-records', dest='manage_records', action='store', - choices=['on', 'off'], help='account permission: can manage records.') -share_folder_parser.add_argument('-o', '--manage-users', dest='manage_users', action='store', - choices=['on', 'off'], help='account permission: can manage users.') -share_folder_parser.add_argument('-s', '--can-share', dest='can_share', action='store', - choices=['on', 'off'], help='record permission: can be shared') -share_folder_parser.add_argument('-d', '--can-edit', dest='can_edit', action='store', - choices=['on', 'off'], help='record permission: can be modified.') -share_folder_parser.add_argument('-f', '--force', dest='force', action='store_true', - help='Apply permission changes ignoring default folder permissions. Used on the ' - 'initial sharing action') -expiration = share_folder_parser.add_mutually_exclusive_group() -expiration.add_argument('--expire-at', dest='expire_at', action='store', metavar='TIMESTAMP', - help='share expiration: never or ISO datetime (yyyy-MM-dd[ hh:mm:ss])') -expiration.add_argument('--expire-in', dest='expire_in', action='store', metavar='PERIOD', - help='share expiration: never or period ([(y)ears|(mo)nths|(d)ays|(h)ours(mi)nutes]') -share_folder_parser.add_argument('-roe', '--rotate-on-expiration', dest='rotate_on_expiration', action='store_true', - help='rotate the password when the share access expires. ' - 'Only valid on grant; requires a positive --expire-at/--expire-in ' - '(not "never") and at least one pamUser record with rotation ' - 'configured in the folder.') -share_folder_parser.add_argument('folder', nargs='+', type=str, action='store', help='shared folder path or UID') +share_folder_parser = argparse.ArgumentParser( + prog='share-folder', + description='Manage shared folder access for users and teams, and record permissions in the folder.') + +folder_access = share_folder_parser.add_argument_group( + 'folder access', 'Who can use the folder (grant or remove with -a)') +folder_access.add_argument( + '-a', '--action', dest='action', choices=['grant', 'remove'], + default='grant', action='store', + help='folder access action for -e users/teams: grant (default) or remove') +folder_access.add_argument( + '-e', '--email', dest='user', action='append', + help='account email, team, @existing for all users and teams in the folder, ' + 'or \'*\' as default user permission') +folder_access.add_argument( + '-p', '--manage-records', dest='manage_records', action='store', + choices=['on', 'off'], help='account permission: can manage records. Requires -e.') +folder_access.add_argument( + '-o', '--manage-users', dest='manage_users', action='store', + choices=['on', 'off'], + help='account permission: can manage users. Mutually exclusive with --expire-at/--expire-in. Requires -e.') +expiration = folder_access.add_mutually_exclusive_group() +expiration.add_argument( + '--expire-at', dest='expire_at', action='store', metavar='TIMESTAMP', + help='folder access expiration for -e: never or ISO datetime (yyyy-MM-dd[ hh:mm:ss]). Requires -e.') +expiration.add_argument( + '--expire-in', dest='expire_in', action='store', metavar='PERIOD', + help='folder access expiration for -e: never or period ' + '([(y)ears|(mo)nths|(d)ays|(h)ours|(mi)nutes]). Requires -e.') +folder_access.add_argument( + '-roe', '--rotate-on-expiration', dest='rotate_on_expiration', action='store_true', + help='rotate the password when folder access expires. Grant only; requires a positive ' + '--expire-at/--expire-in (not "never") and a pamUser record with rotation configured. ' + 'Requires -a grant, -e, and --expire-at or --expire-in.') +folder_access.add_argument( + '-f', '--force', dest='force', action='store_true', + help='skip confirmation prompts') + +record_access = share_folder_parser.add_argument_group( + 'record permissions', 'Can edit and can share for records in the folder') +record_access.add_argument( + '-r', '--record', dest='record', action='append', + help='record name or UID already in the folder, @existing for all records in the folder, ' + 'or \'*\' as default record permission') +record_access.add_argument( + '-s', '--can-share', dest='can_share', action='store', + choices=['on', 'off'], help='record permission: can be shared. Requires -r.') +record_access.add_argument( + '-d', '--can-edit', dest='can_edit', action='store', + choices=['on', 'off'], help='record permission: can be modified. Requires -r.') + +share_folder_parser.add_argument( + 'folder', nargs='+', type=str, action='store', help='shared folder path or UID') share_report_parser = argparse.ArgumentParser(prog='share-report', description='Generates a report of shared records', parents=[base.report_output_parser]) @@ -232,6 +254,7 @@ def get_share_expiration(expire_at, expire_in, cmd_name='share-record'): # ( return dt = None # type: Optional[datetime.datetime] + now_ms = None # type: Optional[int] if isinstance(expire_at, str): if expire_at == 'never': return -1 @@ -244,16 +267,108 @@ def get_share_expiration(expire_at, expire_in, cmd_name='share-record'): # ( raise CommandError( cmd_name, 'Share expiration must be at least 1 minute.', - ) - dt = datetime.datetime.now() + td + now_utc = datetime.datetime.now(datetime.timezone.utc) + now_ms = int(now_utc.timestamp() * 1000) + dt = now_utc + td if dt is None: raise ValueError(f'Incorrect expiration: {expire_at or expire_in}') - expiration_seconds = int(dt.timestamp()) + expiration_ms = int(dt.timestamp() * 1000) from .nested_share_folder.helpers import validate_share_expiration_timestamp - validate_share_expiration_timestamp(expiration_seconds * 1000, cmd_name) - return expiration_seconds + validate_share_expiration_timestamp(expiration_ms, cmd_name, now_ms=now_ms) + return expiration_ms // 1000 + + +def _as_append_list(value): + # type: (Any) -> List[Any] + if not value: + return [] + return value if isinstance(value, list) else [value] + + +def _folder_has_record_permission_target(record_uids, default_record, all_records): + # type: (Set[str], bool, bool) -> bool + return bool(record_uids or default_record or all_records) + + +def _folder_user_lookup(shared_folder, email): + # type: (dict, str) -> Optional[dict] + """Find a folder user entry by email (case-insensitive).""" + email_lower = email.lower() + for user in shared_folder.get('users', []): + if user.get('username', '').lower() == email_lower: + return user + return None + + +def format_share_expiration_ms(expiration_ms): + # type: (int) -> str + """Format a share expiration timestamp (milliseconds) for log output.""" + if expiration_ms > 0: + return str(datetime.datetime.fromtimestamp(expiration_ms / 1000)) + if expiration_ms < 0: + return 'never' + return '' + + +def _folder_share_expiration_lookups(rq): + # type: (folder_pb2.SharedFolderUpdateV3Request) -> Tuple[Dict[str, int], Dict[str, int]] + """Map folder user/team names to expiration values from an outgoing folder update request.""" + user_exp = {} + for folder_user in list(rq.sharedFolderAddUser) + list(rq.sharedFolderUpdateUser): + if folder_user.expiration: + user_exp[folder_user.username.lower()] = folder_user.expiration + team_exp = {} + for folder_team in list(rq.sharedFolderAddTeam) + list(rq.sharedFolderUpdateTeam): + if folder_team.expiration: + team_exp[utils.base64_url_encode(folder_team.teamUid)] = folder_team.expiration + return user_exp, team_exp + + +def _record_share_expiration_lookup(rq): + # type: (record_pb2.RecordShareUpdateRequest) -> Dict[Tuple[str, str], int] + """Map (record_uid, username) pairs to expiration values from an outgoing record share request.""" + lookup = {} + for attr in ('addSharedRecord', 'updateSharedRecord'): + for shared_record in getattr(rq, attr): + if shared_record.expiration: + lookup[(utils.base64_url_encode(shared_record.recordUid), + shared_record.toUsername.lower())] = shared_record.expiration + return lookup + + +def _record_share_log_title(params, record_uid): + # type: (KeeperParams, str) -> str + if record_uid in params.record_cache: + return api.get_record(params, record_uid).title + return record_uid + + +def _is_shared_folder_owner(params, shared_folder): + # type: (KeeperParams, dict) -> bool + owner_uid = shared_folder.get('owner_account_uid') + if owner_uid: + return owner_uid == utils.base64_url_encode(params.account_uid_bytes) + owner_username = shared_folder.get('owner_username') + if owner_username and params.user: + return owner_username.lower() == params.user.lower() + return False + + +def _folder_has_full_manager_excluding(shared_folder, exclude_usernames=()): + # type: (dict, Iterable[str]) -> bool + exclude = {x.lower() for x in exclude_usernames if x} + for user in shared_folder.get('users', []): + username = (user.get('username') or '').lower() + if username in exclude: + continue + if user.get('manage_records') and user.get('manage_users'): + return True + for team in shared_folder.get('teams', []): + if team.get('manage_records') and team.get('manage_users'): + return True + return False class ShareFolderCommand(Command): @@ -321,6 +436,8 @@ def get_share_admin_obj_uids(obj_names, obj_type): if action == 'grant': share_expiration = get_share_expiration( kwargs.get('expire_at'), kwargs.get('expire_in'), cmd_name='share-folder') + if isinstance(share_expiration, int) and (kwargs.get('user') or kwargs.get('record')): + SyncDownCommand().execute(params, force=True) rotate_on_expiration = bool(kwargs.get('rotate_on_expiration')) if rotate_on_expiration: @@ -347,7 +464,7 @@ def get_share_admin_obj_uids(obj_names, obj_type): all_users = False default_account = False if 'user' in kwargs: - for u in (kwargs.get('user') or []): + for u in _as_append_list(kwargs.get('user')): if u == '*': default_account = True elif u in ('@existing', '@current'): @@ -378,8 +495,7 @@ def get_share_admin_obj_uids(obj_names, obj_type): default_record = False unresolved_names = [] if 'record' in kwargs: - records = kwargs.get('record') or [] - for r in records: + for r in _as_append_list(kwargs.get('record')): if r == '*': default_record = True elif r in ('@existing', '@current'): @@ -392,12 +508,26 @@ def get_share_admin_obj_uids(obj_names, obj_type): sa_record_uids = get_share_admin_obj_uids(unresolved_names, record_pb2.CHECK_SA_ON_RECORD) record_uids.update(sa_record_uids or {}) + ShareFolderCommand._validate_share_folder_kwargs( + action, kwargs, + record_uids=record_uids, + default_record=default_record, + all_records=all_records) + + if record_uids and not default_record and not all_records: + ShareFolderCommand._validate_records_in_shared_folders( + params, shared_folder_uids, record_uids) + if len(as_users) == 0 and len(as_teams) == 0 and len(record_uids) == 0 and \ not default_record and not default_account and \ not all_users and not all_records: logging.info('Nothing to do') return + if action == 'remove' and as_users: + ShareFolderCommand._confirm_folder_user_removals( + params, shared_folder_uids, as_users, force=kwargs.get('force') is True) + rq_groups = [] def prep_rq(recs, users, curr_sf): @@ -424,19 +554,20 @@ def prep_rq(recs, users, curr_sf): else: sh_fol = { 'shared_folder_uid': sf_uid, - 'users': [{'username': x, 'manage_records': action != 'grant', 'manage_users': action != 'grant'} + 'users': [{'username': x, 'manage_records': False, 'manage_users': False} for x in as_users], - 'teams': [{'team_uid': x, 'manage_records': action != 'grant', 'manage_users': action != 'grant'} + 'teams': [{'team_uid': x, 'manage_records': False, 'manage_users': False} for x in as_teams], - 'records': [{'record_uid': x, 'can_share': action != 'grant', 'can_edit': action != 'grant'} - for x in record_uids] } + if record_uids: + sh_fol['records'] = [ + {'record_uid': x, 'can_share': False, 'can_edit': False} for x in record_uids] chunk_size = 500 rec_list = list(sf_records) user_list = list(sf_users) - num_rec_chunks = math.ceil(len(sf_records) / chunk_size) - num_user_chunks = math.ceil(len(sf_users) / chunk_size) - num_rq_groups = num_user_chunks or 1 * num_rec_chunks or 1 + num_rec_chunks = math.ceil(len(sf_records) / chunk_size) if sf_records else 0 + num_user_chunks = math.ceil(len(sf_users) / chunk_size) if sf_users else 0 + num_rq_groups = (num_rec_chunks or 1) * (num_user_chunks or 1) while len(rq_groups) < num_rq_groups: rq_groups.append([]) rec_chunks = [rec_list[i * chunk_size:(i + 1) * chunk_size] for i in range(num_rec_chunks)] or [[]] @@ -446,11 +577,79 @@ def prep_rq(recs, users, curr_sf): for u_chunk in user_chunks: sf_info = sh_fol.copy() if group_idx: - del sf_info['revision'] + sf_info.pop('revision', None) rq_groups[group_idx].append(prep_rq(r_chunk, u_chunk, sf_info)) group_idx += 1 self.send_requests(params, rq_groups) + @staticmethod + def _validate_share_folder_kwargs(action, kwargs, *, record_uids, default_record, all_records): + has_record_target = _folder_has_record_permission_target(record_uids, default_record, all_records) + has_record_perms = kwargs.get('can_edit') is not None or kwargs.get('can_share') is not None + + if has_record_perms and not has_record_target: + raise CommandError( + 'share-folder', + '-d and -s require a record target: -r , -r *, or -r @existing.') + + @staticmethod + def _validate_records_in_shared_folders(params, shared_folder_uids, record_uids): + # type: (KeeperParams, Set[str], Set[str]) -> None + """Reject -r targets that are not already linked to the shared folder.""" + for sf_uid in shared_folder_uids: + sh_fol = params.shared_folder_cache.get(sf_uid) + if not sh_fol: + raise CommandError( + 'share-folder', + f'Shared folder "{sf_uid}" is not loaded. Sync down and retry.') + folder_record_uids = {x['record_uid'] for x in sh_fol.get('records', [])} + missing = record_uids - folder_record_uids + if not missing: + continue + labels = [] + for uid in sorted(missing): + rec = params.record_cache.get(uid) + title = rec.get('title_unencrypted') if rec else None + labels.append(title or uid) + folder_name = sh_fol.get('name_unencrypted') or sf_uid + raise CommandError( + 'share-folder', + f'Record(s) not in shared folder "{folder_name}": ' + ', '.join(labels)) + + @staticmethod + def _confirm_folder_user_removals(params, shared_folder_uids, users_to_remove, *, force=False): + # type: (KeeperParams, Set[str], Set[str], bool) -> None + current_user = (params.user or '').lower() + if not current_user or current_user not in {u.lower() for u in users_to_remove}: + return + + removing_self_from_shared_folder = False + for sf_uid in shared_folder_uids: + sh_fol = params.shared_folder_cache.get(sf_uid, {}) + if _is_shared_folder_owner(params, sh_fol): + if not _folder_has_full_manager_excluding(sh_fol, [params.user]): + raise CommandError( + 'share-folder', + 'Cannot remove yourself from this shared folder: no other participant has ' + 'manage users and manage records permission.') + if not force: + answer = user_choice( + 'Removing yourself will relinquish folder ownership. ' + 'Another participant can manage users and records. Proceed?', + 'yn', 'n') + if answer.lower() not in ('y', 'yes'): + raise CommandError('share-folder', 'Operation cancelled.') + else: + removing_self_from_shared_folder = True + + if removing_self_from_shared_folder and not force: + answer = user_choice( + 'Are you sure that you want to delete yourself from this shared folder? ' + 'You will not be able to add yourself back into the shared folder after removal.', + 'yn', 'n') + if answer.lower() not in ('y', 'yes'): + raise CommandError('share-folder', 'Operation cancelled.') + @staticmethod def prepare_request(params, kwargs, curr_sf, users, teams, rec_uids, *, default_record=False, default_account=False, @@ -466,7 +665,7 @@ def prepare_request(params, kwargs, curr_sf, users, teams, rec_uids, *, mu = kwargs.get('manage_users') def apply_share_expiration(target): - """Set expiration / timer / rotateOnExpiration on a User/Team/Record update proto.""" + """Set expiration / timer / rotateOnExpiration on a User/Team share update proto.""" if not isinstance(share_expiration, int): return if share_expiration > 0: @@ -488,13 +687,20 @@ def apply_share_expiration(target): rq.defaultManageUsers = folder_pb2.BOOLEAN_NO_CHANGE if len(users) > 0: - existing_users = {x['username'] for x in curr_sf.get('users', [])} for email in users: + current_user = _folder_user_lookup(curr_sf, email) + if current_user: + email = current_user['username'] uo = folder_pb2.SharedFolderUpdateUser() uo.username = email apply_share_expiration(uo) - if email in existing_users: + if current_user: if action == 'grant': + if rec_uids and mr is None and mu is None: + mr_unchanged = (current_user.get('manage_records') is True) + mu_unchanged = (current_user.get('manage_users') is True) + if mr_unchanged and mu_unchanged and not isinstance(share_expiration, int): + continue uo.manageRecords = folder_pb2.BOOLEAN_NO_CHANGE if mr is None else folder_pb2.BOOLEAN_TRUE if mr == 'on' else folder_pb2.BOOLEAN_FALSE uo.manageUsers = folder_pb2.BOOLEAN_NO_CHANGE if mu is None else folder_pb2.BOOLEAN_TRUE if mu == 'on' else folder_pb2.BOOLEAN_FALSE rq.sharedFolderUpdateUser.append(uo) @@ -575,39 +781,24 @@ def apply_share_expiration(target): ce = kwargs.get('can_edit') cs = kwargs.get('can_share') - if default_record and action == 'grant': + if default_record: rq.defaultCanEdit = folder_pb2.BOOLEAN_NO_CHANGE if ce is None else folder_pb2.BOOLEAN_TRUE if ce == 'on' else folder_pb2.BOOLEAN_FALSE rq.defaultCanShare = folder_pb2.BOOLEAN_NO_CHANGE if cs is None else folder_pb2.BOOLEAN_TRUE if cs == 'on' else folder_pb2.BOOLEAN_FALSE if len(rec_uids) > 0: existing_records = {x['record_uid'] for x in curr_sf.get('records', [])} for record_uid in rec_uids: - ro = folder_pb2.SharedFolderUpdateRecord() - ro.recordUid = utils.base64_url_decode(record_uid) - apply_share_expiration(ro) + folder_record_update = folder_pb2.SharedFolderUpdateRecord() + folder_record_update.recordUid = utils.base64_url_decode(record_uid) if record_uid in existing_records: - if action == 'grant': - ro.canEdit = folder_pb2.BOOLEAN_NO_CHANGE if ce is None else folder_pb2.BOOLEAN_TRUE if ce == 'on' else folder_pb2.BOOLEAN_FALSE - ro.canShare = folder_pb2.BOOLEAN_NO_CHANGE if cs is None else folder_pb2.BOOLEAN_TRUE if cs == 'on' else folder_pb2.BOOLEAN_FALSE - rq.sharedFolderUpdateRecord.append(ro) - elif action == 'remove': - rq.sharedFolderRemoveRecord.append(ro.recordUid) + folder_record_update.canEdit = folder_pb2.BOOLEAN_NO_CHANGE if ce is None else folder_pb2.BOOLEAN_TRUE if ce == 'on' else folder_pb2.BOOLEAN_FALSE + folder_record_update.canShare = folder_pb2.BOOLEAN_NO_CHANGE if cs is None else folder_pb2.BOOLEAN_TRUE if cs == 'on' else folder_pb2.BOOLEAN_FALSE + rq.sharedFolderUpdateRecord.append(folder_record_update) else: - if action == 'grant': - default_ce = folder_pb2.BOOLEAN_TRUE if curr_sf.get('default_can_edit') is True else folder_pb2.BOOLEAN_FALSE - default_cs = folder_pb2.BOOLEAN_TRUE if curr_sf.get('default_can_share') is True else folder_pb2.BOOLEAN_FALSE - ro.canEdit = default_ce if ce is None else folder_pb2.BOOLEAN_TRUE if ce == 'on' else folder_pb2.BOOLEAN_FALSE - ro.canShare = default_cs if cs is None else folder_pb2.BOOLEAN_TRUE if cs == 'on' else folder_pb2.BOOLEAN_FALSE - sf_key = curr_sf.get('shared_folder_key_unencrypted') - if sf_key: - rec = params.record_cache[record_uid] - rec_key = rec['record_key_unencrypted'] - if rec.get('version', 0) < 3: - ro.encryptedRecordKey = crypto.encrypt_aes_v1(rec_key, sf_key) - else: - ro.encryptedRecordKey = crypto.encrypt_aes_v2(rec_key, sf_key) - rq.sharedFolderAddRecord.append(ro) + logging.debug( + 'Record %s is not in shared folder %s; skipping record permission update', + record_uid, curr_sf.get('shared_folder_uid')) return rq @staticmethod @@ -622,7 +813,8 @@ def send_requests(params, partitioned_requests): try: rss = api.communicate_rest(params, rqs, 'vault/shared_folder_update_v3', payload_version=1, rs_type=folder_pb2.SharedFolderUpdateV3ResponseV2) - for rs in rss.sharedFoldersUpdateV3Response: + for rq_item, rs in zip(chunk, rss.sharedFoldersUpdateV3Response): + user_exp, team_exp = _folder_share_expiration_lookups(rq_item) team_cache = params.available_team_cache or [] for attr in ( 'sharedFolderAddTeamStatus', 'sharedFolderUpdateTeamStatus', @@ -634,11 +826,13 @@ def send_requests(params, partitioned_requests): team = next((x for x in team_cache if x.get('team_uid') == team_uid), None) if team: status = t.status + exp_text = format_share_expiration_ms(team_exp.get(team_uid, 0)) + exp_suffix = f', folder access expires {exp_text}' if exp_text else '' if status == 'success': - logging.info('Team share \'%s\' %s', team['team_name'], + logging.info('Team share \'%s\' %s%s', team['team_name'], 'added' if attr == 'sharedFolderAddTeamStatus' else 'updated' if attr == 'sharedFolderUpdateTeamStatus' else - 'removed') + 'removed', exp_suffix) else: logging.warning('Team share \'%s\' failed', team['team_name']) @@ -650,15 +844,27 @@ def send_requests(params, partitioned_requests): for s in statuses: username = s.username status = s.status + exp_text = format_share_expiration_ms(user_exp.get(username.lower(), 0)) if status == 'success': - logging.info('User share \'%s\' %s', username, - 'added' if attr == 'sharedFolderAddUserStatus' else - 'updated' if attr == 'sharedFolderUpdateUserStatus' else - 'removed') + if exp_text and attr in ( + 'sharedFolderAddUserStatus', 'sharedFolderUpdateUserStatus'): + logging.info( + 'Folder access granted to user \'%s\', expires %s', + username, exp_text) + elif attr == 'sharedFolderRemoveUserStatus': + logging.info( + 'Folder access removed from user \'%s\'', username) + else: + exp_suffix = f', folder access expires {exp_text}' if exp_text else '' + logging.info('User share \'%s\' %s%s', username, + 'added' if attr == 'sharedFolderAddUserStatus' else + 'updated' if attr == 'sharedFolderUpdateUserStatus' else + 'removed', exp_suffix) elif status == 'invited': logging.info('User \'%s\' invited', username) else: - logging.warning('User share \'%s\' failed', username) + logging.warning( + 'User share \'%s\' failed: %s', username, status) for attr in ('sharedFolderAddRecordStatus', 'sharedFolderUpdateRecordStatus', 'sharedFolderRemoveRecordStatus'): @@ -667,11 +873,7 @@ def send_requests(params, partitioned_requests): for r in statuses: record_uid = utils.base64_url_encode(r.recordUid) status = r.status - if record_uid in params.record_cache: - rec = api.get_record(params, record_uid) - title = rec.title - else: - title = record_uid + title = _record_share_log_title(params, record_uid) if status == 'success': logging.info('Record share \'%s\' %s', title, 'added' if attr == 'sharedFolderAddRecordStatus' else @@ -1077,6 +1279,7 @@ def send_requests(params, requests): left -= added rs = api.communicate_rest(params, rq1, 'vault/records_share_update', rs_type=record_pb2.RecordShareUpdateResponse) + record_exp = _record_share_expiration_lookup(rq1) for attr in ['addSharedRecordStatus', 'updateSharedRecordStatus', 'removeSharedRecordStatus']: if hasattr(rs, attr): statuses = getattr(rs, attr) @@ -1084,13 +1287,23 @@ def send_requests(params, requests): record_uid = utils.base64_url_encode(status_rs.recordUid) status = status_rs.status email = status_rs.username - if status == 'success': - verb = 'granted to' if attr == 'addSharedRecordStatus' else 'changed for' if attr == 'updateSharedRecordStatus' else 'revoked from' - logging.info('Record \"%s\" access permissions has been %s user \'%s\'', record_uid, verb, email) - else: - verb = 'grant' if attr == 'addSharedRecordStatus' else 'change' if attr == 'updateSharedRecordStatus' else 'revoke' - - logging.info('Failed to %s record \"%s\" access permissions for user \'%s\': %s', verb, record_uid, email, status_rs.message) + if status != 'success': + verb = ('grant' if attr == 'addSharedRecordStatus' + else 'change' if attr == 'updateSharedRecordStatus' + else 'revoke') + logging.info( + 'Failed to %s record \"%s\" access permissions for user \'%s\': %s', + verb, record_uid, email, status_rs.message) + continue + verb = ('granted to' if attr == 'addSharedRecordStatus' + else 'changed for' if attr == 'updateSharedRecordStatus' + else 'revoked from') + exp_text = format_share_expiration_ms( + record_exp.get((record_uid, email.lower()), 0)) + exp_suffix = f', record access expires {exp_text}' if exp_text else '' + logging.info( + 'Record \"%s\" access permissions has been %s user \'%s\'%s', + record_uid, verb, email, exp_suffix) rq = next(requests, None) diff --git a/keepercommander/proto/cnapp_pb2.py b/keepercommander/proto/cnapp_pb2.py new file mode 100644 index 000000000..a146a3e0e --- /dev/null +++ b/keepercommander/proto/cnapp_pb2.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: cnapp.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x63napp.proto\x12\x05\x43NAPP\"A\n\x15\x43nappQueueListRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x0cstatusFilter\x18\x02 \x01(\x05\"O\n\x16\x43nappQueueListResponse\x12$\n\x05items\x18\x01 \x03(\x0b\x32\x15.CNAPP.CnappQueueItem\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"\xd0\x01\n\x0e\x43nappQueueItem\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12-\n\x0f\x63nappProviderId\x18\x02 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x1a\n\x12\x63nappQueueStatusId\x18\x03 \x01(\x05\x12\x12\n\nreceivedAt\x18\x04 \x01(\x03\x12\x12\n\nresolvedAt\x18\x05 \x01(\x03\x12\x11\n\tnetworkId\x18\x06 \x01(\x0c\x12\x0f\n\x07payload\x18\x07 \x01(\x0c\x12\x11\n\trecordUid\x18\x08 \x01(\x0c\"@\n\x15\x43nappAssociateRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x63nappQueueId\x18\x02 \x01(\x05\"4\n\x16\x43nappAssociateResponse\x12\x1a\n\x12\x63nappQueueStatusId\x18\x01 \x01(\x05\"\x97\x02\n\x15\x43nappRemediateRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12\x31\n\nactionType\x18\x02 \x01(\x0e\x32\x1d.CNAPP.CnappRemediationAction\x12#\n\x1b\x63nappConfigurationRecordUid\x18\x03 \x01(\x0c\x12\x15\n\rpwdComplexity\x18\x04 \x01(\t\x12\x13\n\x0bresourceRef\x18\x05 \x01(\x0c\x12&\n\x08provider\x18\x06 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x15\n\rcontrollerUid\x18\x07 \x01(\t\x12\x12\n\nmessageUid\x18\x08 \x01(\x0c\x12\x11\n\tgroupName\x18\t \x01(\t\"w\n\x16\x43nappRemediateResponse\x12\x31\n\nactionType\x18\x01 \x01(\x0e\x32\x1d.CNAPP.CnappRemediationAction\x12\x0e\n\x06result\x18\x02 \x01(\t\x12\x1a\n\x12\x63nappQueueStatusId\x18\x03 \x01(\x05\"Y\n\x15\x43nappSetStatusRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12\x1a\n\x12\x63nappQueueStatusId\x18\x02 \x01(\x05\x12\x0e\n\x06reason\x18\x03 \x01(\t\"4\n\x16\x43nappSetStatusResponse\x12\x1a\n\x12\x63nappQueueStatusId\x18\x01 \x01(\x05\"3\n\x1b\x43nappDeleteQueueItemRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\"\x1e\n\x1c\x43nappDeleteQueueItemResponse\"\xc7\x01\n\x12\x43nappConfiguration\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12&\n\x08provider\x18\x02 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x10\n\x08\x63lientId\x18\x03 \x01(\t\x12\x14\n\x0c\x63lientSecret\x18\x04 \x01(\t\x12\x16\n\x0e\x61piEndpointUrl\x18\x05 \x01(\t\x12\x1c\n\x14\x63nappConfigRecordUid\x18\x06 \x01(\x0c\x12\x17\n\x0f\x61uthEndpointUrl\x18\x07 \x01(\t\"5\n\x1f\x43nappDeleteConfigurationRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\"5\n\x19\x43nappTestEncrypterRequest\x12\x18\n\x10urlBaseEncrypter\x18\x01 \x01(\t*G\n\rCnappProvider\x12\x1e\n\x1a\x43NAPP_PROVIDER_UNSPECIFIED\x10\x00\x12\x16\n\x12\x43NAPP_PROVIDER_WIZ\x10\x01*\x83\x01\n\x16\x43nappRemediationAction\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\x16\n\x12ROTATE_CREDENTIALS\x10\x01\x12\x11\n\rMANAGE_ACCESS\x10\x02\x12\x0e\n\nJIT_ACCESS\x10\x03\x12\x1d\n\x19REMOVE_STANDING_PRIVILEGE\x10\x04\x42!\n\x18\x63om.keepersecurity.protoB\x05\x43nappb\x06proto3') + +_CNAPPPROVIDER = DESCRIPTOR.enum_types_by_name['CnappProvider'] +CnappProvider = enum_type_wrapper.EnumTypeWrapper(_CNAPPPROVIDER) +_CNAPPREMEDIATIONACTION = DESCRIPTOR.enum_types_by_name['CnappRemediationAction'] +CnappRemediationAction = enum_type_wrapper.EnumTypeWrapper(_CNAPPREMEDIATIONACTION) +CNAPP_PROVIDER_UNSPECIFIED = 0 +CNAPP_PROVIDER_WIZ = 1 +UNSPECIFIED = 0 +ROTATE_CREDENTIALS = 1 +MANAGE_ACCESS = 2 +JIT_ACCESS = 3 +REMOVE_STANDING_PRIVILEGE = 4 + + +_CNAPPQUEUELISTREQUEST = DESCRIPTOR.message_types_by_name['CnappQueueListRequest'] +_CNAPPQUEUELISTRESPONSE = DESCRIPTOR.message_types_by_name['CnappQueueListResponse'] +_CNAPPQUEUEITEM = DESCRIPTOR.message_types_by_name['CnappQueueItem'] +_CNAPPASSOCIATEREQUEST = DESCRIPTOR.message_types_by_name['CnappAssociateRequest'] +_CNAPPASSOCIATERESPONSE = DESCRIPTOR.message_types_by_name['CnappAssociateResponse'] +_CNAPPREMEDIATEREQUEST = DESCRIPTOR.message_types_by_name['CnappRemediateRequest'] +_CNAPPREMEDIATERESPONSE = DESCRIPTOR.message_types_by_name['CnappRemediateResponse'] +_CNAPPSETSTATUSREQUEST = DESCRIPTOR.message_types_by_name['CnappSetStatusRequest'] +_CNAPPSETSTATUSRESPONSE = DESCRIPTOR.message_types_by_name['CnappSetStatusResponse'] +_CNAPPDELETEQUEUEITEMREQUEST = DESCRIPTOR.message_types_by_name['CnappDeleteQueueItemRequest'] +_CNAPPDELETEQUEUEITEMRESPONSE = DESCRIPTOR.message_types_by_name['CnappDeleteQueueItemResponse'] +_CNAPPCONFIGURATION = DESCRIPTOR.message_types_by_name['CnappConfiguration'] +_CNAPPDELETECONFIGURATIONREQUEST = DESCRIPTOR.message_types_by_name['CnappDeleteConfigurationRequest'] +_CNAPPTESTENCRYPTERREQUEST = DESCRIPTOR.message_types_by_name['CnappTestEncrypterRequest'] +CnappQueueListRequest = _reflection.GeneratedProtocolMessageType('CnappQueueListRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPQUEUELISTREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappQueueListRequest) + }) +_sym_db.RegisterMessage(CnappQueueListRequest) + +CnappQueueListResponse = _reflection.GeneratedProtocolMessageType('CnappQueueListResponse', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPQUEUELISTRESPONSE, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappQueueListResponse) + }) +_sym_db.RegisterMessage(CnappQueueListResponse) + +CnappQueueItem = _reflection.GeneratedProtocolMessageType('CnappQueueItem', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPQUEUEITEM, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappQueueItem) + }) +_sym_db.RegisterMessage(CnappQueueItem) + +CnappAssociateRequest = _reflection.GeneratedProtocolMessageType('CnappAssociateRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPASSOCIATEREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappAssociateRequest) + }) +_sym_db.RegisterMessage(CnappAssociateRequest) + +CnappAssociateResponse = _reflection.GeneratedProtocolMessageType('CnappAssociateResponse', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPASSOCIATERESPONSE, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappAssociateResponse) + }) +_sym_db.RegisterMessage(CnappAssociateResponse) + +CnappRemediateRequest = _reflection.GeneratedProtocolMessageType('CnappRemediateRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPREMEDIATEREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappRemediateRequest) + }) +_sym_db.RegisterMessage(CnappRemediateRequest) + +CnappRemediateResponse = _reflection.GeneratedProtocolMessageType('CnappRemediateResponse', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPREMEDIATERESPONSE, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappRemediateResponse) + }) +_sym_db.RegisterMessage(CnappRemediateResponse) + +CnappSetStatusRequest = _reflection.GeneratedProtocolMessageType('CnappSetStatusRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPSETSTATUSREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappSetStatusRequest) + }) +_sym_db.RegisterMessage(CnappSetStatusRequest) + +CnappSetStatusResponse = _reflection.GeneratedProtocolMessageType('CnappSetStatusResponse', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPSETSTATUSRESPONSE, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappSetStatusResponse) + }) +_sym_db.RegisterMessage(CnappSetStatusResponse) + +CnappDeleteQueueItemRequest = _reflection.GeneratedProtocolMessageType('CnappDeleteQueueItemRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPDELETEQUEUEITEMREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappDeleteQueueItemRequest) + }) +_sym_db.RegisterMessage(CnappDeleteQueueItemRequest) + +CnappDeleteQueueItemResponse = _reflection.GeneratedProtocolMessageType('CnappDeleteQueueItemResponse', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPDELETEQUEUEITEMRESPONSE, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappDeleteQueueItemResponse) + }) +_sym_db.RegisterMessage(CnappDeleteQueueItemResponse) + +CnappConfiguration = _reflection.GeneratedProtocolMessageType('CnappConfiguration', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPCONFIGURATION, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappConfiguration) + }) +_sym_db.RegisterMessage(CnappConfiguration) + +CnappDeleteConfigurationRequest = _reflection.GeneratedProtocolMessageType('CnappDeleteConfigurationRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPDELETECONFIGURATIONREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappDeleteConfigurationRequest) + }) +_sym_db.RegisterMessage(CnappDeleteConfigurationRequest) + +CnappTestEncrypterRequest = _reflection.GeneratedProtocolMessageType('CnappTestEncrypterRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPTESTENCRYPTERREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappTestEncrypterRequest) + }) +_sym_db.RegisterMessage(CnappTestEncrypterRequest) + +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\030com.keepersecurity.protoB\005Cnapp' + _CNAPPPROVIDER._serialized_start=1446 + _CNAPPPROVIDER._serialized_end=1517 + _CNAPPREMEDIATIONACTION._serialized_start=1520 + _CNAPPREMEDIATIONACTION._serialized_end=1651 + _CNAPPQUEUELISTREQUEST._serialized_start=22 + _CNAPPQUEUELISTREQUEST._serialized_end=87 + _CNAPPQUEUELISTRESPONSE._serialized_start=89 + _CNAPPQUEUELISTRESPONSE._serialized_end=168 + _CNAPPQUEUEITEM._serialized_start=171 + _CNAPPQUEUEITEM._serialized_end=379 + _CNAPPASSOCIATEREQUEST._serialized_start=381 + _CNAPPASSOCIATEREQUEST._serialized_end=445 + _CNAPPASSOCIATERESPONSE._serialized_start=447 + _CNAPPASSOCIATERESPONSE._serialized_end=499 + _CNAPPREMEDIATEREQUEST._serialized_start=502 + _CNAPPREMEDIATEREQUEST._serialized_end=781 + _CNAPPREMEDIATERESPONSE._serialized_start=783 + _CNAPPREMEDIATERESPONSE._serialized_end=902 + _CNAPPSETSTATUSREQUEST._serialized_start=904 + _CNAPPSETSTATUSREQUEST._serialized_end=993 + _CNAPPSETSTATUSRESPONSE._serialized_start=995 + _CNAPPSETSTATUSRESPONSE._serialized_end=1047 + _CNAPPDELETEQUEUEITEMREQUEST._serialized_start=1049 + _CNAPPDELETEQUEUEITEMREQUEST._serialized_end=1100 + _CNAPPDELETEQUEUEITEMRESPONSE._serialized_start=1102 + _CNAPPDELETEQUEUEITEMRESPONSE._serialized_end=1132 + _CNAPPCONFIGURATION._serialized_start=1135 + _CNAPPCONFIGURATION._serialized_end=1334 + _CNAPPDELETECONFIGURATIONREQUEST._serialized_start=1336 + _CNAPPDELETECONFIGURATIONREQUEST._serialized_end=1389 + _CNAPPTESTENCRYPTERREQUEST._serialized_start=1391 + _CNAPPTESTENCRYPTERREQUEST._serialized_end=1444 +# @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/pam_pb2.py b/keepercommander/proto/pam_pb2.py index 9f8a54d8a..05f9d0a0c 100644 --- a/keepercommander/proto/pam_pb2.py +++ b/keepercommander/proto/pam_pb2.py @@ -26,7 +26,7 @@ from . import record_pb2 as record__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tpam.proto\x12\x03PAM\x1a\x10\x65nterprise.proto\x1a\x0crecord.proto\"\x83\x01\n\x13PAMRotationSchedule\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x63onfigurationUid\x18\x02 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x14\n\x0cscheduleData\x18\x04 \x01(\t\x12\x12\n\nnoSchedule\x18\x05 \x01(\x08\"K\n\x1cPAMRotationSchedulesResponse\x12+\n\tschedules\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRotationSchedule\"\x94\x01\n\x13PAMOnlineController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63onnectedOn\x18\x02 \x01(\x03\x12\x11\n\tipAddress\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12-\n\x0b\x63onnections\x18\x05 \x03(\x0b\x32\x18.PAM.PAMWebRtcConnection\"\xa7\x01\n\x13PAMWebRtcConnection\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\'\n\x04type\x18\x02 \x01(\x0e\x32\x19.PAM.WebRtcConnectionType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x18\n\x10\x63onfigurationUid\x18\x06 \x01(\x0c\"Y\n\x14PAMOnlineControllers\x12\x12\n\ndeprecated\x18\x01 \x03(\x0c\x12-\n\x0b\x63ontrollers\x18\x02 \x03(\x0b\x32\x18.PAM.PAMOnlineController\"9\n\x10PAMRotateRequest\x12\x12\n\nrequestUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\"A\n\x16PAMControllersResponse\x12\'\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x12.PAM.PAMController\"=\n\x13PAMRemoveController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\t\"L\n\x1bPAMRemoveControllerResponse\x12-\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRemoveController\"=\n\x10PAMModifyRequest\x12)\n\noperations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMDataOperation\"\x98\x01\n\x10PAMDataOperation\x12,\n\roperationType\x18\x01 \x01(\x0e\x32\x15.PAM.PAMOperationType\x12\x30\n\rconfiguration\x18\x02 \x01(\x0b\x32\x19.PAM.PAMConfigurationData\x12$\n\x07\x65lement\x18\x03 \x01(\x0b\x32\x13.PAM.PAMElementData\"e\n\x14PAMConfigurationData\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"E\n\x0ePAMElementData\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"m\n\x19PAMElementOperationResult\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12+\n\x06result\x18\x02 \x01(\x0e\x32\x1b.PAM.PAMOperationResultType\x12\x0f\n\x07message\x18\x03 \x01(\t\"B\n\x0fPAMModifyResult\x12/\n\x07results\x18\x01 \x03(\x0b\x32\x1e.PAM.PAMElementOperationResult\"x\n\nPAMElement\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x03 \x01(\x03\x12\x14\n\x0clastModified\x18\x04 \x01(\x03\x12!\n\x08\x63hildren\x18\x05 \x03(\x0b\x32\x0f.PAM.PAMElement\"#\n\x14PAMGenericUidRequest\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"%\n\x15PAMGenericUidsRequest\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\xab\x01\n\x10PAMConfiguration\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12!\n\x08\x63hildren\x18\x07 \x03(\x0b\x32\x0f.PAM.PAMElement\"B\n\x11PAMConfigurations\x12-\n\x0e\x63onfigurations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMConfiguration\"\xff\x01\n\rPAMController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x03 \x01(\t\x12\x12\n\ndeviceName\x18\x04 \x01(\t\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x0f\n\x07\x63reated\x18\x06 \x01(\x03\x12\x14\n\x0clastModified\x18\x07 \x01(\x03\x12\x16\n\x0e\x61pplicationUid\x18\x08 \x01(\x0c\x12\x30\n\rappClientType\x18\t \x01(\x0e\x32\x19.Enterprise.AppClientType\x12\x15\n\risInitialized\x18\n \x01(\x08\"P\n\x1dPAMSetMaxInstanceCountRequest\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x18\n\x10maxInstanceCount\x18\x02 \x01(\x05\"%\n\x12\x43ontrollerResponse\x12\x0f\n\x07payload\x18\x01 \x01(\t\"M\n\x1aPAMConfigurationController\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x02 \x01(\x0c\"\xa3\x01\n\x17\x43onfigurationAddRequest\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12(\n\x0brecordLinks\x18\x04 \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\x05 \x01(\x0b\x32\x14.Records.RecordAudit\"J\n\x10RelayAccessCreds\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x12\n\nserverTime\x18\x03 \x01(\x03\"\x81\x02\n\x14PAMRecordingsRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\xd4\x02\n\x0cPAMRecording\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12,\n\rrecordingType\x18\x02 \x01(\x0e\x32\x15.PAM.PAMRecordingType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x0e\n\x06length\x18\x06 \x01(\x05\x12\x10\n\x08\x66ileSize\x18\x07 \x01(\x03\x12\x11\n\tcreatedOn\x18\x08 \x01(\x03\x12\x10\n\x08protocol\x18\t \x01(\t\x12\x13\n\x0b\x63loseReason\x18\n \x01(\x05\x12\x19\n\x11recordingDuration\x18\x0b \x01(\x05\x12\x36\n\x12\x61iOverallRiskLevel\x18\x0c \x01(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x18\n\x10\x61iOverallSummary\x18\r \x01(\x0c\"O\n\x15PAMRecordingsResponse\x12%\n\nrecordings\x18\x01 \x03(\x0b\x32\x11.PAM.PAMRecording\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"*\n\x07PAMData\x12\x0e\n\x06vertex\x18\x01 \x01(\x0c\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\x0c\"\x17\n\x07UidList\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\xb4\x03\n\x11PAMResourceConfig\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\nnetworkUid\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\x15\n\x08\x61\x64minUid\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x11\n\x04meta\x18\x04 \x01(\x0cH\x02\x88\x01\x01\x12\x1f\n\x12\x63onnectionSettings\x18\x05 \x01(\x0cH\x03\x88\x01\x01\x12\'\n\x0c\x63onnectUsers\x18\x06 \x01(\x0b\x32\x0c.PAM.UidListH\x04\x88\x01\x01\x12\x16\n\tdomainUid\x18\x07 \x01(\x0cH\x05\x88\x01\x01\x12\x18\n\x0bjitSettings\x18\x08 \x01(\x0cH\x06\x88\x01\x01\x12\x1d\n\x10keeperAiSettings\x18\t \x01(\x0cH\x07\x88\x01\x01\x12\x1b\n\x0eupdateServices\x18\n \x01(\x08H\x08\x88\x01\x01\x42\r\n\x0b_networkUidB\x0b\n\t_adminUidB\x07\n\x05_metaB\x15\n\x13_connectionSettingsB\x0f\n\r_connectUsersB\x0c\n\n_domainUidB\x0e\n\x0c_jitSettingsB\x13\n\x11_keeperAiSettingsB\x11\n\x0f_updateServices\"%\n\x16PAMUniversalSyncFolder\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"\xfc\x01\n\x16PAMUniversalSyncConfig\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x07\x65nabled\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\rdryRunEnabled\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12,\n\x07\x66olders\x18\x04 \x03(\x0b\x32\x1b.PAM.PAMUniversalSyncFolder\x12\x19\n\x0csyncIdentity\x18\x05 \x01(\x0cH\x02\x88\x01\x01\x12\x16\n\tvaultName\x18\x06 \x01(\x0cH\x03\x88\x01\x01\x42\n\n\x08_enabledB\x10\n\x0e_dryRunEnabledB\x0f\n\r_syncIdentityB\x0c\n\n_vaultName\"7\n\x11NhiMetricsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"\x9c\x02\n\x0ePamUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12!\n\x19recordRotationScheduledOk\x18\x02 \x01(\x05\x12\x1c\n\x14pamConnectionStarted\x18\x03 \x01(\x05\x12\x18\n\x10pamTunnelStarted\x18\x04 \x01(\x05\x12\x1b\n\x13\x64iscoveryJobStarted\x18\x05 \x01(\x05\x12 \n\x18recordRotationOnDemandOk\x18\x06 \x01(\x05\x12\"\n\x1apamSessionRecordingStarted\x18\x07 \x01(\x05\x12\x15\n\rpamRbiStarted\x18\x08 \x01(\x05\x12%\n\x1dpamSessionRbiRecordingStarted\x18\t \x01(\x05\"p\n\x0eNhiUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trotations\x18\x02 \x01(\x05\x12\x0f\n\x07tunnels\x18\x03 \x01(\x05\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x15\n\rdiscoveryJobs\x18\x05 \x01(\x05\"\x84\x02\n\x12NhiMetricsResponse\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x03\x12\x11\n\tstartTime\x18\x02 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x03 \x01(\x03\x12\x18\n\x10uniqueKsmDevices\x18\x04 \x01(\x05\x12\x18\n\x10pamGatewayOnline\x18\x05 \x01(\x05\x12/\n\x0epamUsageByUser\x18\x06 \x03(\x0b\x32\x13.PAM.PamUsageByUserB\x02\x18\x01\x12\x10\n\x08nhiCount\x18\x07 \x01(\x05\x12\x13\n\x0bksmNhiCount\x18\x08 \x01(\x05\x12(\n\x0busageByUser\x18\t \x03(\x0b\x32\x13.PAM.NhiUsageByUser\"D\n\x16NhiBulkMetricsResponse\x12*\n\tresponses\x18\x01 \x03(\x0b\x32\x17.PAM.NhiMetricsResponse\"N\n\x0bNhiUidEntry\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\"\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32\x10.PAM.NhiCategory\x12\x0e\n\x06ksmNhi\x18\x03 \x01(\x08\"7\n\x11GetNhiUidsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"4\n\x12GetNhiUidsResponse\x12\x1e\n\x04uids\x18\x01 \x03(\x0b\x32\x10.PAM.NhiUidEntry\"I\n\x1fPAMUniversalSyncPreCheckRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x12\n\nfolderUids\x18\x02 \x03(\x0c\"C\n\x1ePAMUniversalSyncPreCheckResult\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x0e\n\x06isUsed\x18\x02 \x01(\x08\"X\n PAMUniversalSyncPreCheckResponse\x12\x34\n\x07results\x18\x01 \x03(\x0b\x32#.PAM.PAMUniversalSyncPreCheckResult*\x9e\x01\n\x14WebRtcConnectionType\x12\x0e\n\nCONNECTION\x10\x00\x12\n\n\x06TUNNEL\x10\x01\x12\x07\n\x03SSH\x10\x02\x12\x07\n\x03RDP\x10\x03\x12\x08\n\x04HTTP\x10\x04\x12\x07\n\x03VNC\x10\x05\x12\n\n\x06TELNET\x10\x06\x12\t\n\x05MYSQL\x10\x07\x12\x0e\n\nSQL_SERVER\x10\x08\x12\x0e\n\nPOSTGRESQL\x10\t\x12\x0e\n\nKUBERNETES\x10\n*@\n\x10PAMOperationType\x12\x07\n\x03\x41\x44\x44\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\x0b\n\x07REPLACE\x10\x02\x12\n\n\x06\x44\x45LETE\x10\x03*p\n\x16PAMOperationResultType\x12\x0f\n\x0bPOT_SUCCESS\x10\x00\x12\x15\n\x11POT_UNKNOWN_ERROR\x10\x01\x12\x16\n\x12POT_ALREADY_EXISTS\x10\x02\x12\x16\n\x12POT_DOES_NOT_EXIST\x10\x03*\xc9\x01\n\x15\x43ontrollerMessageType\x12\x0f\n\x0b\x43MT_GENERAL\x10\x00\x12\x0e\n\nCMT_ROTATE\x10\x01\x12\x11\n\rCMT_DISCOVERY\x10\x02\x12\x0f\n\x0b\x43MT_CONNECT\x10\x03\x12\x19\n\x15\x43MT_ANALYZE_RECORDING\x10\x04\x12!\n\x1d\x43MT_WORKFLOW_ACCESS_ELEVATION\x10\x05\x12\x0b\n\x07\x43MT_USS\x10\x06\x12\x0c\n\x08\x43MT_INFO\x10\x07\x12\x12\n\x0e\x43MT_AUTOMATION\x10\x08*V\n\x10PAMRecordingType\x12\x0f\n\x0bPRT_SESSION\x10\x00\x12\x12\n\x0ePRT_TYPESCRIPT\x10\x01\x12\x0c\n\x08PRT_TIME\x10\x02\x12\x0f\n\x0bPRT_SUMMARY\x10\x03*i\n\x15PAMRecordingRiskLevel\x12\x13\n\x0fPRR_UNSPECIFIED\x10\x00\x12\x0b\n\x07PRR_LOW\x10\x01\x12\x0e\n\nPRR_MEDIUM\x10\x02\x12\x0c\n\x08PRR_HIGH\x10\x03\x12\x10\n\x0cPRR_CRITICAL\x10\x04*`\n\x0bNhiCategory\x12\x18\n\x14NHI_CATEGORY_UNKNOWN\x10\x00\x12\x0c\n\x08PAM_USER\x10\x01\x12\x10\n\x0cPAM_RESOURCE\x10\x02\x12\x0b\n\x07GATEWAY\x10\x03\x12\n\n\x06\x44\x45VICE\x10\x04\x42\x1f\n\x18\x63om.keepersecurity.protoB\x03PAMb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tpam.proto\x12\x03PAM\x1a\x10\x65nterprise.proto\x1a\x0crecord.proto\"\x83\x01\n\x13PAMRotationSchedule\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x63onfigurationUid\x18\x02 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x14\n\x0cscheduleData\x18\x04 \x01(\t\x12\x12\n\nnoSchedule\x18\x05 \x01(\x08\"K\n\x1cPAMRotationSchedulesResponse\x12+\n\tschedules\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRotationSchedule\"\x94\x01\n\x13PAMOnlineController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63onnectedOn\x18\x02 \x01(\x03\x12\x11\n\tipAddress\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12-\n\x0b\x63onnections\x18\x05 \x03(\x0b\x32\x18.PAM.PAMWebRtcConnection\"\xa7\x01\n\x13PAMWebRtcConnection\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\'\n\x04type\x18\x02 \x01(\x0e\x32\x19.PAM.WebRtcConnectionType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x18\n\x10\x63onfigurationUid\x18\x06 \x01(\x0c\"Y\n\x14PAMOnlineControllers\x12\x12\n\ndeprecated\x18\x01 \x03(\x0c\x12-\n\x0b\x63ontrollers\x18\x02 \x03(\x0b\x32\x18.PAM.PAMOnlineController\"9\n\x10PAMRotateRequest\x12\x12\n\nrequestUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\"A\n\x16PAMControllersResponse\x12\'\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x12.PAM.PAMController\"=\n\x13PAMRemoveController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\t\"L\n\x1bPAMRemoveControllerResponse\x12-\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRemoveController\"=\n\x10PAMModifyRequest\x12)\n\noperations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMDataOperation\"\x98\x01\n\x10PAMDataOperation\x12,\n\roperationType\x18\x01 \x01(\x0e\x32\x15.PAM.PAMOperationType\x12\x30\n\rconfiguration\x18\x02 \x01(\x0b\x32\x19.PAM.PAMConfigurationData\x12$\n\x07\x65lement\x18\x03 \x01(\x0b\x32\x13.PAM.PAMElementData\"e\n\x14PAMConfigurationData\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"E\n\x0ePAMElementData\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"m\n\x19PAMElementOperationResult\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12+\n\x06result\x18\x02 \x01(\x0e\x32\x1b.PAM.PAMOperationResultType\x12\x0f\n\x07message\x18\x03 \x01(\t\"B\n\x0fPAMModifyResult\x12/\n\x07results\x18\x01 \x03(\x0b\x32\x1e.PAM.PAMElementOperationResult\"x\n\nPAMElement\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x03 \x01(\x03\x12\x14\n\x0clastModified\x18\x04 \x01(\x03\x12!\n\x08\x63hildren\x18\x05 \x03(\x0b\x32\x0f.PAM.PAMElement\"#\n\x14PAMGenericUidRequest\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"%\n\x15PAMGenericUidsRequest\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\xab\x01\n\x10PAMConfiguration\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12!\n\x08\x63hildren\x18\x07 \x03(\x0b\x32\x0f.PAM.PAMElement\"B\n\x11PAMConfigurations\x12-\n\x0e\x63onfigurations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMConfiguration\"\xff\x01\n\rPAMController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x03 \x01(\t\x12\x12\n\ndeviceName\x18\x04 \x01(\t\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x0f\n\x07\x63reated\x18\x06 \x01(\x03\x12\x14\n\x0clastModified\x18\x07 \x01(\x03\x12\x16\n\x0e\x61pplicationUid\x18\x08 \x01(\x0c\x12\x30\n\rappClientType\x18\t \x01(\x0e\x32\x19.Enterprise.AppClientType\x12\x15\n\risInitialized\x18\n \x01(\x08\"P\n\x1dPAMSetMaxInstanceCountRequest\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x18\n\x10maxInstanceCount\x18\x02 \x01(\x05\"%\n\x12\x43ontrollerResponse\x12\x0f\n\x07payload\x18\x01 \x01(\t\"M\n\x1aPAMConfigurationController\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x02 \x01(\x0c\"\xa3\x01\n\x17\x43onfigurationAddRequest\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12(\n\x0brecordLinks\x18\x04 \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\x05 \x01(\x0b\x32\x14.Records.RecordAudit\"J\n\x10RelayAccessCreds\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x12\n\nserverTime\x18\x03 \x01(\x03\"\x81\x02\n\x14PAMRecordingsRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\x89\x02\n\x1cPAMRecordingsForUsersRequest\x12\x11\n\tusernames\x18\x01 \x03(\t\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\xd4\x02\n\x0cPAMRecording\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12,\n\rrecordingType\x18\x02 \x01(\x0e\x32\x15.PAM.PAMRecordingType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x0e\n\x06length\x18\x06 \x01(\x05\x12\x10\n\x08\x66ileSize\x18\x07 \x01(\x03\x12\x11\n\tcreatedOn\x18\x08 \x01(\x03\x12\x10\n\x08protocol\x18\t \x01(\t\x12\x13\n\x0b\x63loseReason\x18\n \x01(\x05\x12\x19\n\x11recordingDuration\x18\x0b \x01(\x05\x12\x36\n\x12\x61iOverallRiskLevel\x18\x0c \x01(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x18\n\x10\x61iOverallSummary\x18\r \x01(\x0c\"O\n\x15PAMRecordingsResponse\x12%\n\nrecordings\x18\x01 \x03(\x0b\x32\x11.PAM.PAMRecording\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"*\n\x07PAMData\x12\x0e\n\x06vertex\x18\x01 \x01(\x0c\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\x0c\"\x17\n\x07UidList\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\x84\x03\n\x11PAMResourceConfig\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\nnetworkUid\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\x15\n\x08\x61\x64minUid\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x11\n\x04meta\x18\x04 \x01(\x0cH\x02\x88\x01\x01\x12\x1f\n\x12\x63onnectionSettings\x18\x05 \x01(\x0cH\x03\x88\x01\x01\x12\'\n\x0c\x63onnectUsers\x18\x06 \x01(\x0b\x32\x0c.PAM.UidListH\x04\x88\x01\x01\x12\x16\n\tdomainUid\x18\x07 \x01(\x0cH\x05\x88\x01\x01\x12\x18\n\x0bjitSettings\x18\x08 \x01(\x0cH\x06\x88\x01\x01\x12\x1d\n\x10keeperAiSettings\x18\t \x01(\x0cH\x07\x88\x01\x01\x42\r\n\x0b_networkUidB\x0b\n\t_adminUidB\x07\n\x05_metaB\x15\n\x13_connectionSettingsB\x0f\n\r_connectUsersB\x0c\n\n_domainUidB\x0e\n\x0c_jitSettingsB\x13\n\x11_keeperAiSettings\"%\n\x16PAMUniversalSyncFolder\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"\xfc\x01\n\x16PAMUniversalSyncConfig\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x07\x65nabled\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\rdryRunEnabled\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12,\n\x07\x66olders\x18\x04 \x03(\x0b\x32\x1b.PAM.PAMUniversalSyncFolder\x12\x19\n\x0csyncIdentity\x18\x05 \x01(\x0cH\x02\x88\x01\x01\x12\x16\n\tvaultName\x18\x06 \x01(\x0cH\x03\x88\x01\x01\x42\n\n\x08_enabledB\x10\n\x0e_dryRunEnabledB\x0f\n\r_syncIdentityB\x0c\n\n_vaultName\"7\n\x11NhiMetricsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"\x9c\x02\n\x0ePamUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12!\n\x19recordRotationScheduledOk\x18\x02 \x01(\x05\x12\x1c\n\x14pamConnectionStarted\x18\x03 \x01(\x05\x12\x18\n\x10pamTunnelStarted\x18\x04 \x01(\x05\x12\x1b\n\x13\x64iscoveryJobStarted\x18\x05 \x01(\x05\x12 \n\x18recordRotationOnDemandOk\x18\x06 \x01(\x05\x12\"\n\x1apamSessionRecordingStarted\x18\x07 \x01(\x05\x12\x15\n\rpamRbiStarted\x18\x08 \x01(\x05\x12%\n\x1dpamSessionRbiRecordingStarted\x18\t \x01(\x05\"p\n\x0eNhiUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trotations\x18\x02 \x01(\x05\x12\x0f\n\x07tunnels\x18\x03 \x01(\x05\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x15\n\rdiscoveryJobs\x18\x05 \x01(\x05\"\x84\x02\n\x12NhiMetricsResponse\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x03\x12\x11\n\tstartTime\x18\x02 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x03 \x01(\x03\x12\x18\n\x10uniqueKsmDevices\x18\x04 \x01(\x05\x12\x18\n\x10pamGatewayOnline\x18\x05 \x01(\x05\x12/\n\x0epamUsageByUser\x18\x06 \x03(\x0b\x32\x13.PAM.PamUsageByUserB\x02\x18\x01\x12\x10\n\x08nhiCount\x18\x07 \x01(\x05\x12\x13\n\x0bksmNhiCount\x18\x08 \x01(\x05\x12(\n\x0busageByUser\x18\t \x03(\x0b\x32\x13.PAM.NhiUsageByUser\"D\n\x16NhiBulkMetricsResponse\x12*\n\tresponses\x18\x01 \x03(\x0b\x32\x17.PAM.NhiMetricsResponse\"N\n\x0bNhiUidEntry\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\"\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32\x10.PAM.NhiCategory\x12\x0e\n\x06ksmNhi\x18\x03 \x01(\x08\"7\n\x11GetNhiUidsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"4\n\x12GetNhiUidsResponse\x12\x1e\n\x04uids\x18\x01 \x03(\x0b\x32\x10.PAM.NhiUidEntry*\x9e\x01\n\x14WebRtcConnectionType\x12\x0e\n\nCONNECTION\x10\x00\x12\n\n\x06TUNNEL\x10\x01\x12\x07\n\x03SSH\x10\x02\x12\x07\n\x03RDP\x10\x03\x12\x08\n\x04HTTP\x10\x04\x12\x07\n\x03VNC\x10\x05\x12\n\n\x06TELNET\x10\x06\x12\t\n\x05MYSQL\x10\x07\x12\x0e\n\nSQL_SERVER\x10\x08\x12\x0e\n\nPOSTGRESQL\x10\t\x12\x0e\n\nKUBERNETES\x10\n*@\n\x10PAMOperationType\x12\x07\n\x03\x41\x44\x44\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\x0b\n\x07REPLACE\x10\x02\x12\n\n\x06\x44\x45LETE\x10\x03*p\n\x16PAMOperationResultType\x12\x0f\n\x0bPOT_SUCCESS\x10\x00\x12\x15\n\x11POT_UNKNOWN_ERROR\x10\x01\x12\x16\n\x12POT_ALREADY_EXISTS\x10\x02\x12\x16\n\x12POT_DOES_NOT_EXIST\x10\x03*\xc9\x01\n\x15\x43ontrollerMessageType\x12\x0f\n\x0b\x43MT_GENERAL\x10\x00\x12\x0e\n\nCMT_ROTATE\x10\x01\x12\x11\n\rCMT_DISCOVERY\x10\x02\x12\x0f\n\x0b\x43MT_CONNECT\x10\x03\x12\x19\n\x15\x43MT_ANALYZE_RECORDING\x10\x04\x12!\n\x1d\x43MT_WORKFLOW_ACCESS_ELEVATION\x10\x05\x12\x0b\n\x07\x43MT_USS\x10\x06\x12\x0c\n\x08\x43MT_INFO\x10\x07\x12\x12\n\x0e\x43MT_AUTOMATION\x10\x08*V\n\x10PAMRecordingType\x12\x0f\n\x0bPRT_SESSION\x10\x00\x12\x12\n\x0ePRT_TYPESCRIPT\x10\x01\x12\x0c\n\x08PRT_TIME\x10\x02\x12\x0f\n\x0bPRT_SUMMARY\x10\x03*i\n\x15PAMRecordingRiskLevel\x12\x13\n\x0fPRR_UNSPECIFIED\x10\x00\x12\x0b\n\x07PRR_LOW\x10\x01\x12\x0e\n\nPRR_MEDIUM\x10\x02\x12\x0c\n\x08PRR_HIGH\x10\x03\x12\x10\n\x0cPRR_CRITICAL\x10\x04*`\n\x0bNhiCategory\x12\x18\n\x14NHI_CATEGORY_UNKNOWN\x10\x00\x12\x0c\n\x08PAM_USER\x10\x01\x12\x10\n\x0cPAM_RESOURCE\x10\x02\x12\x0b\n\x07GATEWAY\x10\x03\x12\n\n\x06\x44\x45VICE\x10\x04\x42\x1f\n\x18\x63om.keepersecurity.protoB\x03PAMb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -36,20 +36,20 @@ _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\003PAM' _globals['_NHIMETRICSRESPONSE'].fields_by_name['pamUsageByUser']._loaded_options = None _globals['_NHIMETRICSRESPONSE'].fields_by_name['pamUsageByUser']._serialized_options = b'\030\001' - _globals['_WEBRTCCONNECTIONTYPE']._serialized_start=5354 - _globals['_WEBRTCCONNECTIONTYPE']._serialized_end=5512 - _globals['_PAMOPERATIONTYPE']._serialized_start=5514 - _globals['_PAMOPERATIONTYPE']._serialized_end=5578 - _globals['_PAMOPERATIONRESULTTYPE']._serialized_start=5580 - _globals['_PAMOPERATIONRESULTTYPE']._serialized_end=5692 - _globals['_CONTROLLERMESSAGETYPE']._serialized_start=5695 - _globals['_CONTROLLERMESSAGETYPE']._serialized_end=5896 - _globals['_PAMRECORDINGTYPE']._serialized_start=5898 - _globals['_PAMRECORDINGTYPE']._serialized_end=5984 - _globals['_PAMRECORDINGRISKLEVEL']._serialized_start=5986 - _globals['_PAMRECORDINGRISKLEVEL']._serialized_end=6091 - _globals['_NHICATEGORY']._serialized_start=6093 - _globals['_NHICATEGORY']._serialized_end=6189 + _globals['_WEBRTCCONNECTIONTYPE']._serialized_start=5340 + _globals['_WEBRTCCONNECTIONTYPE']._serialized_end=5498 + _globals['_PAMOPERATIONTYPE']._serialized_start=5500 + _globals['_PAMOPERATIONTYPE']._serialized_end=5564 + _globals['_PAMOPERATIONRESULTTYPE']._serialized_start=5566 + _globals['_PAMOPERATIONRESULTTYPE']._serialized_end=5678 + _globals['_CONTROLLERMESSAGETYPE']._serialized_start=5681 + _globals['_CONTROLLERMESSAGETYPE']._serialized_end=5882 + _globals['_PAMRECORDINGTYPE']._serialized_start=5884 + _globals['_PAMRECORDINGTYPE']._serialized_end=5970 + _globals['_PAMRECORDINGRISKLEVEL']._serialized_start=5972 + _globals['_PAMRECORDINGRISKLEVEL']._serialized_end=6077 + _globals['_NHICATEGORY']._serialized_start=6079 + _globals['_NHICATEGORY']._serialized_end=6175 _globals['_PAMROTATIONSCHEDULE']._serialized_start=51 _globals['_PAMROTATIONSCHEDULE']._serialized_end=182 _globals['_PAMROTATIONSCHEDULESRESPONSE']._serialized_start=184 @@ -104,40 +104,36 @@ _globals['_RELAYACCESSCREDS']._serialized_end=2649 _globals['_PAMRECORDINGSREQUEST']._serialized_start=2652 _globals['_PAMRECORDINGSREQUEST']._serialized_end=2909 - _globals['_PAMRECORDING']._serialized_start=2912 - _globals['_PAMRECORDING']._serialized_end=3252 - _globals['_PAMRECORDINGSRESPONSE']._serialized_start=3254 - _globals['_PAMRECORDINGSRESPONSE']._serialized_end=3333 - _globals['_PAMDATA']._serialized_start=3335 - _globals['_PAMDATA']._serialized_end=3377 - _globals['_UIDLIST']._serialized_start=3379 - _globals['_UIDLIST']._serialized_end=3402 - _globals['_PAMRESOURCECONFIG']._serialized_start=3405 - _globals['_PAMRESOURCECONFIG']._serialized_end=3841 - _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_start=3843 - _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_end=3880 - _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_start=3883 - _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_end=4135 - _globals['_NHIMETRICSREQUEST']._serialized_start=4137 - _globals['_NHIMETRICSREQUEST']._serialized_end=4192 - _globals['_PAMUSAGEBYUSER']._serialized_start=4195 - _globals['_PAMUSAGEBYUSER']._serialized_end=4479 - _globals['_NHIUSAGEBYUSER']._serialized_start=4481 - _globals['_NHIUSAGEBYUSER']._serialized_end=4593 - _globals['_NHIMETRICSRESPONSE']._serialized_start=4596 - _globals['_NHIMETRICSRESPONSE']._serialized_end=4856 - _globals['_NHIBULKMETRICSRESPONSE']._serialized_start=4858 - _globals['_NHIBULKMETRICSRESPONSE']._serialized_end=4926 - _globals['_NHIUIDENTRY']._serialized_start=4928 - _globals['_NHIUIDENTRY']._serialized_end=5006 - _globals['_GETNHIUIDSREQUEST']._serialized_start=5008 - _globals['_GETNHIUIDSREQUEST']._serialized_end=5063 - _globals['_GETNHIUIDSRESPONSE']._serialized_start=5065 - _globals['_GETNHIUIDSRESPONSE']._serialized_end=5117 - _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_start=5119 - _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_end=5192 - _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_start=5194 - _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_end=5261 - _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_start=5263 - _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_end=5351 + _globals['_PAMRECORDINGSFORUSERSREQUEST']._serialized_start=2912 + _globals['_PAMRECORDINGSFORUSERSREQUEST']._serialized_end=3177 + _globals['_PAMRECORDING']._serialized_start=3180 + _globals['_PAMRECORDING']._serialized_end=3520 + _globals['_PAMRECORDINGSRESPONSE']._serialized_start=3522 + _globals['_PAMRECORDINGSRESPONSE']._serialized_end=3601 + _globals['_PAMDATA']._serialized_start=3603 + _globals['_PAMDATA']._serialized_end=3645 + _globals['_UIDLIST']._serialized_start=3647 + _globals['_UIDLIST']._serialized_end=3670 + _globals['_PAMRESOURCECONFIG']._serialized_start=3673 + _globals['_PAMRESOURCECONFIG']._serialized_end=4061 + _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_start=4063 + _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_end=4100 + _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_start=4103 + _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_end=4355 + _globals['_NHIMETRICSREQUEST']._serialized_start=4357 + _globals['_NHIMETRICSREQUEST']._serialized_end=4412 + _globals['_PAMUSAGEBYUSER']._serialized_start=4415 + _globals['_PAMUSAGEBYUSER']._serialized_end=4699 + _globals['_NHIUSAGEBYUSER']._serialized_start=4701 + _globals['_NHIUSAGEBYUSER']._serialized_end=4813 + _globals['_NHIMETRICSRESPONSE']._serialized_start=4816 + _globals['_NHIMETRICSRESPONSE']._serialized_end=5076 + _globals['_NHIBULKMETRICSRESPONSE']._serialized_start=5078 + _globals['_NHIBULKMETRICSRESPONSE']._serialized_end=5146 + _globals['_NHIUIDENTRY']._serialized_start=5148 + _globals['_NHIUIDENTRY']._serialized_end=5226 + _globals['_GETNHIUIDSREQUEST']._serialized_start=5228 + _globals['_GETNHIUIDSREQUEST']._serialized_end=5283 + _globals['_GETNHIUIDSRESPONSE']._serialized_start=5285 + _globals['_GETNHIUIDSRESPONSE']._serialized_end=5337 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/service/commands/handle_service.py b/keepercommander/service/commands/handle_service.py index d18398498..8051ea92f 100644 --- a/keepercommander/service/commands/handle_service.py +++ b/keepercommander/service/commands/handle_service.py @@ -39,11 +39,14 @@ def execute(self, params: KeeperParams, **kwargs) -> None: class ServiceStatus(Command): """Command to get service status.""" + + skip_sync_on_auth = True + @debug_decorator def get_parser(self): parser = argparse.ArgumentParser(prog='service-status', parents=[report_output_parser], description='Displays if the Commander API service is running or stopped') return parser - + def execute(self, params: KeeperParams, **kwargs) -> str: status = ServiceManager.get_status() print(f"Current status: {status}") \ No newline at end of file diff --git a/keepercommander/service/docker/printer.py b/keepercommander/service/docker/printer.py index 28d593729..af1b66dfd 100644 --- a/keepercommander/service/docker/printer.py +++ b/keepercommander/service/docker/printer.py @@ -13,6 +13,8 @@ Output formatting utilities for Docker setup commands. """ +import shlex + from ...display import bcolors from .models import SetupResult @@ -68,7 +70,7 @@ def print_common_deployment_steps(port: str, config_path: str = None) -> None: config_file = config_path if config_path else '~/.keeper/config.json' print(f"\n{bcolors.BOLD}Step 2: Delete the local config.json file{bcolors.ENDC}") - print(f" {bcolors.OKGREEN}rm {config_file}{bcolors.ENDC}") + print(f" {bcolors.OKGREEN}rm {shlex.quote(config_file)}{bcolors.ENDC}") print(f" Why? Prevents device token conflicts - Docker will download its own config.") print(f"\n{bcolors.BOLD}Step 3: Review docker-compose.yml{bcolors.ENDC}") diff --git a/keepercommander/utils.py b/keepercommander/utils.py index 3a6f84119..41d3f3829 100644 --- a/keepercommander/utils.py +++ b/keepercommander/utils.py @@ -90,6 +90,19 @@ def generate_aes_key(): # type: () -> bytes return crypto.get_random_bytes(32) +def _windows_icacls_principal(): # type: () -> str + """Return a DOMAIN\\username principal suitable for icacls /grant on Windows.""" + username = os.environ.get('USERNAME', '') + if not username: + username = os.getlogin() + if '\\' in username: + return username + domain = os.environ.get('USERDOMAIN') or os.environ.get('COMPUTERNAME', '') + if domain: + return f'{domain}\\{username}' + return username + + def set_file_permissions(file_path): # type: (str) -> None """ Set secure file permissions (600) for configuration files containing sensitive data. @@ -111,10 +124,10 @@ def set_file_permissions(file_path): # type: (str) -> None os.chmod(file_path, stat.S_IRUSR | stat.S_IWUSR) logging.debug(f'Set secure permissions (600) for file: {file_path}') else: - username = os.getlogin() + principal = _windows_icacls_principal() subprocess.run(["icacls", file_path, "/inheritance:r"], check=True, capture_output=True) subprocess.run(["icacls", file_path, "/remove", "NT AUTHORITY\\SYSTEM", "BUILTIN\\Administrators"], check=False, capture_output=True) - subprocess.run(["icacls", file_path, "/grant", f"{username}:RW"], check=True, capture_output=True) + subprocess.run(["icacls", file_path, "/grant", f"{principal}:RW"], check=True, capture_output=True) logging.debug(f'Set secure permissions (owner RW only) for Windows file: {file_path}') except Exception: logging.warning(f'Failed to set file permissions for {file_path}') diff --git a/tests/test_pam_recording_commands.py b/tests/test_pam_recording_commands.py new file mode 100644 index 000000000..c129ac010 --- /dev/null +++ b/tests/test_pam_recording_commands.py @@ -0,0 +1,717 @@ +"""Tests for PAM recording commands (PR-212 / get_recordings_for_users).""" + +import json +import os +import tempfile +import time +import unittest +from unittest.mock import MagicMock, patch, call + +from keepercommander.commands.pam.recording_commands import ( + PAMGetRecordingsForUsersCommand, + PAMDownloadRecordingsCommand, + RECORDING_TYPE_NAMES, + RISK_LEVEL_NAMES, + _parse_dt, + _fmt_ts, + _get_record_key, + _download_one, + _DOWNLOAD_WORKERS, +) +from keepercommander.proto import pam_pb2 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_recording(**kwargs): + """Build a PAMRecording proto with sensible defaults.""" + rec = pam_pb2.PAMRecording() + rec.connectionUid = bytes.fromhex(kwargs.get('connectionUid', 'deadbeef' * 4)) + rec.recordUid = bytes.fromhex(kwargs.get('recordUid', 'cafebabe' * 4)) + rec.userName = kwargs.get('userName', 'alice@example.com') + rec.recordingType = kwargs.get('recordingType', pam_pb2.PRT_SESSION) + rec.protocol = kwargs.get('protocol', 'SSH') + rec.startedOn = kwargs.get('startedOn', int(time.time()) * 1000) + rec.length = kwargs.get('length', 120) + rec.fileSize = kwargs.get('fileSize', 4096) + rec.createdOn = kwargs.get('createdOn', int(time.time()) * 1000) + rec.aiOverallRiskLevel = kwargs.get('aiOverallRiskLevel', pam_pb2.PRR_UNSPECIFIED) + return rec + + +def _make_response(recordings, has_more=False): + rs = pam_pb2.PAMRecordingsResponse() + rs.recordings.extend(recordings) + rs.hasMore = has_more + return rs + + +def _make_params(record_key=None, record_uid_hex='cafebabe' * 4): + """Return a mock params object with a record_cache entry.""" + params = MagicMock() + import base64 + uid_bytes = bytes.fromhex(record_uid_hex) + uid_str = base64.urlsafe_b64encode(uid_bytes).rstrip(b'=').decode() + if record_key is not None: + params.record_cache = {uid_str: {'record_key_unencrypted': record_key}} + else: + params.record_cache = {} + return params + + +FAKE_KEY = b'\x00' * 32 +FAKE_PLAINTEXT = b'decrypted session data' + +def _make_fake_recording_bytes(record_key=FAKE_KEY): + """Build a minimal valid recording wire format so _decrypt_recording_file succeeds.""" + import base64, json, struct + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + import os + + conv_uid_b64 = base64.b64encode(b'\xde\xad\xbe\xef' * 4).decode() # standard b64 (with +/=) + nonce = os.urandom(12) + salt = os.urandom(12) + salt_b64 = base64.b64encode(salt).decode() + + # derive resource key + info = f"{conv_uid_b64}_RECORDING-SESSION_AES-GCM-256".encode() + resource_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=info).derive(record_key) + + # recording secret + recording_secret = os.urandom(32) + private_meta = json.dumps({'recordingSecret': base64.b64encode(recording_secret).decode(), + 'resourceIp': '127.0.0.1', 'resourcePort': '22', + 'startTime': 0, 'userName': 'test'}).encode() + resource_data_nonce = os.urandom(12) + resource_data_enc = resource_data_nonce + AESGCM(resource_key).encrypt(resource_data_nonce, private_meta, None) + resource_data_b64 = base64.b64encode(resource_data_enc).decode() + + ad = {'conversationUid': conv_uid_b64, 'recordingType': 'ses', + 'resourceKeysSalt': salt_b64, 'resourceUid': 'AAAA', + 'resourceData': resource_data_b64, 'userData': ''} + ad_bytes = json.dumps(ad).encode() + ad_len_bytes = struct.pack('>I', len(ad_bytes)) + + # encrypt recording data + recording_ct = AESGCM(recording_secret).encrypt(nonce, FAKE_PLAINTEXT, ad_bytes) + + return ad_len_bytes + ad_bytes + b';' + nonce + recording_ct + + +# --------------------------------------------------------------------------- +# _parse_dt +# --------------------------------------------------------------------------- + +class TestParseDt(unittest.TestCase): + def test_date_only(self): + dt = _parse_dt('2025-03-15') + self.assertEqual((dt.year, dt.month, dt.day), (2025, 3, 15)) + + def test_datetime_T(self): + dt = _parse_dt('2025-03-15T08:30:00') + self.assertEqual(dt.hour, 8) + + def test_datetime_space(self): + dt = _parse_dt('2025-03-15 08:30:00') + self.assertEqual(dt.hour, 8) + + def test_invalid(self): + with self.assertRaises(ValueError): + _parse_dt('not-a-date') + + +# --------------------------------------------------------------------------- +# _fmt_ts +# --------------------------------------------------------------------------- + +class TestFmtTs(unittest.TestCase): + def test_zero(self): + self.assertEqual(_fmt_ts(0), '') + + def test_none(self): + self.assertEqual(_fmt_ts(None), '') + + def test_known_epoch(self): + # 1000 ms = 1 second past epoch → still 1970 + self.assertIn('1970', _fmt_ts(1000)) + + +# --------------------------------------------------------------------------- +# _get_record_key +# --------------------------------------------------------------------------- + +class TestGetRecordKey(unittest.TestCase): + def test_found(self): + params = _make_params(record_key=FAKE_KEY) + key = _get_record_key(params, bytes.fromhex('cafebabe' * 4)) + self.assertEqual(key, FAKE_KEY) + + def test_not_found(self): + params = _make_params(record_key=None) + key = _get_record_key(params, bytes.fromhex('cafebabe' * 4)) + self.assertIsNone(key) + + +# --------------------------------------------------------------------------- +# _download_one +# --------------------------------------------------------------------------- + +class TestDownloadOne(unittest.TestCase): + def test_success(self): + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=_make_fake_recording_bytes(FAKE_KEY)): + filename, nbytes, err = _download_one( + MagicMock(), bytes.fromhex('deadbeef' * 4), 'ses', tmpdir, FAKE_KEY) + self.assertIsNone(err) + self.assertEqual(nbytes, len(FAKE_PLAINTEXT)) + self.assertTrue(filename.endswith('.ses')) + + def test_router_error(self): + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + side_effect=Exception('404')): + filename, nbytes, err = _download_one( + MagicMock(), bytes.fromhex('deadbeef' * 4), 'ses', tmpdir, FAKE_KEY) + self.assertIsNotNone(err) + self.assertEqual(nbytes, 0) + + def test_decrypt_error(self): + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=b'invalid-garbage-bytes'): + _, nbytes, err = _download_one( + MagicMock(), bytes.fromhex('deadbeef' * 4), 'ses', tmpdir, FAKE_KEY) + self.assertIsNotNone(err) + + +# --------------------------------------------------------------------------- +# PAMGetRecordingsForUsersCommand (list-by-user) +# --------------------------------------------------------------------------- + +class TestPAMGetRecordingsForUsersCommand(unittest.TestCase): + + def _run(self, router_return, **kwargs): + cmd = PAMGetRecordingsForUsersCommand() + params = _make_params(record_key=FAKE_KEY) + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=router_return) as mock_router: + cmd.execute(params, **kwargs) + return mock_router + + def test_basic_request_shape(self): + rs = _make_response([_make_recording()]) + mock = self._run(rs, usernames=['alice@example.com'], output_format='json') + rq = mock.call_args[0][1] + self.assertIsInstance(rq, pam_pb2.PAMRecordingsForUsersRequest) + self.assertIn('alice@example.com', rq.usernames) + self.assertEqual(rq.maxCount, 0) + + def test_multiple_usernames(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['alice@example.com', 'bob@example.com']) + rq = mock.call_args[0][1] + self.assertIn('alice@example.com', rq.usernames) + self.assertIn('bob@example.com', rq.usernames) + + def test_max_count(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['u@x.com'], max_count=50) + self.assertEqual(mock.call_args[0][1].maxCount, 50) + + def test_range_filters(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['u@x.com'], + range_start='2025-01-01', range_end='2025-12-31') + rq = mock.call_args[0][1] + self.assertTrue(rq.HasField('rangeStart')) + self.assertTrue(rq.HasField('rangeEnd')) + self.assertGreater(rq.rangeEnd, rq.rangeStart) + + def test_type_filter(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['u@x.com'], types=['session', 'typescript']) + rq = mock.call_args[0][1] + self.assertIn(pam_pb2.PRT_SESSION, rq.types) + self.assertIn(pam_pb2.PRT_TYPESCRIPT, rq.types) + + def test_risk_filter(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['u@x.com'], risks=['high', 'critical']) + rq = mock.call_args[0][1] + self.assertIn(pam_pb2.PRR_HIGH, rq.risks) + self.assertIn(pam_pb2.PRR_CRITICAL, rq.risks) + + def test_protocol_filter(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['u@x.com'], protocols=['SSH', 'RDP']) + rq = mock.call_args[0][1] + self.assertIn('SSH', rq.protocols) + self.assertIn('RDP', rq.protocols) + + def test_empty_usernames_aborts(self): + cmd = PAMGetRecordingsForUsersCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users') as mock: + cmd.execute(MagicMock(), usernames=[]) + mock.assert_not_called() + + def test_none_response(self): + printed = [] + cmd = PAMGetRecordingsForUsersCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=None), \ + patch('builtins.print', side_effect=printed.append): + cmd.execute(MagicMock(), usernames=['u@x.com']) + self.assertTrue(any('No response' in str(p) for p in printed)) + + def test_router_error(self): + cmd = PAMGetRecordingsForUsersCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + side_effect=Exception('network error')): + cmd.execute(MagicMock(), usernames=['u@x.com']) # must not raise + + def test_json_output_shape(self): + rec = _make_recording(userName='alice@example.com', protocol='SSH', + aiOverallRiskLevel=pam_pb2.PRR_HIGH) + rs = _make_response([rec], has_more=True) + lines = [] + cmd = PAMGetRecordingsForUsersCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('builtins.print', side_effect=lambda x: lines.append(x)): + cmd.execute(MagicMock(), usernames=['alice@example.com'], output_format='json') + data = json.loads('\n'.join(lines)) + self.assertIn('recordings', data) + self.assertTrue(data['hasMore']) + row = data['recordings'][0] + self.assertEqual(row['userName'], 'alice@example.com') + self.assertEqual(row['aiOverallRiskLevel'], 'high') + + def test_json_output_to_file(self): + rec = _make_recording() + rs = _make_response([rec]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs): + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + with open(outpath) as f: + data = json.load(f) + self.assertIn('recordings', data) + finally: + os.unlink(outpath) + + def test_output_format_inferred_from_csv_extension(self): + rs = _make_response([_make_recording()]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.csv', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.dump_report_data') as mock_dump: + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + fmt_used = mock_dump.call_args[1].get('fmt') or mock_dump.call_args[0][2] + self.assertEqual(fmt_used, 'csv') + finally: + os.unlink(outpath) + + def test_download_dir_triggers_download(self): + rec = _make_recording() + rs = _make_response([rec]) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=_make_fake_recording_bytes(FAKE_KEY)) as mock_dl: + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + cmd = PAMGetRecordingsForUsersCommand() + cmd.execute(params, usernames=['u@x.com'], download_dir=tmpdir) + mock_dl.assert_called() + + def test_download_dir_skips_missing_record_key(self): + rec = _make_recording() + rs = _make_response([rec]) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + params = _make_params(record_key=None) # no key in cache + cmd = PAMGetRecordingsForUsersCommand() + cmd.execute(params, usernames=['u@x.com'], download_dir=tmpdir) + mock_dl.assert_not_called() + + +# --------------------------------------------------------------------------- +# PAMDownloadRecordingsCommand +# --------------------------------------------------------------------------- + +class TestPAMDownloadRecordingsCommand(unittest.TestCase): + + def test_connection_uid_requires_record_uid(self): + cmd = PAMDownloadRecordingsCommand() + with patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + cmd.execute(MagicMock(), connection_uid=['deadbeef' * 4]) + mock_dl.assert_not_called() + + def test_invalid_connection_uid_hex(self): + cmd = PAMDownloadRecordingsCommand() + params = _make_params(record_key=FAKE_KEY) + with patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + cmd.execute(params, + connection_uid=['not-hex'], + record_uid='cafebabe' * 4, + output_dir='/tmp') + mock_dl.assert_not_called() + + def test_invalid_record_uid_hex(self): + cmd = PAMDownloadRecordingsCommand() + with patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + cmd.execute(MagicMock(), + connection_uid=['deadbeef' * 4], + record_uid='not-hex', + output_dir='/tmp') + mock_dl.assert_not_called() + + def test_record_key_not_in_cache(self): + cmd = PAMDownloadRecordingsCommand() + params = _make_params(record_key=None) # empty cache + with patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + cmd.execute(params, + connection_uid=['deadbeef' * 4], + record_uid='cafebabe' * 4, + output_dir='/tmp') + mock_dl.assert_not_called() + + def test_single_connection_uid_downloads_and_decrypts(self): + cmd = PAMDownloadRecordingsCommand() + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + fake_wire = _make_fake_recording_bytes(FAKE_KEY) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=fake_wire): + cmd.execute(params, + connection_uid=['deadbeef' * 4], + record_uid='cafebabe' * 4, + output_dir=tmpdir, + types=['session']) + saved = [f for f in os.listdir(tmpdir) if f.endswith('.ses')] + self.assertEqual(len(saved), 1) + with open(os.path.join(tmpdir, saved[0]), 'rb') as f: + self.assertEqual(f.read(), FAKE_PLAINTEXT) + + def test_multiple_connection_uids(self): + cmd = PAMDownloadRecordingsCommand() + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + uid1 = 'deadbeef' * 4 + uid2 = 'beefdead' * 4 + fake_wire = _make_fake_recording_bytes(FAKE_KEY) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=fake_wire): + cmd.execute(params, + connection_uid=[uid1, uid2], + record_uid='cafebabe' * 4, + output_dir=tmpdir, + types=['session']) + saved = [f for f in os.listdir(tmpdir) if f.endswith('.ses')] + self.assertEqual(len(saved), 2) + + def test_username_mode_no_results(self): + cmd = PAMDownloadRecordingsCommand() + rs = _make_response([]) + printed = [] + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('builtins.print', side_effect=printed.append): + cmd.execute(MagicMock(), usernames=['u@x.com']) + self.assertTrue(any('No recordings' in str(p) for p in printed)) + + def test_username_mode_downloads_with_decryption(self): + rec = _make_recording() + rs = _make_response([rec]) + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=_make_fake_recording_bytes(FAKE_KEY)): + cmd = PAMDownloadRecordingsCommand() + cmd.execute(params, usernames=['alice@example.com'], output_dir=tmpdir) + saved = os.listdir(tmpdir) + self.assertTrue(len(saved) > 0) + with open(os.path.join(tmpdir, saved[0]), 'rb') as f: + self.assertEqual(f.read(), FAKE_PLAINTEXT) + + def test_username_mode_skips_missing_key(self): + rec = _make_recording() + rs = _make_response([rec]) + params = _make_params(record_key=None) # no key + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + cmd = PAMDownloadRecordingsCommand() + cmd.execute(params, usernames=['alice@example.com'], output_dir=tmpdir) + mock_dl.assert_not_called() + + def test_username_mode_type_filter(self): + rec = _make_recording(recordingType=pam_pb2.PRT_SESSION) + rs = _make_response([rec]) + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=_make_fake_recording_bytes(FAKE_KEY)) as mock_dl: + cmd = PAMDownloadRecordingsCommand() + cmd.execute(params, usernames=['u@x.com'], + types=['session'], output_dir=tmpdir) + # should only request .ses, not all 4 types + calls = mock_dl.call_args_list + exts = [c[0][2] for c in calls] + self.assertEqual(exts, ['ses']) + + def test_router_error_in_username_mode(self): + cmd = PAMDownloadRecordingsCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + side_effect=Exception('network error')): + cmd.execute(MagicMock(), usernames=['u@x.com']) # must not raise + + def test_none_response_in_username_mode(self): + printed = [] + cmd = PAMDownloadRecordingsCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=None), \ + patch('builtins.print', side_effect=printed.append): + cmd.execute(MagicMock(), usernames=['u@x.com']) + self.assertTrue(any('No response' in str(p) for p in printed)) + + def test_has_more_warning_printed(self): + rec = _make_recording() + rs = _make_response([rec], has_more=True) + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + printed = [] + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=_make_fake_recording_bytes(FAKE_KEY)), \ + patch('builtins.print', side_effect=printed.append): + cmd = PAMDownloadRecordingsCommand() + with tempfile.TemporaryDirectory() as tmpdir: + cmd.execute(params, usernames=['u@x.com'], output_dir=tmpdir) + self.assertTrue(any('more' in str(p).lower() for p in printed)) + + +# --------------------------------------------------------------------------- +# get_parser (covers argparse setup lines) +# --------------------------------------------------------------------------- + +class TestGetParser(unittest.TestCase): + def test_lbu_parser_returns_parser(self): + parser = PAMGetRecordingsForUsersCommand().get_parser() + self.assertIsNotNone(parser) + + def test_dl_parser_returns_parser(self): + parser = PAMDownloadRecordingsCommand().get_parser() + self.assertIsNotNone(parser) + + def test_lbu_parser_has_download_dir(self): + parser = PAMGetRecordingsForUsersCommand().get_parser() + args = parser.parse_args(['user@example.com', '--download-dir', '/tmp/recs']) + self.assertEqual(args.download_dir, '/tmp/recs') + + def test_dl_parser_has_output_dir(self): + parser = PAMDownloadRecordingsCommand().get_parser() + args = parser.parse_args(['--usernames', 'user@example.com', '--output-dir', '/tmp/recs']) + self.assertEqual(args.output_dir, '/tmp/recs') + + def test_lbu_parser_filter_args(self): + parser = PAMGetRecordingsForUsersCommand().get_parser() + args = parser.parse_args(['u@x.com', '--types', 'session', '--risks', 'high', + '--protocols', 'SSH', '--max-count', '10']) + self.assertEqual(args.types, ['session']) + self.assertEqual(args.risks, ['high']) + self.assertEqual(args.max_count, 10) + + +# --------------------------------------------------------------------------- +# Legacy salt path (line 85) +# --------------------------------------------------------------------------- + +class TestDecryptLegacySalt(unittest.TestCase): + def test_no_resource_keys_salt_uses_nonce(self): + """When resourceKeysSalt is absent, the nonce is used as the salt.""" + import base64 as b64, struct + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + from keepercommander.commands.pam.recording_commands import _decrypt_recording_file + import os + + record_key = FAKE_KEY + conv_uid_b64 = b64.b64encode(b'\xaa\xbb\xcc\xdd' * 4).decode() + nonce = os.urandom(12) + + # Use nonce as salt (legacy path — no resourceKeysSalt key in AD) + info = f"{conv_uid_b64}_RECORDING-SESSION_AES-GCM-256".encode() + resource_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=nonce, info=info).derive(record_key) + + recording_secret = os.urandom(32) + private_meta = json.dumps({'recordingSecret': b64.b64encode(recording_secret).decode()}).encode() + rd_nonce = os.urandom(12) + resource_data_enc = rd_nonce + AESGCM(resource_key).encrypt(rd_nonce, private_meta, None) + + # No 'resourceKeysSalt' key + ad = {'conversationUid': conv_uid_b64, 'resourceData': b64.b64encode(resource_data_enc).decode()} + ad_bytes = json.dumps(ad).encode() + ad_len_bytes = struct.pack('>I', len(ad_bytes)) + plaintext_msg = b'legacy plaintext' + ct = AESGCM(recording_secret).encrypt(nonce, plaintext_msg, ad_bytes) + + wire = ad_len_bytes + ad_bytes + b';' + nonce + ct + result = _decrypt_recording_file(wire, record_key) + self.assertEqual(result, plaintext_msg) + + +# --------------------------------------------------------------------------- +# _download_recording_files error path (line 184) +# --------------------------------------------------------------------------- + +class TestDownloadRecordingFilesError(unittest.TestCase): + def test_error_logged_when_download_fails(self): + from keepercommander.commands.pam.recording_commands import _download_recording_files + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + side_effect=Exception('server error')), \ + patch('keepercommander.commands.pam.recording_commands.logging') as mock_log: + _download_recording_files(MagicMock(), bytes(16), ['ses'], tmpdir, FAKE_KEY) + mock_log.warning.assert_called() + + +# --------------------------------------------------------------------------- +# _download_recordings edge cases (lines 198, 212-213, 224) +# --------------------------------------------------------------------------- + +class TestDownloadRecordingsEdgeCases(unittest.TestCase): + def test_skips_recording_with_no_connection_uid(self): + from keepercommander.commands.pam.recording_commands import _download_recordings + rec = _make_recording() + rec.ClearField('connectionUid') # empty bytes + printed = [] + with patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl, \ + patch('builtins.print', side_effect=printed.append): + _download_recordings(_make_params(FAKE_KEY), [rec], [], '/tmp/noop_dir') + mock_dl.assert_not_called() + self.assertTrue(any('Nothing' in str(p) for p in printed)) + + def test_nothing_to_download_when_all_keys_missing(self): + from keepercommander.commands.pam.recording_commands import _download_recordings + rec = _make_recording() + params = _make_params(record_key=None) # no keys + printed = [] + with patch('builtins.print', side_effect=printed.append): + with tempfile.TemporaryDirectory() as tmpdir: + _download_recordings(params, [rec], [], tmpdir) + self.assertTrue(any('Nothing' in str(p) for p in printed)) + + def test_error_in_parallel_loop_logged(self): + from keepercommander.commands.pam.recording_commands import _download_recordings + rec = _make_recording() + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + side_effect=Exception('download failed')), \ + patch('keepercommander.commands.pam.recording_commands.logging') as mock_log: + with tempfile.TemporaryDirectory() as tmpdir: + _download_recordings(params, [rec], [], tmpdir) + mock_log.warning.assert_called() + + +# --------------------------------------------------------------------------- +# Grid output format (.md / .txt extension → line 281) +# --------------------------------------------------------------------------- + +class TestGridOutputFormat(unittest.TestCase): + def test_md_extension_uses_grid_format(self): + rec = _make_recording() + rs = _make_response([rec]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.md', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.dump_report_data') as mock_dump: + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + fmt_used = mock_dump.call_args[1].get('fmt') or mock_dump.call_args[0][2] + self.assertEqual(fmt_used, 'grid') + finally: + os.unlink(outpath) + + def test_txt_extension_uses_grid_format(self): + rec = _make_recording() + rs = _make_response([rec]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.dump_report_data') as mock_dump: + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + fmt_used = mock_dump.call_args[1].get('fmt') or mock_dump.call_args[0][2] + self.assertEqual(fmt_used, 'grid') + finally: + os.unlink(outpath) + + +# --------------------------------------------------------------------------- +# _print_table with output_file (lines 406-407) +# --------------------------------------------------------------------------- + +class TestPrintTableOutputFile(unittest.TestCase): + def test_table_written_to_txt_file(self): + rec = _make_recording() + rs = _make_response([rec]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.dump_report_data'): + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + finally: + os.unlink(outpath) + + +# --------------------------------------------------------------------------- +# _print_json to file (lines 435-438) +# --------------------------------------------------------------------------- + +class TestPrintJsonToFile(unittest.TestCase): + def test_json_written_to_file(self): + rec = _make_recording() + rs = _make_response([rec]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs): + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + with open(outpath) as f: + data = json.load(f) + self.assertIn('recordings', data) + finally: + os.unlink(outpath) + + +if __name__ == '__main__': + unittest.main() diff --git a/unit-tests/conftest.py b/unit-tests/conftest.py new file mode 100644 index 000000000..5650ef913 --- /dev/null +++ b/unit-tests/conftest.py @@ -0,0 +1,12 @@ +"""Pytest session hooks for unit-tests. + +CNAPP tests import `cnapp_helper` before `keepercommander.commands.record` is loaded, +which triggers a pre-existing record <-> ksm circular import. Loading `record` first +resolves the cycle (same as production startup order). +""" +import pytest + + +@pytest.fixture(scope='session', autouse=True) +def _preload_commands_record_module(): + import keepercommander.commands.record # noqa: F401 diff --git a/unit-tests/pam/test_cnapp.py b/unit-tests/pam/test_cnapp.py new file mode 100644 index 000000000..8d8421710 --- /dev/null +++ b/unit-tests/pam/test_cnapp.py @@ -0,0 +1,1001 @@ +"""Unit tests for the Commander CNAPP helper and command surface. + +Strategy: every test patches `_post_request_to_router` so we can assert on what the +helper sends to krouter and feed deterministic responses back into the commands. We +deliberately stay one layer below the network — no socket calls, no real protobuf +encryption — but we exercise the real proto serializers so wire-format breakage +surfaces here. +""" +import base64 +import io +import json +import os +import unittest +from contextlib import redirect_stdout +from unittest.mock import MagicMock, patch + +# isort: off +# Pre-load `record` before cnapp modules (record↔ksm cycle). Pytest also loads it via +# unit-tests/conftest.py; keep this guard for `python unit-tests/pam/test_cnapp.py`. +import keepercommander.commands.record # noqa: F401 +# isort: on + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM # noqa: E402 +from keeper_secrets_manager_core.utils import bytes_to_base64 # noqa: E402 + +from keepercommander.commands.pam import cnapp_helper # noqa: E402 +from keepercommander.commands.pam import cnapp_commands # noqa: E402 +from keepercommander.error import CommandError # noqa: E402 +from keepercommander.proto import cnapp_pb2 # noqa: E402 + + +# Sample 16-byte UIDs as base64url (the format Commander callers pass in). +NETWORK_UID = 'AAAAAAAAAAAAAAAAAAAAAA' # 16 zero bytes +RECORD_UID = 'AQEBAQEBAQEBAQEBAQEBAQ' # 16 0x01 bytes +CONFIG_RECORD_UID = 'AgICAgICAgICAgICAgICAg' + + +def _mock_params(): + """Minimal KeeperParams stand-in — the helpers only use it to drive the router_helper + transport, which is mocked here, so a MagicMock is enough.""" + return MagicMock() + + +# --------------------------------------------------------------------------- +# cnapp_helper: enum parsing +# --------------------------------------------------------------------------- + +class TestEnumParsing(unittest.TestCase): + """provider_from_name and action_from_name must accept short or full names and + reject unknown values with a helpful error listing valid options.""" + + def test_provider_short_name(self): + self.assertEqual(cnapp_helper.provider_from_name('wiz'), cnapp_pb2.CNAPP_PROVIDER_WIZ) + + def test_provider_full_name_case_insensitive(self): + self.assertEqual( + cnapp_helper.provider_from_name('cnapp_provider_wiz'), + cnapp_pb2.CNAPP_PROVIDER_WIZ, + ) + + def test_provider_empty_returns_unspecified(self): + self.assertEqual(cnapp_helper.provider_from_name(''), cnapp_pb2.CNAPP_PROVIDER_UNSPECIFIED) + + def test_provider_unknown_raises_with_valid_options(self): + with self.assertRaises(ValueError) as ctx: + cnapp_helper.provider_from_name('aws') + self.assertIn('WIZ', str(ctx.exception).upper()) + + def test_action_short_name(self): + self.assertEqual( + cnapp_helper.action_from_name('rotate_credentials'), + cnapp_pb2.ROTATE_CREDENTIALS, + ) + + def test_action_hyphenated(self): + # The CLI accepts hyphens (`--action remove-standing-privilege`) for ergonomics; + # helper must normalize before resolving the enum. + self.assertEqual( + cnapp_helper.action_from_name('remove-standing-privilege'), + cnapp_pb2.REMOVE_STANDING_PRIVILEGE, + ) + + def test_action_unknown_raises(self): + with self.assertRaises(ValueError): + cnapp_helper.action_from_name('teleport') + + def test_action_empty_returns_unspecified(self): + self.assertEqual(cnapp_helper.action_from_name(''), cnapp_pb2.UNSPECIFIED) + + +# --------------------------------------------------------------------------- +# cnapp_helper: configuration endpoints +# --------------------------------------------------------------------------- + +class TestConfigurationHelpers(unittest.TestCase): + """Each helper must dispatch to the right krouter path with a correctly populated + protobuf request and return the typed response.""" + + def setUp(self): + self.params = _mock_params() + + def _patch_post(self, return_value=None): + return patch.object(cnapp_helper, '_post_request_to_router', return_value=return_value) + + def test_set_configuration_dispatches_with_full_payload(self): + expected_response = cnapp_pb2.CnappConfiguration( + clientId='abc', apiEndpointUrl='https://api.wiz.io') + with self._patch_post(return_value=expected_response) as post: + result = cnapp_helper.set_cnapp_configuration( + self.params, + network_uid=NETWORK_UID, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + client_id='abc', + client_secret='secret', + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + auth_endpoint_url='https://auth.wiz.io/oauth/token', + ) + self.assertIs(result, expected_response) + args, kwargs = post.call_args + self.assertEqual(args[1], 'cnapp/configuration/set') + rq = kwargs['rq_proto'] + self.assertEqual(rq.provider, cnapp_pb2.CNAPP_PROVIDER_WIZ) + self.assertEqual(rq.clientId, 'abc') + self.assertEqual(rq.clientSecret, 'secret') + self.assertEqual(rq.apiEndpointUrl, 'https://api.wiz.io') + self.assertEqual(rq.authEndpointUrl, 'https://auth.wiz.io/oauth/token') + self.assertEqual(len(rq.networkUid), 16) + self.assertEqual(len(rq.cnappConfigRecordUid), 16) + self.assertIs(kwargs['rs_type'], cnapp_pb2.CnappConfiguration) + + def test_set_configuration_omits_empty_secret_to_keep_existing(self): + """Edge case: passing '' for client_secret on set must leave the field blank in + the request so krouter can splice in the previously stored secret.""" + with self._patch_post() as post: + cnapp_helper.set_cnapp_configuration( + self.params, + network_uid=NETWORK_UID, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + client_id='abc', + client_secret='', + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + ) + rq = post.call_args.kwargs['rq_proto'] + self.assertEqual(rq.clientSecret, '') + + def test_test_configuration_dispatches_to_test_endpoint(self): + with self._patch_post() as post: + cnapp_helper.test_cnapp_configuration( + self.params, + network_uid=NETWORK_UID, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + client_id='abc', + client_secret='secret', + api_endpoint_url='https://api.wiz.io', + auth_endpoint_url='https://auth.wiz.io/oauth/token', + ) + self.assertEqual(post.call_args.args[1], 'cnapp/configuration/test') + self.assertEqual(post.call_args.kwargs['rq_proto'].authEndpointUrl, 'https://auth.wiz.io/oauth/token') + # test endpoint never persists, so it must not require / send config record UID + self.assertEqual(post.call_args.kwargs['rq_proto'].cnappConfigRecordUid, b'') + + def test_test_encrypter_sets_url(self): + with self._patch_post() as post: + cnapp_helper.test_cnapp_encrypter(self.params, url_base_encrypter='https://encr.local') + rq = post.call_args.kwargs['rq_proto'] + self.assertEqual(post.call_args.args[1], 'cnapp/configuration/test-encrypter') + self.assertEqual(rq.urlBaseEncrypter, 'https://encr.local') + + def test_read_configuration_uses_read_endpoint(self): + with self._patch_post(return_value=cnapp_pb2.CnappConfiguration()) as post: + cnapp_helper.read_cnapp_configuration( + self.params, network_uid=NETWORK_UID, provider=cnapp_pb2.CNAPP_PROVIDER_WIZ) + self.assertEqual(post.call_args.args[1], 'cnapp/configuration/read') + self.assertIs(post.call_args.kwargs['rs_type'], cnapp_pb2.CnappConfiguration) + + def test_delete_configuration_uses_delete_endpoint(self): + with self._patch_post() as post: + cnapp_helper.delete_cnapp_configuration(self.params, network_uid=NETWORK_UID) + self.assertEqual(post.call_args.args[1], 'cnapp/configuration/delete') + self.assertEqual(len(post.call_args.kwargs['rq_proto'].networkUid), 16) + + +# --------------------------------------------------------------------------- +# cnapp_helper: queue endpoints +# --------------------------------------------------------------------------- + +class TestQueueHelpers(unittest.TestCase): + def setUp(self): + self.params = _mock_params() + + def _patch_post(self, return_value=None): + return patch.object(cnapp_helper, '_post_request_to_router', return_value=return_value) + + def test_list_queue_with_status_filter(self): + items = cnapp_pb2.CnappQueueListResponse( + items=[cnapp_pb2.CnappQueueItem(cnappQueueId=42)]) + with self._patch_post(return_value=items) as post: + response = cnapp_helper.list_cnapp_queue( + self.params, network_uid=NETWORK_UID, status_filter=1) + self.assertEqual(post.call_args.args[1], 'cnapp/queue') + self.assertEqual(post.call_args.kwargs['rq_proto'].statusFilter, 1) + self.assertEqual(response.items[0].cnappQueueId, 42) + + def test_list_queue_defaults_to_all_status(self): + with self._patch_post(return_value=cnapp_pb2.CnappQueueListResponse()) as post: + cnapp_helper.list_cnapp_queue(self.params, network_uid=NETWORK_UID) + self.assertEqual(post.call_args.kwargs['rq_proto'].statusFilter, 0) + + def test_associate_record_dispatches(self): + with self._patch_post() as post: + cnapp_helper.associate_cnapp_record( + self.params, cnapp_queue_id=7, record_uid=RECORD_UID) + rq = post.call_args.kwargs['rq_proto'] + self.assertEqual(post.call_args.args[1], 'cnapp/queue/associate') + self.assertEqual(rq.cnappQueueId, 7) + self.assertEqual(len(rq.recordUid), 16) + + def test_remediate_forwards_optional_fields(self): + with self._patch_post(return_value=cnapp_pb2.CnappRemediateResponse()) as post: + cnapp_helper.remediate_cnapp_queue_item( + self.params, + cnapp_queue_id=3, + action_type=cnapp_pb2.ROTATE_CREDENTIALS, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + cnapp_config_record_uid=CONFIG_RECORD_UID, + resource_ref=RECORD_UID, + pwd_complexity='{"len":24}', + controller_uid='gateway-1', + message_uid=RECORD_UID, + group_name='Admins', + ) + rq = post.call_args.kwargs['rq_proto'] + self.assertEqual(post.call_args.args[1], 'cnapp/queue/remediate') + self.assertEqual(rq.cnappQueueId, 3) + self.assertEqual(rq.actionType, cnapp_pb2.ROTATE_CREDENTIALS) + self.assertEqual(rq.provider, cnapp_pb2.CNAPP_PROVIDER_WIZ) + self.assertEqual(rq.pwdComplexity, '{"len":24}') + self.assertEqual(rq.controllerUid, 'gateway-1') + self.assertEqual(rq.groupName, 'Admins') + + def test_remediate_minimal_fields(self): + """No optional fields — only queueId and actionType must be set on the wire.""" + with self._patch_post(return_value=cnapp_pb2.CnappRemediateResponse()) as post: + cnapp_helper.remediate_cnapp_queue_item( + self.params, cnapp_queue_id=9, action_type=cnapp_pb2.ROTATE_CREDENTIALS) + rq = post.call_args.kwargs['rq_proto'] + self.assertEqual(rq.cnappQueueId, 9) + self.assertEqual(rq.provider, 0) + self.assertEqual(rq.pwdComplexity, '') + self.assertEqual(rq.controllerUid, '') + self.assertEqual(rq.groupName, '') + + def test_set_status_with_reason(self): + with self._patch_post(return_value=cnapp_pb2.CnappSetStatusResponse(cnappQueueStatusId=3)) as post: + response = cnapp_helper.set_cnapp_queue_status( + self.params, cnapp_queue_id=11, cnapp_queue_status_id=3, reason='Manually resolved') + self.assertEqual(post.call_args.args[1], 'cnapp/queue/set-status') + self.assertEqual(post.call_args.kwargs['rq_proto'].reason, 'Manually resolved') + self.assertEqual(response.cnappQueueStatusId, 3) + + def test_delete_queue_item_dispatches(self): + with self._patch_post() as post: + cnapp_helper.delete_cnapp_queue_item(self.params, cnapp_queue_id=11) + self.assertEqual(post.call_args.args[1], 'cnapp/queue/delete') + self.assertEqual(post.call_args.kwargs['rq_proto'].cnappQueueId, 11) + + +# --------------------------------------------------------------------------- +# cnapp_helper: error propagation +# --------------------------------------------------------------------------- + +class TestHelperErrorPropagation(unittest.TestCase): + """The router layer raises on RRC_!=OK; helpers must NOT swallow those errors.""" + + def test_set_configuration_propagates_router_error(self): + params = _mock_params() + with patch.object(cnapp_helper, '_post_request_to_router', + side_effect=Exception('Credential validation failed: Unauthorized')): + with self.assertRaises(Exception) as ctx: + cnapp_helper.set_cnapp_configuration( + params, + network_uid=NETWORK_UID, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + client_id='abc', + client_secret='bad', + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + ) + self.assertIn('Credential validation failed', str(ctx.exception)) + + +# --------------------------------------------------------------------------- +# cnapp_commands: status resolver +# --------------------------------------------------------------------------- + +class TestStatusResolver(unittest.TestCase): + + def test_numeric_passes_through(self): + self.assertEqual(cnapp_commands._resolve_status('1'), 1) + self.assertEqual(cnapp_commands._resolve_status(2), 2) + + def test_unknown_numeric_id_raises(self): + with self.assertRaises(CommandError): + cnapp_commands._resolve_status(99) + + def test_zero_is_all(self): + self.assertEqual(cnapp_commands._resolve_status('0'), 0) + self.assertEqual(cnapp_commands._resolve_status(None), 0) + self.assertEqual(cnapp_commands._resolve_status(''), 0) + + def test_named_status_case_insensitive(self): + self.assertEqual(cnapp_commands._resolve_status('PENDING'), 1) + self.assertEqual(cnapp_commands._resolve_status('in_progress'), 2) + self.assertEqual(cnapp_commands._resolve_status('Resolved'), 3) + + def test_unknown_status_raises_command_error(self): + with self.assertRaises(CommandError): + cnapp_commands._resolve_status('flapping') + + +# --------------------------------------------------------------------------- +# cnapp_commands: end-to-end (helpers patched) +# --------------------------------------------------------------------------- + +class TestConfigCommands(unittest.TestCase): + def setUp(self): + self.params = _mock_params() + + def _capture_stdout(self): + buf = io.StringIO() + return buf, redirect_stdout(buf) + + def test_config_set_calls_helper_with_resolved_provider(self): + with patch.object(cnapp_commands.cnapp_helper, 'set_cnapp_configuration', + return_value=cnapp_pb2.CnappConfiguration(clientId='abc', + apiEndpointUrl='https://api.wiz.io', + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ)) as helper: + buf, ctx = self._capture_stdout() + with ctx: + cnapp_commands.PAMCnappConfigSetCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='wiz', + client_id='abc', + client_secret='secret', + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + auth_endpoint_url='https://auth.wiz.io/oauth/token', + ) + helper.assert_called_once() + kwargs = helper.call_args.kwargs + self.assertEqual(kwargs['provider'], cnapp_pb2.CNAPP_PROVIDER_WIZ) + self.assertEqual(kwargs['client_secret'], 'secret') + self.assertEqual(kwargs['auth_endpoint_url'], 'https://auth.wiz.io/oauth/token') + self.assertIn('saved', buf.getvalue().lower()) + + def test_config_set_blank_secret_passes_through(self): + """Edge case: the CLI must forward an empty secret unchanged so krouter can + keep the existing value.""" + with patch.object(cnapp_commands.cnapp_helper, 'set_cnapp_configuration', + return_value=cnapp_pb2.CnappConfiguration()) as helper: + with redirect_stdout(io.StringIO()): + cnapp_commands.PAMCnappConfigSetCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='wiz', + client_id='abc', + client_secret='', # explicit + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + ) + self.assertEqual(helper.call_args.kwargs['client_secret'], '') + + def test_config_set_omitted_secret_keeps_existing(self): + with patch.object(cnapp_commands.cnapp_helper, 'set_cnapp_configuration', + return_value=cnapp_pb2.CnappConfiguration()) as helper: + with redirect_stdout(io.StringIO()): + cnapp_commands.PAMCnappConfigSetCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='wiz', + client_id='abc', + client_secret=None, + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + ) + self.assertEqual(helper.call_args.kwargs['client_secret'], '') + + def test_config_set_invalid_provider_raises(self): + with self.assertRaises(ValueError): + cnapp_commands.PAMCnappConfigSetCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='bogus', + client_id='abc', + client_secret='secret', + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + ) + + def test_config_test_prints_success(self): + with patch.object(cnapp_commands.cnapp_helper, 'test_cnapp_configuration', return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappConfigTestCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='wiz', + client_id='abc', + client_secret='secret', + api_endpoint_url='https://api.wiz.io', + auth_endpoint_url='https://auth.wiz.io/oauth/token', + ) + self.assertEqual(helper.call_args.kwargs['auth_endpoint_url'], 'https://auth.wiz.io/oauth/token') + self.assertIn('validated', buf.getvalue().lower()) + + def test_config_test_propagates_helper_error(self): + with patch.object(cnapp_commands.cnapp_helper, 'test_cnapp_configuration', + side_effect=Exception('Credential validation failed: bad')): + with self.assertRaises(Exception): + cnapp_commands.PAMCnappConfigTestCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='wiz', + client_id='abc', + client_secret='bad', + api_endpoint_url='https://api.wiz.io', + ) + + def test_config_test_encrypter_success(self): + with patch.object(cnapp_commands.cnapp_helper, 'test_cnapp_encrypter', return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappConfigTestEncrypterCommand().execute( + self.params, url='https://encr.local') + helper.assert_called_once_with(self.params, url_base_encrypter='https://encr.local') + self.assertIn('reachable', buf.getvalue().lower()) + + def test_config_read_table_format(self): + config = cnapp_pb2.CnappConfiguration( + clientId='abc', + apiEndpointUrl='https://api.wiz.io', + authEndpointUrl='https://auth.wiz.io/oauth/token', + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + ) + with patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappConfigReadCommand().execute( + self.params, network_uid=NETWORK_UID, provider='wiz', format='table') + output = buf.getvalue() + self.assertIn('CNAPP Configuration', output) + self.assertIn('https://api.wiz.io', output) + self.assertIn('https://auth.wiz.io/oauth/token', output) + + def test_config_read_json_format(self): + config = cnapp_pb2.CnappConfiguration( + clientId='abc', + apiEndpointUrl='https://api.wiz.io', + authEndpointUrl='https://auth.wiz.io/oauth/token', + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + ) + with patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config): + buf = io.StringIO() + with redirect_stdout(buf): + result = cnapp_commands.PAMCnappConfigReadCommand().execute( + self.params, network_uid=NETWORK_UID, provider='wiz', format='json') + payload = json.loads(buf.getvalue()) + self.assertEqual(payload['clientId'], 'abc') + self.assertEqual(payload['provider'], 'CNAPP_PROVIDER_WIZ') + self.assertEqual(payload['apiEndpointUrl'], 'https://api.wiz.io') + self.assertEqual(payload['authEndpointUrl'], 'https://auth.wiz.io/oauth/token') + self.assertIsNone(result, 'JSON output is the channel — no value returned to the REPL') + + def test_config_read_handles_none_response(self): + with patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=None): + self.assertIsNone(cnapp_commands.PAMCnappConfigReadCommand().execute( + self.params, network_uid=NETWORK_UID, provider='wiz', format='table')) + + def test_config_delete_success(self): + with patch.object(cnapp_commands.cnapp_helper, 'delete_cnapp_configuration', return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappConfigDeleteCommand().execute(self.params, network_uid=NETWORK_UID) + helper.assert_called_once_with(self.params, network_uid=NETWORK_UID) + self.assertIn('deleted', buf.getvalue().lower()) + + +class TestQueueCommands(unittest.TestCase): + def setUp(self): + self.params = _mock_params() + + def _queue_response(self, items=None, has_more=False): + return cnapp_pb2.CnappQueueListResponse(items=items or [], hasMore=has_more) + + def _queue_item(self, queue_id=1, status_id=1, record_uid=b''): + return cnapp_pb2.CnappQueueItem( + cnappQueueId=queue_id, + cnappProviderId=cnapp_pb2.CNAPP_PROVIDER_WIZ, + cnappQueueStatusId=status_id, + receivedAt=1700000000000, + networkId=b'\x00' * 16, + recordUid=record_uid, + ) + + def test_queue_list_empty(self): + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', + return_value=self._queue_response()): + buf = io.StringIO() + with redirect_stdout(buf): + result = cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + no_decrypt=True) + self.assertIn('No CNAPP queue items', buf.getvalue()) + self.assertIsNone(result, 'queue list must not return the proto so the REPL does not dump bytes') + + def test_queue_list_with_items_table(self): + item = self._queue_item(queue_id=99, status_id=2, record_uid=b'\x01' * 16) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', + return_value=self._queue_response([item])): + buf = io.StringIO() + with redirect_stdout(buf): + result = cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + no_decrypt=True) + output = buf.getvalue() + self.assertIn('99', output) + self.assertIn('IN_PROGRESS', output) + self.assertIn('CNAPP_PROVIDER_WIZ', output) + self.assertIsNone(result) + + def test_queue_list_filter_resolves_named_status(self): + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', + return_value=self._queue_response()) as helper: + with redirect_stdout(io.StringIO()): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status='pending', format='table', + no_decrypt=True) + self.assertEqual(helper.call_args.kwargs['status_filter'], 1) + + def test_queue_list_json_format(self): + item = self._queue_item(queue_id=5, status_id=3, record_uid=b'\x02' * 16) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', + return_value=self._queue_response([item], has_more=True)): + buf = io.StringIO() + with redirect_stdout(buf): + result = cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='json', + no_decrypt=True) + payload = json.loads(buf.getvalue()) + self.assertEqual(payload['items'][0]['cnappQueueId'], 5) + self.assertEqual(payload['items'][0]['cnappQueueStatusName'], 'RESOLVED') + self.assertTrue(payload['hasMore']) + self.assertEqual(payload['items'][0]['recordUid'], + bytes_to_base64(b'\x02' * 16)) + self.assertNotIn('payload', payload['items'][0], + 'raw encrypted payload bytes must not leak into JSON output') + self.assertIsNone(result, 'JSON output stream must not also return a value') + + def test_queue_list_warns_when_has_more(self): + item = self._queue_item() + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', + return_value=self._queue_response([item], has_more=True)): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + no_decrypt=True) + self.assertIn('hasMore=true', buf.getvalue()) + + def test_queue_associate_success(self): + with patch.object(cnapp_commands.cnapp_helper, 'associate_cnapp_record', return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueAssociateCommand().execute( + self.params, cnapp_queue_id=12, record_uid=RECORD_UID) + helper.assert_called_once_with(self.params, cnapp_queue_id=12, record_uid=RECORD_UID) + self.assertIn('12', buf.getvalue()) + + def test_queue_remediate_prints_response(self): + response = cnapp_pb2.CnappRemediateResponse( + actionType=cnapp_pb2.ROTATE_CREDENTIALS, + result='Scheduled', + cnappQueueStatusId=2, + ) + with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item', + return_value=response): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='rotate_credentials', + provider='wiz', + ) + output = buf.getvalue() + self.assertIn('ROTATE_CREDENTIALS', output) + self.assertIn('IN_PROGRESS', output) + self.assertIn('Scheduled', output) + + def test_queue_remediate_unsupported_action_propagates(self): + with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item', + side_effect=Exception('Unsupported action type response code: RRC_BAD_REQUEST')): + with self.assertRaises(Exception) as ctx: + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='jit_access', + ) + self.assertIn('Unsupported', str(ctx.exception)) + + def test_queue_remediate_invalid_action_name(self): + with self.assertRaises(ValueError): + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, cnapp_queue_id=1, action_type='nuke_everything') + + def test_queue_set_status_normalizes_named(self): + response = cnapp_pb2.CnappSetStatusResponse(cnappQueueStatusId=3) + with patch.object(cnapp_commands.cnapp_helper, 'set_cnapp_queue_status', + return_value=response) as helper: + with redirect_stdout(io.StringIO()): + cnapp_commands.PAMCnappQueueSetStatusCommand().execute( + self.params, cnapp_queue_id=8, status='resolved', reason='manual') + kwargs = helper.call_args.kwargs + self.assertEqual(kwargs['cnapp_queue_status_id'], 3) + self.assertEqual(kwargs['reason'], 'manual') + + def test_queue_set_status_rejects_zero(self): + with self.assertRaises(CommandError): + cnapp_commands.PAMCnappQueueSetStatusCommand().execute( + self.params, cnapp_queue_id=8, status=0) + + def test_queue_set_status_rejects_unknown_name(self): + with self.assertRaises(CommandError): + cnapp_commands.PAMCnappQueueSetStatusCommand().execute( + self.params, cnapp_queue_id=8, status='snoozed') + + def test_queue_delete_success(self): + with patch.object(cnapp_commands.cnapp_helper, 'delete_cnapp_queue_item', + return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueDeleteCommand().execute(self.params, cnapp_queue_id=22) + helper.assert_called_once_with(self.params, cnapp_queue_id=22) + self.assertIn('22', buf.getvalue()) + + def test_queue_delete_unknown_id_propagates_error(self): + with patch.object(cnapp_commands.cnapp_helper, 'delete_cnapp_queue_item', + side_effect=Exception('Queue item not found: 99 Response code: RRC_BAD_REQUEST')): + with self.assertRaises(Exception): + cnapp_commands.PAMCnappQueueDeleteCommand().execute(self.params, cnapp_queue_id=99) + + +# --------------------------------------------------------------------------- +# Command tree wiring +# --------------------------------------------------------------------------- + +class TestCommandTree(unittest.TestCase): + """Sanity check that the cnapp commands are reachable via `pam cnapp ...`.""" + + def test_pam_cnapp_subcommands(self): + from keepercommander.commands.discoveryrotation import PAMControllerCommand + pam = PAMControllerCommand() + self.assertIn('cnapp', pam.subcommands) + config = pam.subcommands['cnapp'].subcommands['config'] + queue = pam.subcommands['cnapp'].subcommands['queue'] + self.assertEqual( + sorted(config.subcommands), + ['delete', 'read', 'set', 'test', 'test-encrypter'], + ) + self.assertEqual( + sorted(queue.subcommands), + ['associate', 'delete', 'list', 'remediate', 'set-status'], + ) + + +# --------------------------------------------------------------------------- +# Payload decryption — round-trip an AES-256-GCM envelope and decrypt it back +# --------------------------------------------------------------------------- + +def _encrypt_cnapp_payload_for_test(plaintext_json, key): + """Produce a CNAPP queue payload byte string the way the Encrypter would so we can + exercise `_decrypt_cnapp_payload` end-to-end without mocking AES-GCM.""" + nonce = os.urandom(12) + ciphertext = AESGCM(key).encrypt(nonce, plaintext_json.encode('utf-8'), None) + enc_b64url = base64.urlsafe_b64encode(nonce + ciphertext).rstrip(b'=').decode('ascii') + envelope = json.dumps({ + 'encrypted_payload': enc_b64url, + 'alg': 'AES-256-GCM', + 'version': '1', + }).encode('utf-8') + envelope_b64url = base64.urlsafe_b64encode(envelope).rstrip(b'=').decode('ascii') + return envelope_b64url.encode('utf-8') + + +class TestPayloadDecryption(unittest.TestCase): + """`_decrypt_cnapp_payload` must round-trip the envelope produced by the customer + Encrypter (UTF-8 base64url envelope wrapping nonce||ciphertext||tag).""" + + def setUp(self): + self.key = os.urandom(32) + self.plaintext = { + 'issue': {'id': 'wiz-001', 'severity': 'HIGH', 'created': '2026-05-01T00:00:00Z'}, + 'resource': {'name': 'i-abc', 'type': 'EC2', 'cloudPlatform': 'AWS'}, + 'control': {'name': 'Public S3', 'risks': ['data-exposure']}, + 'tags': ['team:platform'], + } + + def test_roundtrip(self): + payload = _encrypt_cnapp_payload_for_test(json.dumps(self.plaintext), self.key) + decrypted = cnapp_commands._decrypt_cnapp_payload(payload, self.key) + self.assertEqual(decrypted['issue']['id'], 'wiz-001') + self.assertEqual(decrypted['resource']['name'], 'i-abc') + + def test_wrong_key_raises(self): + payload = _encrypt_cnapp_payload_for_test(json.dumps(self.plaintext), self.key) + with self.assertRaises(Exception): + cnapp_commands._decrypt_cnapp_payload(payload, os.urandom(32)) + + def test_unsupported_alg_raises(self): + envelope = json.dumps({'encrypted_payload': '', 'alg': 'ChaCha20', 'version': '1'}).encode('utf-8') + payload = base64.urlsafe_b64encode(envelope).rstrip(b'=') + with self.assertRaises(ValueError): + cnapp_commands._decrypt_cnapp_payload(payload, self.key) + + def test_missing_alg_raises(self): + envelope = json.dumps({'encrypted_payload': '', 'version': '1'}).encode('utf-8') + payload = base64.urlsafe_b64encode(envelope).rstrip(b'=') + with self.assertRaises(ValueError) as ctx: + cnapp_commands._decrypt_cnapp_payload(payload, self.key) + self.assertIn('missing', str(ctx.exception).lower()) + + def test_short_ciphertext_raises(self): + envelope = json.dumps({ + 'encrypted_payload': base64.urlsafe_b64encode(b'abc').rstrip(b'=').decode('ascii'), + 'alg': 'AES-256-GCM', + }).encode('utf-8') + payload = base64.urlsafe_b64encode(envelope).rstrip(b'=') + with self.assertRaises(ValueError): + cnapp_commands._decrypt_cnapp_payload(payload, self.key) + + +class TestKeyDecode(unittest.TestCase): + """`_decode_aes_key` must accept both standard and url-safe base64, only when the + decoded length is exactly 32 bytes (AES-256). 16-byte keys are rejected.""" + + def test_standard_base64_32(self): + raw = base64.b64encode(b'\x11' * 32).decode('ascii') + self.assertEqual(cnapp_commands._decode_aes_key(raw), b'\x11' * 32) + + def test_urlsafe_base64_32(self): + raw = base64.urlsafe_b64encode(b'\x22' * 32).decode('ascii') + self.assertEqual(cnapp_commands._decode_aes_key(raw), b'\x22' * 32) + + def test_16_bytes_returns_none(self): + raw = base64.b64encode(b'\x44' * 16).decode('ascii') + self.assertIsNone(cnapp_commands._decode_aes_key(raw)) + + def test_wrong_length_returns_none(self): + raw = base64.b64encode(b'\x33' * 24).decode('ascii') + self.assertIsNone(cnapp_commands._decode_aes_key(raw)) + + def test_garbage_returns_none(self): + self.assertIsNone(cnapp_commands._decode_aes_key('not base64 at all!!!')) + self.assertIsNone(cnapp_commands._decode_aes_key('')) + self.assertIsNone(cnapp_commands._decode_aes_key(None)) + + +class TestLoadEncrypterKey(unittest.TestCase): + """_load_encrypter_key must try all labeled candidates before giving up.""" + + VALID_KEY = b'\xAB' * 32 + INVALID_RAW = 'not-a-valid-key!!' + + def _make_typed_field(self, type_ref, label=None, value=None): + field = MagicMock() + field.type = type_ref + field.label = label + field.value = value or [] + return field + + def _make_record(self, secret_labeled=None, note_labeled=None, note_unlabeled=None): + record = MagicMock(spec=cnapp_commands.vault.TypedRecord) + + def get_typed_field(type_ref, label=None): + if type_ref == 'secret' and label == cnapp_commands.CNAPP_ENCRYPTION_KEY_LABEL: + return secret_labeled + if type_ref == 'note' and label == cnapp_commands.CNAPP_ENCRYPTION_KEY_LABEL: + return note_labeled + if type_ref == 'note' and label is None: + return note_unlabeled + return None + + record.get_typed_field.side_effect = get_typed_field + return record + + def _field_with_value(self, raw): + f = MagicMock() + f.value = [raw] + return f + + def test_returns_valid_secret_labeled_key(self): + raw = base64.b64encode(self.VALID_KEY).decode('ascii') + record = self._make_record(secret_labeled=self._field_with_value(raw)) + params = MagicMock() + with patch.object(cnapp_commands.vault.KeeperRecord, 'load', return_value=record): + result = cnapp_commands._load_encrypter_key(params, 'uid123') + self.assertEqual(result, self.VALID_KEY) + + def test_falls_through_to_note_labeled_when_secret_invalid(self): + """When the secret-labeled field is invalid, note-labeled field must still be tried.""" + valid_raw = base64.b64encode(self.VALID_KEY).decode('ascii') + record = self._make_record( + secret_labeled=self._field_with_value(self.INVALID_RAW), + note_labeled=self._field_with_value(valid_raw), + ) + params = MagicMock() + with patch.object(cnapp_commands.vault.KeeperRecord, 'load', return_value=record): + result = cnapp_commands._load_encrypter_key(params, 'uid123') + self.assertEqual(result, self.VALID_KEY) + + def test_warns_and_returns_none_when_all_labeled_invalid(self): + """If labeled fields exist but all are invalid, warn and return None (no unlabeled fallback).""" + unlabeled = self._field_with_value(base64.b64encode(self.VALID_KEY).decode('ascii')) + record = self._make_record( + secret_labeled=self._field_with_value(self.INVALID_RAW), + note_labeled=self._field_with_value(self.INVALID_RAW), + note_unlabeled=unlabeled, + ) + params = MagicMock() + with patch.object(cnapp_commands.vault.KeeperRecord, 'load', return_value=record): + with self.assertLogs(cnapp_commands.__name__, level='WARNING') as cm: + result = cnapp_commands._load_encrypter_key(params, 'uid123') + self.assertIsNone(result) + self.assertTrue(any('not a valid AES-256 key' in msg for msg in cm.output)) + + def test_falls_back_to_unlabeled_note_when_no_labeled_fields(self): + """When no labeled key fields exist at all, the first unlabeled note field is used.""" + valid_raw = base64.b64encode(self.VALID_KEY).decode('ascii') + unlabeled = self._field_with_value(valid_raw) + record = self._make_record(note_unlabeled=unlabeled) + params = MagicMock() + with patch.object(cnapp_commands.vault.KeeperRecord, 'load', return_value=record): + result = cnapp_commands._load_encrypter_key(params, 'uid123') + self.assertEqual(result, self.VALID_KEY) + + def test_returns_none_for_missing_record_uid(self): + params = MagicMock() + self.assertIsNone(cnapp_commands._load_encrypter_key(params, None)) + self.assertIsNone(cnapp_commands._load_encrypter_key(params, '')) + + +class TestQueueListDecryptionIntegration(unittest.TestCase): + """End-to-end: `queue list` resolves the encrypter key via the vault record, decrypts + each payload, and writes the human summary into the table cell.""" + + def setUp(self): + self.params = _mock_params() + self.key = os.urandom(32) + + def _make_item(self, queue_id, plaintext): + return cnapp_pb2.CnappQueueItem( + cnappQueueId=queue_id, + cnappProviderId=cnapp_pb2.CNAPP_PROVIDER_WIZ, + cnappQueueStatusId=1, + receivedAt=1700000000000, + networkId=b'\x00' * 16, + payload=_encrypt_cnapp_payload_for_test(json.dumps(plaintext), self.key), + ) + + def test_table_shows_decrypted_summary_when_key_resolves(self): + items = [self._make_item(101, { + 'issue': {'id': 'wiz-999', 'severity': 'CRITICAL'}, + 'control': {'name': 'Open SSH'}, + 'resource': {'name': 'prod-db-1'}, + })] + response = cnapp_pb2.CnappQueueListResponse(items=items) + config = cnapp_pb2.CnappConfiguration( + cnappConfigRecordUid=b'\xab' * 16, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + ) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config), \ + patch.object(cnapp_commands, '_load_encrypter_key', return_value=self.key): + buf = io.StringIO() + with redirect_stdout(buf): + result = cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + provider='wiz', config_record_uid=None, no_decrypt=False) + output = buf.getvalue() + self.assertIn('CRITICAL', output) + self.assertIn('Open SSH', output) + self.assertIn('prod-db-1', output) + self.assertNotIn('', output, 'payload should have been decrypted') + self.assertIsNone(result) + + def test_table_marks_encrypted_when_key_unavailable(self): + items = [self._make_item(7, {'issue': {'id': 'x'}})] + response = cnapp_pb2.CnappQueueListResponse(items=items) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', + return_value=cnapp_pb2.CnappConfiguration()): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + provider='wiz', no_decrypt=False) + output = buf.getvalue() + self.assertIn('', output) + self.assertIn('No encrypter key', output) + + def test_json_includes_decrypted_payload_and_no_raw_payload(self): + plaintext = {'issue': {'id': 'wiz-42'}, 'resource': {'name': 'i-xyz'}} + response = cnapp_pb2.CnappQueueListResponse(items=[self._make_item(42, plaintext)]) + config = cnapp_pb2.CnappConfiguration(cnappConfigRecordUid=b'\xcd' * 16, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config), \ + patch.object(cnapp_commands, '_load_encrypter_key', return_value=self.key): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='json', + provider='wiz', no_decrypt=False) + payload = json.loads(buf.getvalue()) + self.assertEqual(payload['items'][0]['decryptedPayload']['issue']['id'], 'wiz-42') + self.assertNotIn('payload', payload['items'][0]) + + def test_decrypt_failure_keeps_other_rows_and_reports(self): + good = self._make_item(1, { + 'issue': {'id': 'wiz-good-should-not-show'}, + 'control': {'name': 'Open SSH'}, + 'resource': {'name': 'good-resource'}, + }) + bad = cnapp_pb2.CnappQueueItem( + cnappQueueId=2, + cnappProviderId=cnapp_pb2.CNAPP_PROVIDER_WIZ, + cnappQueueStatusId=1, + payload=b'this-is-not-a-valid-envelope', + ) + response = cnapp_pb2.CnappQueueListResponse(items=[good, bad]) + config = cnapp_pb2.CnappConfiguration(cnappConfigRecordUid=b'\xef' * 16, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config), \ + patch.object(cnapp_commands, '_load_encrypter_key', return_value=self.key): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + provider='wiz', no_decrypt=False) + output = buf.getvalue() + self.assertIn('Open SSH', output) + self.assertNotIn('wiz-good-should-not-show', output) + self.assertIn('good-resource', output) + self.assertIn('', output) + self.assertIn('failed to decrypt payload', output) + + def test_json_reports_decrypt_error(self): + good = self._make_item(1, {'issue': {'id': 'wiz-1'}}) + bad = cnapp_pb2.CnappQueueItem( + cnappQueueId=2, + cnappProviderId=cnapp_pb2.CNAPP_PROVIDER_WIZ, + cnappQueueStatusId=1, + payload=b'not-valid', + ) + response = cnapp_pb2.CnappQueueListResponse(items=[good, bad]) + config = cnapp_pb2.CnappConfiguration(cnappConfigRecordUid=b'\xef' * 16, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config), \ + patch.object(cnapp_commands, '_load_encrypter_key', return_value=self.key): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='json', + provider='wiz', no_decrypt=False) + items = json.loads(buf.getvalue())['items'] + self.assertIn('decryptedPayload', items[0]) + self.assertIn('decryptError', items[1]) + self.assertNotIn('decryptedPayload', items[1]) + + def test_no_decrypt_flag_skips_key_lookup(self): + items = [self._make_item(11, {'issue': {'id': 'x'}})] + response = cnapp_pb2.CnappQueueListResponse(items=items) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands, '_load_encrypter_key') as key_loader: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + no_decrypt=True) + key_loader.assert_not_called() + self.assertNotIn('No encrypter key', buf.getvalue()) + self.assertIn('', buf.getvalue()) + + +if __name__ == '__main__': + unittest.main() diff --git a/unit-tests/pam/test_dag_layer_b_migration.py b/unit-tests/pam/test_dag_layer_b_migration.py index ba381cdcb..7f85e10db 100644 --- a/unit-tests/pam/test_dag_layer_b_migration.py +++ b/unit-tests/pam/test_dag_layer_b_migration.py @@ -155,6 +155,37 @@ def _capture(params, rq): }).encode() meta_mock.assert_called_once() + def test_happy_path_bundles_current_meta_so_krouter_persists_ai_edge(self): + """Regression: krouter's configure_resource only writes a settings edge + when it loads loopEdges, which it does only for requests carrying + meta/jit/connection (UserRest.kt:497). A keeperAiSettings-only request + leaves loopEdges null and the ai_settings write is silently dropped. The + Web Vault always sends meta alongside AI settings; Commander must mirror + that by bundling the resource's current meta in the same request.""" + captured = {} + + def _capture(params, rq): + captured['rq'] = rq + return None + + meta_dict = {'version': 1, 'allowedSettings': {'aiEnabled': True}, 'rotateOnTermination': False} + with _patch_inputs(), \ + patch.object(ai_mod, 'encrypt_aes', return_value=b'CIPHER_BYTES'), \ + patch.object(ai_mod, 'get_resource_settings', return_value=meta_dict) as meta_mock, \ + patch('keepercommander.commands.pam.router_helper.router_configure_resource', side_effect=_capture): + ok = ai_mod.set_resource_keeper_ai_settings( + _mock_params(), RESOURCE_UID_STR, {'level': 'critical'}, config_uid=CONFIG_UID_STR + ) + assert ok is True + rq = captured['rq'] + assert rq.keeperAiSettings == b'CIPHER_BYTES' + # The fix: meta must be present so krouter fetches loopEdges and persists + # the ai_settings edge. Without it the write is a silent no-op. + assert rq.meta == json.dumps(meta_dict).encode() + # meta is read from the resource's current 'meta' DATA edge. + meta_mock.assert_called_once() + assert meta_mock.call_args.args[2] == 'meta' + def test_permission_denied_with_fallback_enabled_calls_legacy(self): legacy_called = {'count': 0} diff --git a/unit-tests/service/test_service_manager.py b/unit-tests/service/test_service_manager.py index f1798ac30..45ac3925b 100644 --- a/unit-tests/service/test_service_manager.py +++ b/unit-tests/service/test_service_manager.py @@ -115,6 +115,10 @@ def test_service_status_when_not_running(self): status_cmd.execute(self.params) mock_print.assert_called_with("Current status: No Commander Service is running currently") + def test_service_status_skips_sync_on_auth(self): + """service-status still requires login but must not trigger a full vault sync.""" + self.assertTrue(ServiceStatus.skip_sync_on_auth) + def test_process_info_save_load(self): """Test ProcessInfo save and load operations""" test_pid = 12345 diff --git a/unit-tests/test_command_enterprise_api_keys.py b/unit-tests/test_command_enterprise_api_keys.py index e90108927..92d4183f9 100644 --- a/unit-tests/test_command_enterprise_api_keys.py +++ b/unit-tests/test_command_enterprise_api_keys.py @@ -100,7 +100,7 @@ def test_api_key_list_json_format(self): "name": "SIEM Tool", "status": "Active", "issued_date": "2025-07-08 14:16:07", - "expiration_date": "2026-07-08 14:16:07", + "expiration_date": "2030-07-08 14:16:07", "integration": "SIEM:2" }, { @@ -667,7 +667,7 @@ def communicate_rest_success(params, request, path, rs_type=None): token4.name = "SIEM Tool" token4.enterprise_id = 8560 token4.issuedDate = int(datetime.datetime(2025, 7, 8, 14, 16, 7).timestamp() * 1000) - token4.expirationDate = int(datetime.datetime(2026, 7, 8, 14, 16, 7).timestamp() * 1000) + token4.expirationDate = int(datetime.datetime(2030, 7, 8, 14, 16, 7).timestamp() * 1000) integration7 = token4.integrations.add() integration7.roleName = "SIEM" integration7.apiIntegrationTypeName = "SIEM" diff --git a/unit-tests/test_command_register.py b/unit-tests/test_command_register.py index 3074af5a7..2f35ea315 100644 --- a/unit-tests/test_command_register.py +++ b/unit-tests/test_command_register.py @@ -6,7 +6,7 @@ from data_vault import get_synced_params, VaultEnvironment from keepercommander.commands import register from keepercommander.error import CommandError -from keepercommander.proto import APIRequest_pb2, record_pb2 +from keepercommander.proto import APIRequest_pb2, folder_pb2, record_pb2 from keepercommander import utils from keepercommander.subfolder import NestedShareFolderNode @@ -305,14 +305,13 @@ def test_share_folder(self): self.assertEqual(len(TestRegister.expected_commands), 0) TestRegister.expected_commands.extend(['shared_folder_update_v3']) - cmd.execute(params, action='revoke', user=['user2@keepersecurity.com'], folder=shared_folder_uid) + cmd.execute(params, action='remove', user=['user2@keepersecurity.com'], folder=shared_folder_uid) self.assertEqual(len(TestRegister.expected_commands), 0) def test_share_folder_prepare_request_sets_rotate_on_expiration(self): - """SharedFolderUpdateUser/Team/Record all carry rotateOnExpiration when -roe is on.""" + """Folder-wide expiration/ROE applies to user/team protos, not record protos.""" params = get_synced_params() shared_folder_uid = next(iter(params.shared_folder_cache.keys())) - record_uid = next(iter([x['record_uid'] for x in params.meta_data_cache.values() if x['can_share']])) team_uid = utils.base64_url_encode(b'a' * 16) curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) @@ -331,7 +330,7 @@ def test_share_folder_prepare_request_sets_rotate_on_expiration(self): curr_sf=curr_sf, users=['user2@keepersecurity.com'], teams=[team_uid], - rec_uids=[record_uid], + rec_uids=[], share_expiration=future_ts, rotate_on_expiration=True, ) @@ -340,7 +339,7 @@ def test_share_folder_prepare_request_sets_rotate_on_expiration(self): team_msgs = list(rq.sharedFolderAddTeam) + list(rq.sharedFolderUpdateTeam) record_msgs = list(rq.sharedFolderAddRecord) + list(rq.sharedFolderUpdateRecord) - for msgs, label in [(user_msgs, 'user'), (team_msgs, 'team'), (record_msgs, 'record')]: + for msgs, label in [(user_msgs, 'user'), (team_msgs, 'team')]: self.assertTrue(msgs, f'expected at least one {label} proto on the wire') for m in msgs: self.assertTrue(m.rotateOnExpiration, @@ -348,13 +347,330 @@ def test_share_folder_prepare_request_sets_rotate_on_expiration(self): self.assertGreater(m.expiration, 0) self.assertEqual(m.timerNotificationType, record_pb2.NOTIFY_OWNER) - def test_share_folder_rotate_on_expiration_rejects_folder_without_pam_user(self): + self.assertFalse(record_msgs, 'record protos must not carry folder-wide expiration') + + def test_share_folder_prepare_request_expire_in_applies_to_folder_user_only(self): + """With -r and --expire-in, only folder user gets a timer; record protos are permissions-only.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + record_uid = next(iter(params.shared_folder_cache[shared_folder_uid]['records']))['record_uid'] + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf.setdefault('users', []) + future_ts = int(datetime.datetime.now().timestamp()) + 86_400 + + params.key_cache['user2@keepersecurity.com'] = mock.MagicMock( + rsa=utils.base64_url_decode(vault_env.encoded_public_key), ec=None) + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'can_edit': 'on', 'can_share': 'on'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[record_uid], + share_expiration=future_ts, + rotate_on_expiration=True, + ) + + user_msgs = list(rq.sharedFolderAddUser) + list(rq.sharedFolderUpdateUser) + record_msgs = list(rq.sharedFolderAddRecord) + list(rq.sharedFolderUpdateRecord) + + self.assertTrue(user_msgs, 'expected folder user share with expiration') + for m in user_msgs: + self.assertGreater(m.expiration, 0) + self.assertTrue(m.rotateOnExpiration) + self.assertEqual(m.timerNotificationType, record_pb2.NOTIFY_OWNER) + + self.assertTrue(record_msgs, 'expected record permission update') + for m in record_msgs: + self.assertEqual(m.expiration, 0) + self.assertFalse(m.rotateOnExpiration) + + def test_share_folder_remove_with_record_permissions_combined(self): + """-a remove affects -e only; -r/-d/-s update record permissions in the same request.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + record_uid = next(iter(params.shared_folder_cache[shared_folder_uid]['records']))['record_uid'] + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'user2@keepersecurity.com', + 'manage_records': True, + 'manage_users': True, + }] + curr_sf['records'] = [{'record_uid': record_uid, 'can_edit': True, 'can_share': True}] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'remove', 'can_edit': 'off'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[record_uid], + ) + + self.assertEqual(list(rq.sharedFolderRemoveUser), ['user2@keepersecurity.com']) + self.assertTrue(list(rq.sharedFolderUpdateRecord)) + self.assertFalse(list(rq.sharedFolderRemoveRecord)) + self.assertFalse(list(rq.sharedFolderAddRecord)) + + def test_share_folder_remove_rejects_record_permissions_without_record_target(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + cmd = register.ShareFolderCommand() + + with self.assertRaises(CommandError) as ctx: + cmd.execute( + params, + action='remove', + user=['user2@keepersecurity.com'], + folder=shared_folder_uid, + can_edit='on', + force=True, + ) + self.assertIn('-d and -s require a record target', str(ctx.exception)) + + def test_share_folder_grant_rejects_record_permissions_without_record_target(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + cmd = register.ShareFolderCommand() + + with self.assertRaises(CommandError) as ctx: + cmd.execute( + params, + action='grant', + folder=shared_folder_uid, + can_edit='on', + force=True, + ) + self.assertIn('-d and -s require a record target', str(ctx.exception)) + + def test_share_folder_rejects_record_not_in_folder(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + folder_record_uids = {x['record_uid'] for x in params.shared_folder_cache[shared_folder_uid]['records']} + foreign_record_uid = next( + uid for uid in params.record_cache if uid not in folder_record_uids) + cmd = register.ShareFolderCommand() + + with self.assertRaises(CommandError) as ctx: + cmd.execute( + params, + action='grant', + folder=shared_folder_uid, + record=[foreign_record_uid], + can_edit='on', + force=True, + ) + self.assertIn('not in shared folder', str(ctx.exception)) + + def test_share_folder_prepare_request_remove_user_only(self): + """Remove with -e revokes folder access only; no record protos are sent.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + record_uid = next(iter(params.shared_folder_cache[shared_folder_uid]['records']))['record_uid'] + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'user2@keepersecurity.com', + 'manage_records': True, + 'manage_users': True, + }] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'remove'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[], + ) + + self.assertTrue(list(rq.sharedFolderRemoveUser)) + self.assertFalse(list(rq.sharedFolderRemoveRecord)) + self.assertFalse(list(rq.sharedFolderUpdateRecord)) + self.assertFalse(list(rq.sharedFolderAddRecord)) + + def test_share_folder_owner_self_remove_blocked_without_backup_manager(self): params = get_synced_params() shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + params.shared_folder_cache[shared_folder_uid]['owner_account_uid'] = utils.base64_url_encode( + params.account_uid_bytes) + params.shared_folder_cache[shared_folder_uid]['users'] = [{ + 'username': params.user, + 'manage_records': True, + 'manage_users': True, + }] + params.shared_folder_cache[shared_folder_uid]['teams'] = [] cmd = register.ShareFolderCommand() + with self.assertRaises(CommandError) as ctx: - cmd.execute(params, action='grant', user=['user2@keepersecurity.com'], - folder=shared_folder_uid, expire_in='1d', rotate_on_expiration=True) + cmd.execute( + params, + action='remove', + user=[params.user], + folder=shared_folder_uid, + force=True, + ) + self.assertIn('no other participant has', str(ctx.exception)) + + def test_share_folder_participant_self_remove_prompt_declined(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + params.shared_folder_cache[shared_folder_uid]['owner_account_uid'] = utils.generate_uid() + params.shared_folder_cache[shared_folder_uid]['users'] = [ + {'username': params.user, 'manage_records': False, 'manage_users': False}, + {'username': 'user2@keepersecurity.com', 'manage_records': True, 'manage_users': True}, + ] + params.shared_folder_cache[shared_folder_uid]['teams'] = [] + cmd = register.ShareFolderCommand() + + with mock.patch('keepercommander.commands.register.user_choice', return_value='n'): + with self.assertRaises(CommandError) as ctx: + cmd.execute( + params, + action='remove', + user=[params.user], + folder=shared_folder_uid, + ) + self.assertIn('Operation cancelled', str(ctx.exception)) + + def test_share_folder_participant_self_remove_prompt_accepted(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + params.shared_folder_cache[shared_folder_uid]['owner_account_uid'] = utils.generate_uid() + params.shared_folder_cache[shared_folder_uid]['users'] = [ + {'username': params.user, 'manage_records': False, 'manage_users': False}, + {'username': 'user2@keepersecurity.com', 'manage_records': True, 'manage_users': True}, + ] + params.shared_folder_cache[shared_folder_uid]['teams'] = [] + cmd = register.ShareFolderCommand() + TestRegister.expected_commands.extend(['shared_folder_update_v3']) + + with mock.patch('keepercommander.commands.register.user_choice', return_value='y'): + cmd.execute( + params, + action='remove', + user=[params.user], + folder=shared_folder_uid, + ) + self.assertEqual(len(TestRegister.expected_commands), 0) + + def test_share_folder_prepare_request_skips_redundant_user_update_for_record_only(self): + """When sharing another record without expiration, skip redundant folder user update.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + record_uid = next(iter(params.shared_folder_cache[shared_folder_uid]['records']))['record_uid'] + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'user2@keepersecurity.com', + 'manage_records': True, + 'manage_users': True, + }] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'can_edit': 'on', 'can_share': 'on'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[record_uid], + share_expiration=None, + ) + + self.assertFalse(list(rq.sharedFolderUpdateUser)) + self.assertTrue(list(rq.sharedFolderUpdateRecord)) + self.assertFalse(list(rq.sharedFolderAddRecord)) + + def test_share_folder_prepare_request_updates_user_when_explicit_perms_with_record(self): + """-p/-o with -r must always update folder user permissions.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + record_uid = next(iter(params.shared_folder_cache[shared_folder_uid]['records']))['record_uid'] + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'user2@keepersecurity.com', + 'manage_records': True, + 'manage_users': True, + }] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'manage_records': 'on', 'manage_users': 'on', + 'can_edit': 'on', 'can_share': 'on'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[record_uid], + share_expiration=None, + ) + + user_msgs = list(rq.sharedFolderUpdateUser) + self.assertTrue(user_msgs, 'explicit -p/-o must update folder user even with -r') + self.assertEqual(user_msgs[0].manageRecords, folder_pb2.BOOLEAN_TRUE) + self.assertEqual(user_msgs[0].manageUsers, folder_pb2.BOOLEAN_TRUE) + + def test_share_folder_prepare_request_case_insensitive_user_lookup(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'User2@KeeperSecurity.com', + 'manage_records': False, + 'manage_users': False, + }] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'manage_records': 'on', 'manage_users': 'on'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[], + ) + + user_msgs = list(rq.sharedFolderUpdateUser) + self.assertEqual(len(user_msgs), 1) + self.assertEqual(user_msgs[0].username, 'User2@KeeperSecurity.com') + + def test_share_folder_prepare_request_updates_user_when_folder_wide_expiration(self): + """Folder-wide --expire-in (no -r) sets expiration on the folder user share.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + future_ts = int(datetime.datetime.now().timestamp()) + 86_400 + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'user2@keepersecurity.com', + 'manage_records': True, + 'manage_users': True, + }] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'manage_records': 'on', 'manage_users': 'on'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[], + share_expiration=future_ts, + ) + + user_msgs = list(rq.sharedFolderUpdateUser) + self.assertTrue(user_msgs) + self.assertGreater(user_msgs[0].expiration, 0) + + def test_share_folder_rotate_on_expiration_rejects_folder_without_pam_user(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + cmd = register.ShareFolderCommand() + with mock.patch('keepercommander.commands.register.SyncDownCommand.execute'): + with self.assertRaises(CommandError) as ctx: + cmd.execute(params, action='grant', user=['user2@keepersecurity.com'], + folder=shared_folder_uid, expire_in='1d', rotate_on_expiration=True) self.assertIn('pamUser', str(ctx.exception)) @staticmethod diff --git a/unit-tests/test_windows_file_permissions.py b/unit-tests/test_windows_file_permissions.py new file mode 100644 index 000000000..c058da26e --- /dev/null +++ b/unit-tests/test_windows_file_permissions.py @@ -0,0 +1,68 @@ +import os +import tempfile +from unittest import TestCase, mock + +from keepercommander import utils + + +class TestWindowsIcaclsPrincipal(TestCase): + def test_userdomain_and_username(self): + with mock.patch.dict(os.environ, {'USERNAME': 'ivan', 'USERDOMAIN': 'IVAN'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'IVAN\\ivan') + + def test_domain_user(self): + with mock.patch.dict(os.environ, {'USERNAME': 'jdoe', 'USERDOMAIN': 'CORP'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'CORP\\jdoe') + + def test_falls_back_to_computername(self): + env = os.environ.copy() + env.pop('USERDOMAIN', None) + with mock.patch.dict(os.environ, env, clear=True): + os.environ['USERNAME'] = 'bob' + os.environ['COMPUTERNAME'] = 'MYPC' + self.assertEqual(utils._windows_icacls_principal(), 'MYPC\\bob') + + def test_already_qualified_username(self): + with mock.patch.dict(os.environ, {'USERNAME': 'CORP\\jdoe', 'USERDOMAIN': 'CORP'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'CORP\\jdoe') + + def test_falls_back_to_getlogin(self): + with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch('os.getlogin', return_value='localuser'): + with mock.patch.dict(os.environ, {'COMPUTERNAME': 'MYPC'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'MYPC\\localuser') + + +class TestSetFilePermissionsWindows(TestCase): + def _grant_principal(self, mock_run): + for call in mock_run.call_args_list: + args = call.args[0] + if '/grant' in args: + return args[args.index('/grant') + 1] + self.fail('icacls /grant was not called') + + @mock.patch('subprocess.run') + @mock.patch('platform.system', return_value='Windows') + @mock.patch('os.path.islink', return_value=False) + def test_grant_uses_qualified_principal_when_names_collide(self, _islink, _system, mock_run): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + path = tmp.name + try: + with mock.patch.dict(os.environ, {'USERNAME': 'ivan', 'USERDOMAIN': 'IVAN'}, clear=False): + utils.set_file_permissions(path) + self.assertEqual(self._grant_principal(mock_run), 'IVAN\\ivan:RW') + finally: + os.unlink(path) + + @mock.patch('subprocess.run') + @mock.patch('platform.system', return_value='Windows') + @mock.patch('os.path.islink', return_value=False) + def test_grant_uses_domain_principal(self, _islink, _system, mock_run): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + path = tmp.name + try: + with mock.patch.dict(os.environ, {'USERNAME': 'jdoe', 'USERDOMAIN': 'CORP'}, clear=False): + utils.set_file_permissions(path) + self.assertEqual(self._grant_principal(mock_run), 'CORP\\jdoe:RW') + finally: + os.unlink(path)