diff --git a/docs/cyberark-pam-import.md b/docs/cyberark-pam-import.md new file mode 100644 index 000000000..c78732ab5 --- /dev/null +++ b/docs/cyberark-pam-import.md @@ -0,0 +1,286 @@ +# CyberArk PAM Import Command — Technical Reference + +## Overview + +Imports privileged accounts from CyberArk (self-hosted PVWA or Privilege Cloud) into KeeperPAM as properly structured PAM records with folder hierarchy, credential rotation, session recording, and access control. + +**Commands**: `pam project cyberark-import` (alias: `ca`) and `pam project cyberark-cleanup` (alias: `CC`) + +--- + +## What It Does + +### Input: CyberArk PVWA REST API +Connects to CyberArk's `/PasswordVault/API/` endpoints to extract: +- **Accounts** — privileged credentials with platform type, address, username, password/SSH key +- **Safes** — container vaults that organize accounts (maps to Keeper folders) +- **Safe Members** — users/groups with granular permissions (maps to shared folder permissions) +- **Linked Accounts** — logon, reconcile, and enable accounts tied to resources +- **Master Policy** — session recording, rotation, tunneling settings +- **Vault Users/Groups** — matched to Keeper users/teams for permission migration + +### Output: KeeperPAM Vault Records +Produces import JSON consumed by `edit.py` (PAMProjectImportCommand) / `extend.py` (PAMProjectExtendCommand): + +``` +Keeper Vault/ +├── {Project} - Resources/ (shared folder) +│ ├── SafeA/ (subfolder per CyberArk safe) +│ │ ├── pamMachine: linux1 (Unix SSH resource) +│ │ ├── pamDatabase: db1 (MSSQL/Oracle/MySQL/PostgreSQL) +│ │ └── pamMachine: firewall1 (network device) +│ └── SafeB/ +│ └── pamMachine: win-dc1 (Windows + domain_name) +├── {Project} - Users/ (shared folder) +│ ├── SafeA/ +│ │ ├── pamUser: root@linux1 (linked to pamMachine via launch_credentials) +│ │ ├── pamUser: sa@db1 (linked, with connect_database) +│ │ └── pamUser: admin@fw1 (linked) +│ └── SafeB/ +│ ├── pamUser: admin@win-dc1 (linked, with distinguished_name) +│ ├── pamUser: svc (logon) (CyberArk linked logon account) +│ ├── pamUser: recon (recon) (CyberArk reconcile → administrative_credentials) +│ └── login: web-portal (BusinessWebsite → login record with URL) +└── PAM Configuration record (gateway, rotation schedule, session recording) +``` + +--- + +## Record Mapping + +### Platform → Record Type + +| CyberArk Platform | Keeper Record | Protocol | Port | +|---|---|---|---| +| UnixSSH | pamMachine | ssh | 22 | +| UnixSSHKey, UnixSSHKeys | pamMachine | ssh | 22 | +| WinDomain, WinLocalAccount, WinServerLocal, WinDesktopLocal | pamMachine | rdp | 3389 | +| MSSql | pamDatabase | mssql | 1433 | +| Oracle | pamDatabase | sql-server | 1521 | +| MySQL | pamDatabase | mysql | 3306 | +| PostgreSQL | pamDatabase | postgresql | 5432 | +| PaloAltoNetworks, CiscoIOS, CiscoASA, JuniperJunos, F5BigIP, CheckPointGAIA | pamMachine | ssh | 22 | +| CyberArk (internal) | pamMachine | ssh | 22 | +| BusinessWebsite | login | — | — | +| (empty platformId) | pamMachine | ssh | 22 | +| (unknown platformId) | pamMachine | ssh | 22 | + +### Field Mapping + +| CyberArk Field | Keeper Field | Where | +|---|---|---| +| address | host | resource | +| platformAccountProperties.Port | port | resource + pam_settings.connection | +| platformAccountProperties.LogonDomain | domain_name | resource (pamMachine only) | +| platformAccountProperties.Database | connect_database | pamUser (pamDatabase only) | +| platformAccountProperties.DistinguishedName | distinguished_name | pamUser | +| userName | login | pamUser (prefixed with LogonDomain\ if present) | +| password (retrieved) | password | pamUser | +| password (SSH key platforms or secretType=key) | private_pem_key | pamUser (password cleared) | +| secretManagement.automaticManagementEnabled | rotation_settings.enabled | pamUser (on/off) | +| secretManagement.manualManagementReason | notes | pamUser (annotated) | +| secretManagement.status=failure | notes | pamUser (FAILURE annotated) | +| safeName | folder_path | resource + users (under shared folder roots) | +| linkedAccounts.reconcileAccount | pam_settings.connection.administrative_credentials | resource | +| linkedAccounts.logonAccount | pamUser (nested) | resource.users[] | +| Master Policy | pam_configuration | connections, rotation, tunneling, session recording | + +### Resource pam_settings Structure + +Every resource (pamMachine/pamDatabase) gets: +```json +{ + "pam_settings": { + "options": { + "rotation": "on|off", + "connections": "on", + "tunneling": "off", + "graphical_session_recording": "off" + }, + "connection": { + "protocol": "ssh|rdp|mssql|...", + "port": "22|3389|...", + "launch_credentials": "username@resource-title" + } + } +} +``` + +### User rotation_settings Structure + +Every pamUser nested in a resource gets: +```json +{ + "rotation_settings": { + "rotation": "general", + "enabled": "on|off", + "schedule": {"type": "on-demand"} + } +} +``` + +`edit.py` resolves `rotation_settings.resource` → parent machine UID automatically at import time. + +--- + +## Authentication + +### Self-Hosted PVWA +- Login types: CyberArk, LDAP, RADIUS, Windows +- Auth token via `POST /PasswordVault/API/Auth/{type}/Logon` +- SSL verification optional (`--no-verify-ssl`) + +### Privilege Cloud (*.cyberark.cloud) +- OAuth2 service account via `POST /oauth2/platformtoken` +- Tenant ID discovery via `platform-discovery.cyberark.cloud` +- Tenant formats: `abc1234`, `mycompany`, `abc1234.id`, `tenant.my.idaptive.app`, full URL +- URL rewrite: `tenant.cyberark.cloud` → `tenant.privilegecloud.cyberark.cloud` +- SSL always enforced + +### Environment Variables +| Variable | Purpose | +|---|---| +| KEEPER_CYBERARK_ID_TENANT | Identity tenant ID (Privilege Cloud) | +| KEEPER_CYBERARK_USERNAME | Username or service account client ID | +| KEEPER_CYBERARK_PASSWORD | Password or client secret | +| KEEPER_CYBERARK_LOGON_TYPE | Logon type for self-hosted (CyberArk/LDAP/RADIUS/Windows) | +| KEEPER_CYBERARK_SAFES | Comma-separated safe names | +| KEEPER_CYBERARK_SAFES_PATH | Path to safes.txt file | +| KEEPER_CYBERARK_TICKETING_SYSTEM | Ticketing system name (for strict policies) | +| KEEPER_CYBERARK_TICKET_ID | Ticket ID (for strict policies) | + +--- + +## CLI Usage + +### Import Command +```bash +# Basic import from self-hosted PVWA +pam project cyberark-import pvwa.company.com --name "CyberArk Migration" --gateway "My Gateway" + +# Privilege Cloud +pam project cyberark-import mycompany.cyberark.cloud --name "Cloud Import" + +# Dry run with JSON output +pam project cyberark-import pvwa.company.com --dry-run --output import.json --include-credentials + +# Filter specific safes +pam project cyberark-import pvwa.company.com --safes "Production,Staging" --exclude-safes "Archive*" + +# Extend existing project +pam project cyberark-import pvwa.company.com --config + +# Skip linked accounts and safe members +pam project cyberark-import pvwa.company.com --skip-linked-accounts --skip-members + +# Custom platform mapping +pam project cyberark-import pvwa.company.com --platform-map platforms.json +``` + +### Cleanup Command +```bash +# Remove imported project +pam project cyberark-cleanup --name "CyberArk Migration" + +# By config UID +pam project cyberark-cleanup --config + +# Dry run +pam project cyberark-cleanup --name "CyberArk Migration" --dry-run +``` + +### All Flags +| Flag | Description | +|---|---| +| `server` | PVWA host (required) | +| `--name`, `-n` | Project name | +| `--config`, `-c` | Extend existing PAM config UID | +| `--gateway`, `-g` | Gateway name or UID | +| `--folder-mode` | flat, exact, ksm (default) | +| `--safes` | Include only these safes (comma/glob) | +| `--exclude-safes` | Exclude safes (comma/glob) | +| `--list-safes` | List safes and exit | +| `--dry-run`, `-d` | Preview without vault changes | +| `--output`, `-o` | Save import JSON to file | +| `--include-credentials` | Include passwords in output/dry-run | +| `--estimate` | Estimate import size and exit | +| `--yes`, `-y` | Skip confirmation prompt | +| `--skip-users` | Don't import user records | +| `--skip-linked-accounts` | Don't fetch linked accounts | +| `--skip-members` | Don't fetch safe members | +| `--batch-size` | Records per batch (default: 100) | +| `--batch-delay` | Seconds between batches (default: 0.5) | +| `--platform-map` | Custom platform mapping JSON file | +| `--state-filter` | Filter by CPM status (e.g. "success,failure") | +| `--user-map` | CyberArk→Keeper user mapping JSON file | +| `--no-verify-ssl` | Disable SSL verification (self-hosted only) | +| `--include-system-safes` | Include system safes (PSM, PasswordManager, etc.) | + +--- + +## Safe Member Permission Mapping + +CyberArk's 24 granular permissions map to Keeper's 4-tier model: + +| Tier | CyberArk Permissions Required | Keeper Result | +|---|---|---| +| View | useAccounts + listAccounts | can_edit=false, manage_records=false | +| Edit | + addAccounts + (updateAccountContent or updateAccountProperties) | can_edit=true, manage_records=true | +| Manage | + manageSafe + manageSafeMembers | can_edit=true, can_share=true, manage_users=true | + +Unmapped permissions logged in report: accessWithoutConfirmation, requestsAuthorizationLevel1/2 + +--- + +## System Safes (Excluded by Default) + +System, VaultInternal, Notification Engine, SharedAuth_Internal, PVWAUserPrefs, PVWAConfig, PVWAReports, PVWATaskDefinitions, PVWAPrivateUserPrefs, PVWAPublicData, PVWATicketingSystem, AccountsFeed, PSM, xRay, PIMSuRecordings, xRay_Config, AccountsFeedAcc, PasswordManager_Pending, PasswordManagerShared, PasswordManager_workspace, PasswordManager_ADInternal, PasswordManager, SCIM Config, PSMSessions, PSMUnmanagedSessionAccounts, PSMLiveSessions, PSMNotifications, PSMRecordings + +Override with `--include-system-safes`. + +--- + +## Pre-Import Validation + +Before building the import JSON, the importer warns about: +- Resources missing host/address +- Users without password or SSH key +- Standalone login records not linked to resources +- Rotation enabled but no credentials (rotation will fail) + +--- + +## CyberArk Products Supported + +| Product | Auth Method | Status | +|---|---|---| +| Self-hosted PVWA (v10.4+) | CyberArk/LDAP/RADIUS/Windows | Implemented | +| Privilege Cloud (SaaS) | OAuth2 service account | Implemented | +| Privilege Cloud Shared Services (ISPSS) | OAuth2 with platform discovery | Implemented | +| PrivateArk / Digital Vault | No REST API | Not accessible | +| User Portal (Identity) | Separate importer (cyberark_portal) | Different scope | + +--- + +## Security + +- SSRF protection: validates PVWA host, rejects private/reserved IPs +- Secure temp files: atomic creation (0o600), zero-overwrite before unlink +- Input validation: account IDs, safe names, logon types regex-checked +- No credential logging: passwords never appear in logs or error messages +- Rate limit handling: automatic retry on HTTP 429 with exponential backoff +- Pagination cap: MAX_FETCH_RECORDS (50,000) prevents OOM attacks +- Ticket ID support: for CyberArk policies requiring audit trail + +--- + +## Project Stats + +| Metric | Value | +|---|---| +| Source files | cyberark_pam.py (1,648 lines), cyberark_import.py (1,065 lines) | +| Test file | test_cyberark_pam_import.py (3,164 lines) | +| Total code | 5,877 lines | +| Tests | 287 (unit + integration) | +| Commits | 29 on feature branch | +| Base | Keeper Commander Release v17.2.13 | diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index fbc045a33..fda7bb82f 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -171,18 +171,24 @@ def parse_schedule_data(kwargs): schedule_cron_data = kwargs.get('schedule_cron_data') schedule_on_demand = kwargs.get('on_demand') is True schedule_data = None # type: Optional[List] + if isinstance(schedule_json_data, str): + schedule_json_data = [schedule_json_data] if isinstance(schedule_json_data, list): schedule_data = [json.loads(x) for x in schedule_json_data] - elif isinstance(schedule_cron_data, list): - # more details: http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html#examples - if schedule_cron_data and isinstance(schedule_cron_data[0], str): - valid, err = validate_cron_expression(schedule_cron_data[0], for_rotation=True) - if valid: - schedule_data = [{"type": "CRON", "cron": schedule_cron_data[0], "tz": "Etc/UTC"}] - else: - logging.error('', f'Invalid CRON "{schedule_cron_data[0]}" Error: {err}') - elif schedule_on_demand is True: - schedule_data = [] + else: + # Programmatic callers may pass a bare cron string; argparse uses a list. + if isinstance(schedule_cron_data, str): + schedule_cron_data = [schedule_cron_data] + if isinstance(schedule_cron_data, list): + # more details: http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html#examples + if schedule_cron_data and isinstance(schedule_cron_data[0], str): + valid, err = validate_cron_expression(schedule_cron_data[0], for_rotation=True) + if valid: + schedule_data = [{"type": "CRON", "cron": schedule_cron_data[0], "tz": "Etc/UTC"}] + else: + logging.error('', f'Invalid CRON "{schedule_cron_data[0]}" Error: {err}') + elif schedule_on_demand is True: + schedule_data = [] return schedule_data diff --git a/keepercommander/commands/pam_import/base.py b/keepercommander/commands/pam_import/base.py index f3056f377..412833c08 100644 --- a/keepercommander/commands/pam_import/base.py +++ b/keepercommander/commands/pam_import/base.py @@ -231,7 +231,8 @@ def __init__(self, environment_type:str, settings:dict, controller_uid:str, fold # {"type": "on-demand"} or {"type": "CRON", "cron": "30 18 * * *", "tz": "America/Chicago" } val = settings.get("default_rotation_schedule", None) if isinstance(val, dict): - schedule_type = str(val.get("type", "")).lower() + # Normalize ON_DEMAND/on_demand/on-demand and CRON/cron (case + underscore). + schedule_type = str(val.get("type", "")).lower().replace("_", "-") schedule_type = {"on-demand": "ON_DEMAND", "cron": "CRON"}.get(schedule_type, "") if schedule_type != "": if schedule_type == "ON_DEMAND": @@ -477,12 +478,13 @@ def load(cls, data: Union[str, dict]) -> PamRotationScheduleObject: if not isinstance(data, dict): return obj type = data.get("type", None) - if type and isinstance(type, str) and type.strip().lower() in schedule_types: - obj.type = type.strip().lower() + type_norm = type.strip().lower().replace("_", "-") if isinstance(type, str) else "" + if type_norm in schedule_types: + obj.type = type_norm elif type: logging.error(f"""Schedule type "{str(type)[:80]}" is unknown - must be one of {schedule_types}""") - if obj.type.lower() == "cron": + if obj.type.lower().replace("_", "-") == "cron": cron = data.get("cron", None) if isinstance(cron, str) and cron.strip() != "": obj.cron = cron.strip() if obj.cron: # validate diff --git a/keepercommander/commands/pam_import/commands.py b/keepercommander/commands/pam_import/commands.py index bec4017e0..103adf48e 100644 --- a/keepercommander/commands/pam_import/commands.py +++ b/keepercommander/commands/pam_import/commands.py @@ -13,6 +13,7 @@ from .export import PAMProjectExportCommand from .extend import PAMProjectExtendCommand from .kcm_import import PAMProjectKCMImportCommand, PAMProjectKCMCleanupCommand +from .cyberark_import import CyberArkPAMImportCommand, CyberArkPAMCleanupCommand from ..base import GroupCommand class PAMProjectCommand(GroupCommand): @@ -23,3 +24,5 @@ def __init__(self): self.register_command("extend", PAMProjectExtendCommand(), "Extend PAM Project by importing additional data", "e") self.register_command("kcm-import", PAMProjectKCMImportCommand(), "Import from KCM/Guacamole database", "k") self.register_command("kcm-cleanup", PAMProjectKCMCleanupCommand(), "Remove a KCM-imported project", "K") + self.register_command("cyberark-import", CyberArkPAMImportCommand(), "Import CyberArk accounts as PAM records", "ci") + self.register_command("cyberark-cleanup", CyberArkPAMCleanupCommand(), "Remove a CyberArk-imported project", "cc") diff --git a/keepercommander/commands/pam_import/cyberark_import.py b/keepercommander/commands/pam_import/cyberark_import.py new file mode 100644 index 000000000..494ca5c41 --- /dev/null +++ b/keepercommander/commands/pam_import/cyberark_import.py @@ -0,0 +1,2582 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + tmp_dir = tempfile.gettempdir() + tmp_fd, tmp_path = tempfile.mkstemp( + suffix='.json', prefix='keeper_ca_import_', dir=tmp_dir, + ) + try: + with os.fdopen(tmp_fd, 'w', encoding='utf-8') as fh: + json.dump(data, fh, indent=2) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + self._pending.add(tmp_path) + return tmp_path + + def remove(self, tmp_path: str): + try: + if os.path.exists(tmp_path): + size = os.path.getsize(tmp_path) + with open(tmp_path, 'wb') as fh: + fh.write(b'\x00' * size) + os.unlink(tmp_path) + except OSError: + pass + self._pending.discard(tmp_path) + + +_temp_store = SecureTempFileStore() + + +def _write_secure_temp_json(data: dict) -> str: + """Backward-compatible alias for tests and external callers.""" + return _temp_store.write_json(data) + + +def _remove_secure_temp(tmp_path: str): + """Backward-compatible alias for tests and external callers.""" + _temp_store.remove(tmp_path) + + +@dataclass +class ImportRunOptions: + """Normalized CLI options for a single import run.""" + + server: str + project_name: str + config_uid: str + gateway_name: str + folder_mode: str + dry_run: bool + output_file: str + include_creds: bool + estimate_only: bool + skip_confirm: bool + skip_users: bool + skip_linked: bool + skip_members: bool + skip_dependents: bool + safe_include: str + safe_exclude: str + list_safes: bool + batch_size: int + batch_delay: float + platform_map_override: Optional[dict] + state_filter: Optional[list[str]] + include_system_safes: bool + user_map_file: str + sync_mode: str = "upsert" + strict_policies: bool = False + raw_kwargs: dict = field(default_factory=dict) + + +@dataclass +class MappedImportResult: + """Output of the account-mapping phase.""" + + pam_resources: list[dict] + pam_users: list[dict] + incomplete: list[dict] + skipped: list[dict] + platform_counts: dict[str, dict] + total_accounts: int + accounts_by_safe: dict[str, list[dict]] + mapper: AccountMapper + folder_mapper: SafeFolderMapper + unmapped_items: list[dict] + dependents: list[dict] = field(default_factory=list) + import_duration: float = 0.0 + + +class CyberArkImportOrchestrator: + """Coordinates CyberArk PVWA fetch → map → vault import phases (SRP).""" + + def __init__(self, command: 'CyberArkPAMImportCommand', params, client: CyberArkPVWAClient, + options: ImportRunOptions): + self._cmd = command + self.params = params + self.client = client + self.options = options + self._temp = _temp_store + + def run(self) -> None: + unmapped_items: list[dict] = [] + master_policy_config = self._load_master_policy(unmapped_items) + safes, system_excluded = self._prepare_safes() + if not safes: + return + if self.options.list_safes: + self._cmd._list_safes_detailed(safes, system_excluded) + return + safes = self._maybe_interactive_safe_pick(safes) + if not safes: + return + safe_names = [s.get("safeUrlId", s["safeName"]) for s in safes] + safe_member_map, member_unmapped = self._fetch_safe_members(safe_names) + unmapped_items.extend(member_unmapped) + user_team_matcher = self._build_user_team_matcher() + accounts_by_safe = self._fetch_accounts(safe_names) + total_accounts = sum(len(v) for v in accounts_by_safe.values()) + if total_accounts == 0: + print_formatted_text(HTML("No accounts found in selected safes")) + return + if self.options.estimate_only: + self._print_estimate(len(accounts_by_safe), total_accounts) + return + self._maybe_auto_extend() + mapped = self._map_accounts( + accounts_by_safe, safe_names, master_policy_config, unmapped_items, + ) + unmapped_items.extend(mapped.mapper.platform_workflow_unmapped) + self._print_mapping_summary(mapped, unmapped_items) + validation_warnings = validate_import_data(mapped.pam_resources, mapped.pam_users) + if validation_warnings: + self._print_validation_warnings(validation_warnings) + idempotency_ctx = self._prepare_idempotency(mapped) + import_data = self._build_import_payload( + mapped, safe_member_map, user_team_matcher, master_policy_config, + ) + if self.options.output_file: + self._write_output_file(import_data) + return + if self.options.dry_run: + self._print_dry_run(import_data, mapped) + return + if not self._confirm_import(mapped, idempotency_ctx): + return + # Apply the partition to the import payload just before we + # call pam project import/extend, so create-only records go + # through and update / unchanged records are handled out of + # band. Done here (not earlier) so the report accurately + # reflects what the mapper produced from CyberArk, not what + # survived the idempotency filter. ``mapped`` is passed so + # the filter can mutate ``pam_resources`` / ``pam_users`` in + # place — ``_execute_vault_import`` reads those lists + # directly for the multi-batch path, not the ``import_data`` + # dict, so any filtered-out record must be dropped from both + # views simultaneously. + if idempotency_ctx: + self._apply_idempotency_filter(import_data, mapped, idempotency_ctx) + # Skip the vault write entirely when every mapped record was + # already up to date — avoids a no-op ``pam project extend`` + # round-trip and lets the report acknowledge "nothing to do". + # We still run the update path in case any records were + # tagged as UPDATE (their data changed even though no new + # records need to be created). + skip_vault_import = ( + idempotency_ctx is not None + and not mapped.pam_resources + and not mapped.pam_users + ) + if skip_vault_import: + project_result = { + "project_name": self.options.project_name, + "config_uid": self.options.config_uid, + "folders": {}, + } + self._populate_folder_info(project_result, self.options.project_name) + else: + project_result = self._execute_vault_import(import_data, mapped) + if idempotency_ctx and project_result is not None: + update_summary = self._apply_record_updates(idempotency_ctx) + idempotency_ctx["update_summary"] = update_summary + self._finalize_report( + mapped, mapped.unmapped_items, project_result, total_accounts, + user_team_matcher, idempotency_ctx, + ) + + def _load_master_policy(self, unmapped_items: list[dict]) -> dict: + master_policy_config = dict(MasterPolicyMapper.DEFAULTS) + print_formatted_text(HTML("Fetching Master Policy...")) + policy_data = self.client.fetch_master_policy() + if policy_data: + self._log_master_policy_raw(policy_data) + master_policy_config, mp_unmapped = MasterPolicyMapper.map_policy(policy_data) + unmapped_items.extend(mp_unmapped) + else: + logging.warning( + '%sMaster Policy not accessible — using defaults. ' + 'Review PAM config settings manually.%s', + bcolors.WARNING, bcolors.ENDC, + ) + # Logs the raw exceptions JSON at INFO internally; returns None when + # the tenant doesn't expose a real per-platform list (common — see + # fetch_master_rotation_policy_exceptions docstring), in which case + # AccountMapper falls back to the per-platform rotation-policy check. + exceptions_raw = self.client.fetch_master_rotation_policy_exceptions() + master_policy_config["rotation_exception_schedules"] = ( + MasterPolicyMapper.parse_rotation_exceptions(exceptions_raw) + if exceptions_raw else {} + ) + try: + master_sm = self.client.fetch_master_session_monitoring() + except Exception as e: # noqa: BLE001 — never block import on optional fetch + logging.debug('Master session-monitoring fetch failed: %s', type(e).__name__) + master_sm = None + master_policy_config = SessionRecordingResolver.apply_to_master_config( + master_policy_config, master_sm, + ) + if master_sm and master_policy_config.get("graphical_session_recording") == "on": + logging.debug( + 'Master session-monitoring resolved to ON — ' + 'PAM Configuration session recording will be enabled.', + ) + if policy_data: + self._print_master_policy_summary(master_policy_config) + return master_policy_config + + @staticmethod + def _log_master_policy_raw(policy_data: dict): + try: + logging.debug( + 'CyberArk Master Policy (raw response):\n%s', + json.dumps(policy_data, indent=2, sort_keys=True), + ) + except (TypeError, ValueError) as e: + logging.error("Could not serialize Master Policy for display: %s", type(e).__name__) + + @staticmethod + def _print_master_policy_summary(master_policy_config: dict): + sched = master_policy_config.get("default_rotation_schedule") or {} + sched_type = str(sched.get("type", SCHEDULE_ON_DEMAND)).lower().replace("_", "-") + if sched_type == "cron": + rotation_summary = ( + f"every {master_policy_config.get('password_change_days', 0)} days " + f"({sched.get('cron', '')})" + ) + else: + rotation_summary = SCHEDULE_ON_DEMAND + exc_schedules = master_policy_config.get("rotation_exception_schedules") or {} + exc_note = "" + if exc_schedules: + exc_note = ( + f", rotation_exceptions={len(exc_schedules)} platform(s)" + ) + print_formatted_text(HTML( + f"Master Policy: session_recording=" + f"{master_policy_config.get('graphical_session_recording', 'off')}, " + f"connections={master_policy_config.get('connections', 'on')}, " + f"rotation={_esc(rotation_summary)}{exc_note}")) + + def _prepare_safes(self) -> tuple[list[dict], int]: + all_safes = self.client.fetch_safes() + if not all_safes: + return [], 0 + safes = exclude_system_safes( + all_safes, include_system=self.options.include_system_safes, + ) + system_excluded = len(all_safes) - len(safes) + safes = apply_safe_filter( + safes, + self.options.safe_include or None, + self.options.safe_exclude or None, + ) + if not safes: + print_formatted_text(HTML("No safes match the filter criteria")) + return safes, system_excluded + + def _maybe_interactive_safe_pick(self, safes: list[dict]) -> list[dict]: + opts = self.options + if (opts.safe_include or opts.safe_exclude or opts.skip_confirm + or opts.dry_run or opts.output_file + or getattr(self.params, 'batch_mode', False)): + return safes + selected = self._cmd._interactive_safe_picker(safes) + if selected is None: + return safes + selected_set = {n.strip() for n in selected.split(',')} + picked = [s for s in safes if s.get("safeName", "") in selected_set] + if not picked: + print_formatted_text(HTML("No safes selected")) + return picked + + def _fetch_safe_members(self, safe_names: list[str]) -> tuple[dict, list[dict]]: + opts = self.options + if opts.skip_members or opts.dry_run: + return {}, [] + print_formatted_text(HTML( + f"\nFetching safe members from {len(safe_names)} safes...")) + safe_member_map: dict[str, list[dict]] = {} + member_unmapped: list[dict] = [] + for safe_url_id in safe_names: + mapped = [] + for m in self.client.fetch_safe_members(safe_url_id): + mapped_member = PermissionMapper.map_member(m) + mapped.append(mapped_member) + for up in mapped_member.get("unmapped_permissions", []): + member_unmapped.append({ + "category": "Safe member permission", + "item": f"{mapped_member['name']} in {safe_url_id}", + "action": f"CyberArk permission '{up}' has no Keeper equivalent", + }) + if mapped: + safe_member_map[safe_url_id] = mapped + total_members = sum(len(v) for v in safe_member_map.values()) + print_formatted_text(HTML( + f"Found {total_members} members across " + f"{len(safe_member_map)} safes")) + return safe_member_map, member_unmapped + + def _build_user_team_matcher(self) -> Optional[UserTeamMatcher]: + opts = self.options + if opts.skip_users or opts.skip_members or opts.dry_run: + return None + print_formatted_text(HTML("\nFetching CyberArk vault users and groups...")) + ca_users = self.client.fetch_users() + ca_groups = self.client.fetch_user_groups() + user_map_override = self._load_user_map_override() + keeper_users, keeper_teams = self._load_keeper_identities() + matcher = UserTeamMatcher( + keeper_users=keeper_users, + keeper_teams=keeper_teams, + user_map_override=user_map_override, + ) + print_formatted_text(HTML( + f"Found {len(ca_users)} vault users, {len(ca_groups)} groups")) + return matcher + + def _load_user_map_override(self) -> Optional[dict]: + path = self.options.user_map_file + if not path: + return None + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + if not isinstance(data, dict): + raise CommandError( + "pam project cyberark-import", + f"--user-map file must contain a JSON object, got {type(data).__name__}", + ) + return data + except (FileNotFoundError, ValueError) as e: + raise CommandError("pam project cyberark-import", + f"Failed to load --user-map file: {e}") from e + + def _load_keeper_identities(self) -> tuple[list[dict], list[dict]]: + keeper_users: list[dict] = [] + keeper_teams: list[dict] = [] + if hasattr(self.params, 'enterprise') and self.params.enterprise: + for u in self.params.enterprise.get('users', []): + keeper_users.append({ + 'email': u.get('username', ''), + 'username': u.get('username', ''), + }) + else: + logging.warning( + '%sEnterprise data not available — all safe members will appear as ' + 'unmatched. Ensure you are logged in as an enterprise admin.%s', + bcolors.WARNING, bcolors.ENDC, + ) + if hasattr(self.params, 'available_team_cache') and self.params.available_team_cache: + for t in self.params.available_team_cache: + keeper_teams.append({'name': t.get('team_name', '')}) + return keeper_users, keeper_teams + + def _fetch_accounts(self, safe_names: list[str]) -> dict[str, list[dict]]: + print_formatted_text(HTML( + f"\nFetching accounts from {len(safe_names)} safes...")) + return self.client.fetch_accounts(safe_names, state_filter=self.options.state_filter) + + @staticmethod + def _print_estimate(safe_count: int, total_accounts: int): + est_seconds = total_accounts * 3 + print(f"\nSafes: {safe_count}") + print(f"Accounts: {total_accounts}") + print(f"Estimated import time: ~{format_duration(est_seconds)}") + + + def _map_accounts(self, accounts_by_safe: dict[str, list[dict]], safe_names: list[str], + master_policy_config: dict, + unmapped_items: list[dict]) -> MappedImportResult: + opts = self.options + try: + ca_platforms = self.client.fetch_platforms() + if ca_platforms: + logging.debug( + "Loaded %d CyberArk platform definitions for mapping", len(ca_platforms), + ) + except Exception as e: + logging.error("fetch_platforms failed (%s) — proceeding without platform metadata", + type(e).__name__) + ca_platforms = [] + mapper = AccountMapper( + opts.platform_map_override, + platforms=ca_platforms, + client=self.client, + default_rotation_schedule=master_policy_config.get("default_rotation_schedule"), + master_rotation_exceptions=master_policy_config.get( + "rotation_exception_schedules"), + master_change_days=master_policy_config.get("password_change_days", 0), + strict_policies=opts.strict_policies, + ) + folder_mapper = SafeFolderMapper(mode=opts.folder_mode) + pam_resources: list[dict] = [] + pam_users: list[dict] = [] + incomplete: list[dict] = [] + skipped: list[dict] = [] + platform_counts: dict[str, dict] = {} + skip_all_passwords: dict[str, bool] = {} + dependents: list[dict] = [] + for safe_name, accounts in accounts_by_safe.items(): + for account in accounts: + self._map_single_account( + account, safe_name, mapper, folder_mapper, master_policy_config, + pam_resources, pam_users, incomplete, skipped, platform_counts, + skip_all_passwords, unmapped_items, dependents, + ) + return MappedImportResult( + pam_resources=pam_resources, + pam_users=pam_users, + incomplete=incomplete, + skipped=skipped, + platform_counts=platform_counts, + total_accounts=sum(len(v) for v in accounts_by_safe.values()), + accounts_by_safe=accounts_by_safe, + mapper=mapper, + folder_mapper=folder_mapper, + unmapped_items=unmapped_items, + dependents=dependents, + ) + + def _map_single_account( + self, account: dict, safe_name: str, mapper: AccountMapper, + folder_mapper: SafeFolderMapper, master_policy_config: dict, + pam_resources: list[dict], pam_users: list[dict], incomplete: list[dict], + skipped: list[dict], platform_counts: dict[str, dict], + skip_all_passwords: dict, unmapped_items: list[dict], + dependents: list[dict], + ): + opts = self.options + if not isinstance(account, dict): + logging.error("Skipping account mapping: expected dict, got %s", type(account).__name__) + return + # CyberArk API field is camelCase and case-sensitive. + platform_id = account.get("platformId") or "Unknown" + if platform_id not in platform_counts: + mapping = mapper.platform_map.get(platform_id) + if not isinstance(mapping, dict): + mapping = {} + platform_counts[platform_id] = { + "rotation": mapping.get("rotation", ROTATION_UNMAPPED), + "count": 0, + } + platform_counts[platform_id]["count"] += 1 + is_incomplete, reason = mapper.is_incomplete(account) + # Hold the retrieved credential as ``token`` (not ``password``) so + # accidental log formatting is less likely to leak the secret name. + # Clear it immediately after map_account copies it into the record. + token = None + record = None + try: + if not opts.dry_run or opts.include_creds: + token = self.client.retrieve_password( + account.get("id", ""), + account_name=account.get("name", ""), + safe_name=safe_name, + skip_all=skip_all_passwords, + ) + if token is None: + skipped.append({ + "account": account.get("name", ""), + "safe": safe_name, + "reason": "password retrieval failed", + }) + return + try: + record = mapper.map_account(account, token, safe_name) + except StrictPolicyError as e: + raise CommandError("pam project cyberark-import", str(e)) from e + finally: + # Drop the local plaintext reference as soon as mapping finishes + # (or fails). The record dict still holds the credential for vault + # import when needed — that is intentional and short-lived. + token = None + if not isinstance(record, dict): + skipped.append({ + "account": account.get("name", ""), + "safe": safe_name, + "reason": f"unmappable platformId: {platform_id}", + }) + return + # Embed the CyberArk identity marker on every mapped record so + # future re-imports can match incoming accounts to already-created + # Keeper records (see importer/cyberark/pam/idempotency.py). The + # marker is a single line in ``notes`` — cheap to carry and + # survives the pam project import path unchanged (unlike ``custom`` + # fields, which PamBaseMachineParser does not preserve). + account_id = str(account.get("id", "") or "").strip() + if account_id: + annotate_record_with_marker(record, account_id, safe_name) + for nested in record.get("users") or []: + if not isinstance(nested, dict): + continue + # Nested pamUsers share the parent account's CyberArk id + # because CyberArk models the credential+resource as a + # single account. The marker is idempotent so re-runs + # can update the pamUser independently from its parent. + annotate_record_with_marker(nested, account_id, safe_name) + self._apply_folder_paths(record, safe_name, folder_mapper) + if is_incomplete: + record["notes"] = f"INCOMPLETE: {reason}" + incomplete.append(record) + dual_fields = detect_dual_account(account) + if dual_fields: + record.setdefault("custom", []) + for key, val in dual_fields.items(): + record["custom"].append({"type": "text", "label": key, "value": [val]}) + unmapped_items.append({ + "category": "Dual account pair", + "item": account.get("name", ""), + "action": "Exclusive rotation behavior requires manual " + "configuration in KeeperPAM rotation settings", + }) + if (not opts.skip_linked and not opts.dry_run and not opts.skip_users + and record.get("type") != RECORD_TYPE_LOGIN): + self._attach_linked_accounts(record, account, safe_name, folder_mapper) + self._collect_dependents(account, record, dependents, unmapped_items) + if record.get("type") == RECORD_TYPE_LOGIN: + pam_users.append(record) + else: + if opts.skip_users: + record.pop("users", None) + pam_resources.append(record) + + def _collect_dependents(self, account: dict, record: dict, + dependents: list[dict], unmapped_items: list[dict]): + """Resolve CyberArk dependents (services/tasks/IIS pools) for ``account`` + and append them to ``dependents`` for post-import service mapping. + + The "user" the service runs as is the nested pamUser record on this + resource (we use its title as the lookup key). Non-pamMachine records + (login, pamDatabase) cannot host a Windows service, so they're skipped. + Categories with no Keeper equivalent (e.g. COM+) are recorded in + ``unmapped_items`` so admins can act on them manually. + """ + opts = self.options + if opts.skip_dependents or opts.dry_run: + return + if not isinstance(account, dict) or not isinstance(record, dict): + logging.error( + "Skipping dependents: account/record must be dicts (got %s / %s)", + type(account).__name__, type(record).__name__, + ) + return + # Keeper record types are case-sensitive (pamMachine ≠ Pammachine). + if record.get("type") != RECORD_TYPE_PAM_MACHINE: + return + users = record.get("users") or [] + if not isinstance(users, list) or not users: + return + first_user = users[0] if isinstance(users[0], dict) else {} + master_user_title = first_user.get("title", "") or "" + if not master_user_title: + return + try: + account_dependents = resolve_account_dependents( + self.client, account, master_user_title, + ) + except Exception as e: # noqa: BLE001 — never block import on optional fetch + logging.error('Dependents resolution failed for %s: %s', + account.get("name", "?"), type(e).__name__) + return + if not account_dependents: + return + if not isinstance(account_dependents, list): + logging.error( + "Dependents resolution returned non-list for %s: %s", + account.get("name", "?"), type(account_dependents).__name__, + ) + return + machine_title = record.get("title", "") + for dep in account_dependents: + if not isinstance(dep, dict): + continue + dep["machine_title"] = machine_title + if dep.get("service_type") is None: + unmapped_items.append({ + "category": "CyberArk dependent", + "item": (f"{dep.get('service_name') or '?'} " + f"({dep.get('raw_type') or 'unknown'}) " + f"on {dep.get('machine_address', '?')}"), + "action": "No KeeperPAM equivalent for this dependent type " + "— configure manually if required", + }) + dependents.append(dep) + # Surface progress + a per-dependent breakdown so the operator can see + # exactly what the importer parsed before the post-import phase runs. + print_formatted_text(HTML( + f"Found {len(account_dependents)} CyberArk dependent(s) " + f"on account {_esc(account.get('name', '?'))}")) + for dep in account_dependents: + mapped_to = dep.get("service_type") or "(unsupported)" + print_formatted_text(HTML( + f" • {_esc(dep.get('service_name', '') or '?')} " + f"({_esc(dep.get('raw_type', '') or 'unknown')}) " + f"on {_esc(dep.get('machine_address', '') or '?')} " + f"→ pam action service {_esc(mapped_to)}")) + + def _apply_folder_paths(self, record: dict, safe_name: str, + folder_mapper: SafeFolderMapper): + opts = self.options + if opts.folder_mode == "flat": + return + safe_folder = folder_mapper.map_safe(safe_name, opts.project_name) + if not safe_folder: + return + if opts.folder_mode == "safe": + # ``safe`` mode: each safe is its own root shared folder + # under the project wrapper, with two organizational + # subfolders inside — ``{safe} - Resources`` for the asset + # records (pamMachine / pamDatabase / pamDirectory / + # pamRemoteBrowser) and ``{safe} - Users`` for the + # credential records (pamUser nested under a resource, plus + # standalone ``login`` records). Prefixing the subfolder + # names with the safe name makes them unambiguous in the + # Keeper UI even when expanded out of their parent folder + # context. The safe's permission set is granted at the + # shared-folder level, so both subfolders inherit it + # automatically — we only need to route records to the + # right subfolder. + res_sub = f"{safe_folder}/{safe_folder} - Resources" + usr_sub = f"{safe_folder}/{safe_folder} - Users" + if record.get("type") == RECORD_TYPE_LOGIN: + record["folder_path"] = usr_sub + else: + record["folder_path"] = res_sub + for u in record.get("users", []): + u["folder_path"] = usr_sub + return + res_root = f"{opts.project_name} - Resources" + usr_root = f"{opts.project_name} - Users" + if record.get("type") == RECORD_TYPE_LOGIN: + record["folder_path"] = f"{usr_root}/{safe_folder}" + else: + record["folder_path"] = f"{res_root}/{safe_folder}" + for u in record.get("users", []): + u["folder_path"] = f"{usr_root}/{safe_folder}" + + def _attach_linked_accounts(self, record: dict, account: dict, safe_name: str, + folder_mapper: SafeFolderMapper): + linked_users = resolve_linked_accounts(self.client, account) + if not linked_users: + return + record.setdefault("users", []).extend(linked_users) + admin_title, admin_role = pick_admin_credentials(linked_users) + if admin_title: + conn = record.setdefault("pam_settings", {}).setdefault("connection", {}) + conn["administrative_credentials"] = admin_title + logging.info("Mapped %s account '%s' as administrative_credentials on '%s'", + admin_role, admin_title, record.get("title", "")) + logon_title = pick_launch_credentials(linked_users) + if logon_title: + conn = record.setdefault("pam_settings", {}).setdefault("connection", {}) + conn["launch_credentials"] = logon_title + logging.info("Overrode launch_credentials with logon account '%s' on '%s'", + logon_title, record.get("title", "")) + if self.options.folder_mode != "flat": + safe_folder = folder_mapper.map_safe(safe_name, self.options.project_name) + if safe_folder: + if self.options.folder_mode == "safe": + usr_sub = f"{safe_folder}/{safe_folder} - Users" + for lu in linked_users: + lu["folder_path"] = usr_sub + else: + usr_root = f"{self.options.project_name} - Users" + for lu in linked_users: + lu["folder_path"] = f"{usr_root}/{safe_folder}" + + def _print_mapping_summary(self, mapped: MappedImportResult, unmapped_items: list[dict]): + resource_count = len(mapped.pam_resources) + user_count = sum(len(r.get("users", [])) for r in mapped.pam_resources) + login_count = len(mapped.pam_users) + print() + print(f"{bcolors.OKBLUE}Mapped {mapped.total_accounts} CyberArk accounts:{bcolors.ENDC}") + print(f" Resources (pamMachine/pamDatabase): {resource_count}") + print(f" Users (pamUser, nested): {user_count}") + print(f" Logins (BusinessWebsite): {login_count}") + if unmapped_items: + print(f" {bcolors.WARNING}Unmapped (manual action needed): {len(unmapped_items)}{bcolors.ENDC}") + if mapped.incomplete: + print(f" {bcolors.WARNING}Incomplete: {len(mapped.incomplete)}{bcolors.ENDC}") + if mapped.skipped: + print(f" {bcolors.WARNING}Skipped: {len(mapped.skipped)}{bcolors.ENDC}") + mapper = mapped.mapper + if mapper.unmapped_platforms: + print(f" {bcolors.WARNING}Unmapped platforms (defaulted): " + f"{sum(mapper.unmapped_platforms.values())}{bcolors.ENDC}") + for pid, cnt in mapper.unmapped_platforms.items(): + print(f" {pid}: {cnt} accounts") + if mapper.platform_schedule_overrides: + total_overrides = sum(mapper.platform_schedule_overrides.values()) + print(f" {bcolors.OKBLUE}Platform rotation-policy overrides: {total_overrides}{bcolors.ENDC}") + for pid, cnt in sorted(mapper.platform_schedule_overrides.items()): + cached = mapper._platform_schedule_cache.get(pid) or {} + cron = cached.get("cron", cached.get("type", "?")) + print(f" {pid}: {cnt} accounts -> {cron}") + if mapper.platform_recording_overrides: + total_rec = sum(mapper.platform_recording_overrides.values()) + print(f" {bcolors.OKBLUE}Platform session-recording overrides: {total_rec}{bcolors.ENDC}") + for pid, cnt in sorted(mapper.platform_recording_overrides.items()): + rec = mapper._platform_session_cache.get(pid) or ("?", "?") + print(f" {pid}: {cnt} resources -> graphical={rec[0]}") + if mapper.platform_complexity_overrides: + total_pc = sum(mapper.platform_complexity_overrides.values()) + print(f" {bcolors.OKBLUE}Platform password-complexity overrides: {total_pc}{bcolors.ENDC}") + for pid, cnt in sorted(mapper.platform_complexity_overrides.items()): + cx = mapper._platform_complexity_cache.get(pid) or "?" + print(f" {pid}: {cnt} accounts -> {cx} (length,upper,lower,digits,symbols)") + if mapper._platform_metadata_cache: + total_md = sum(1 for v in mapper._platform_metadata_cache.values() if v) + if total_md: + print(f" {bcolors.OKBLUE}Platform metadata custom fields: " + f"{total_md} platform(s) emitted CyberArk metadata{bcolors.ENDC}") + for pid, fields in sorted(mapper._platform_metadata_cache.items()): + if fields: + print(f" {pid}: {len(fields)} field(s)") + print() + + @staticmethod + def _print_validation_warnings(validation_warnings: list[str]): + print_formatted_text(HTML( + f"\n⚠ {len(validation_warnings)} validation warning(s):")) + for w in validation_warnings: + print_formatted_text(HTML(f" • {_esc(w)}")) + print() + + def _build_import_payload(self, mapped: MappedImportResult, safe_member_map: dict, + user_team_matcher: Optional[UserTeamMatcher], + master_policy_config: dict) -> dict: + opts = self.options + if opts.config_uid: + return build_extend_json(mapped.pam_resources, mapped.pam_users) + return build_import_json( + project_name=opts.project_name, + gateway_name=opts.gateway_name or None, + resources=mapped.pam_resources, + users=mapped.pam_users, + safe_member_map=safe_member_map or None, + user_team_matcher=user_team_matcher, + master_policy_config=master_policy_config, + folder_mapper=mapped.folder_mapper, + ) + + def _write_output_file(self, import_data: dict): + output_data = copy.deepcopy(import_data) + if not self.options.include_creds: + strip_credentials(output_data) + fd = os.open( + self.options.output_file, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600, + ) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(output_data, fh, indent=2) + print(f"Import JSON saved to: {self.options.output_file}") + + def _print_dry_run(self, import_data: dict, mapped: MappedImportResult): + resource_count = len(mapped.pam_resources) + user_count = sum(len(r.get("users", [])) for r in mapped.pam_resources) + login_count = len(mapped.pam_users) + print("[DRY RUN] No changes will be made to the vault.\n") + output_data = copy.deepcopy(import_data) + if not self.options.include_creds: + strip_credentials(output_data) + print(json.dumps(output_data, indent=2)) + print() + print(f"[DRY RUN COMPLETE] {resource_count} resources, {user_count} users, " + f"{login_count} logins would be imported.") + + def _confirm_import(self, mapped: MappedImportResult, + idempotency_ctx: Optional[dict] = None) -> bool: + opts = self.options + if opts.skip_confirm: + return True + resource_count = len(mapped.pam_resources) + user_count = sum(len(r.get("users", [])) for r in mapped.pam_resources) + login_count = len(mapped.pam_users) + print(f"Ready to import {resource_count} resources, {user_count} users, " + f"{login_count} logins into project '{opts.project_name}'.") + if opts.config_uid: + print(f"Extending existing PAM configuration: {opts.config_uid}") + else: + print("A new PAM project will be created.") + # Surface the create / update / unchanged breakdown so the + # operator knows exactly what will happen before we touch the + # vault. Without this a re-run against an existing project + # looks like it's about to create hundreds of duplicates when + # in fact most of the mapped records are unchanged and will be + # silently skipped. + if idempotency_ctx: + summary = idempotency_ctx.get("partition_summary") + if summary is not None: + print( + f"Sync plan (--sync-mode={opts.sync_mode}): " + f"{bcolors.OKGREEN}{summary.created} to create{bcolors.ENDC}, " + f"{bcolors.OKBLUE}{summary.updated} to update{bcolors.ENDC}, " + f"{bcolors.WARNING}{summary.unchanged} unchanged{bcolors.ENDC}" + ) + for dec in idempotency_ctx.get("decisions", []): + if dec.decision is not IdempotencyDecision.UPDATE: + continue + title = (dec.incoming.get("title") or dec.account_id or "?") + fields = ", ".join(dec.change_fields) or "(none)" + print(f" • update {title}: {fields}") + confirm = input("Proceed? [y/N] ").strip().lower() + if confirm not in ("y", "yes"): + print("Import cancelled.") + return False + return True + + def _execute_vault_import(self, import_data: dict, + mapped: MappedImportResult) -> Optional[dict]: + opts = self.options + import_start = time.time() + try: + result = self._cmd._execute_import( + self.params, import_data, opts.project_name, opts.config_uid, + opts.batch_size, opts.batch_delay, + mapped.pam_resources, mapped.pam_users, + ) + except Exception as e: + logging.error("Import failed: %s", type(e).__name__) + logging.debug("Import error details: %s", e) + logging.debug("Import traceback:", exc_info=True) + print_formatted_text(HTML(f"\nImport failed: {type(e).__name__}")) + return None + mapped.import_duration = time.time() - import_start + # Augment ``project_result`` with the actual shared folders that + # got created under the project wrapper so build_report can list + # them. PAMProjectImportCommand doesn't return this info, so we + # rediscover it from the synced folder cache. + if isinstance(result, dict): + result.setdefault("folders", {}) + self._populate_folder_info(result, opts.project_name) + return result + + def _populate_folder_info(self, project_result: dict, project_name: str) -> None: + """Fill ``project_result['folders']`` with the freshly-created + shared folders so the post-import report shows the safe-per- + folder layout. Best-effort — failures here don't affect the + import itself. + """ + from ... import api as _api # noqa: WPS433 — local import to mirror style + try: + _api.sync_down(self.params) + except Exception: # noqa: BLE001 + pass + + wrapper_uids = CyberArkPAMCleanupCommand._find_project_wrapper_folder_uids( + self.params, project_name, + ) + if not wrapper_uids: + return + + folders_info = project_result.get("folders") or {} + safe_folders: list[dict] = [] + config_folder_uid = "" + config_folder_name = "" + legacy_resources_uid = "" + legacy_resources_name = "" + legacy_users_uid = "" + legacy_users_name = "" + config_suffix = f"{project_name} - Config" + resources_suffix = f"{project_name} - Resources" + users_suffix = f"{project_name} - Users" + + seen_uids: set = set() + for wrapper_uid in wrapper_uids: + wrapper = self.params.folder_cache.get(wrapper_uid) + if not wrapper: + continue + for child_uid in getattr(wrapper, "subfolders", []) or []: + child = self.params.folder_cache.get(child_uid) + if not child or getattr(child, "type", "") != "shared_folder": + continue + if child.uid in seen_uids: + continue + seen_uids.add(child.uid) + name = getattr(child, "name", "") or "" + if name == config_suffix: + config_folder_uid = child.uid + config_folder_name = name + elif name == resources_suffix: + legacy_resources_uid = child.uid + legacy_resources_name = name + elif name == users_suffix: + legacy_users_uid = child.uid + legacy_users_name = name + else: + safe_folders.append({"name": name, "uid": child.uid}) + + if config_folder_uid: + folders_info["config_folder"] = config_folder_name + folders_info["config_folder_uid"] = config_folder_uid + folders_info.setdefault("resources_folder", config_folder_name) + folders_info.setdefault("resources_folder_uid", config_folder_uid) + folders_info.setdefault("users_folder", config_folder_name) + folders_info.setdefault("users_folder_uid", config_folder_uid) + if legacy_resources_uid: + folders_info["resources_folder"] = legacy_resources_name + folders_info["resources_folder_uid"] = legacy_resources_uid + if legacy_users_uid: + folders_info["users_folder"] = legacy_users_name + folders_info["users_folder_uid"] = legacy_users_uid + if safe_folders: + folders_info["safe_folders"] = safe_folders + + project_result["folders"] = folders_info + + def _finalize_report(self, mapped: MappedImportResult, unmapped_items: list[dict], + project_result: Optional[dict], total_accounts: int, + user_team_matcher: Optional[UserTeamMatcher], + idempotency_ctx: Optional[dict] = None): + if project_result is None: + return + dependent_summary = self._apply_service_dependent_mappings( + mapped, project_result, unmapped_items, + ) + resource_counts = self._build_resource_counts(mapped) + import_duration = mapped.import_duration + if dependent_summary: + self._print_dependent_summary(dependent_summary) + if idempotency_ctx: + self._print_idempotency_summary(idempotency_ctx) + report_text = build_report( + project_name=self.options.project_name, + safes_processed=len(mapped.accounts_by_safe), + total_accounts=total_accounts, + resource_counts=resource_counts, + platform_counts=mapped.platform_counts, + skipped=mapped.skipped, + incomplete_count=len(mapped.incomplete), + duration=import_duration, + project_result=project_result, + unmapped_platforms=mapped.mapper.unmapped_platforms, + unmapped_items=unmapped_items, + server=self.options.server, + ) + notes_text = report_text + gw_token = (project_result or {}).get("gateway", {}).get("gateway_token", "") + if gw_token: + notes_text = notes_text.replace( + gw_token, '(see "Gateway Access Token" field on this record)') + try: + print() + print(report_text) + except (BrokenPipeError, OSError): + pass + report_config_uid = (project_result or {}).get("config_uid", "") or self.options.config_uid + if report_config_uid: + self._attach_report_files(notes_text, report_config_uid, user_team_matcher) + + # ------------------------------------------------------------------ + # Idempotency (upsert) support + # ------------------------------------------------------------------ + + def _maybe_auto_extend(self) -> None: + """Promote a repeat import to extend mode when the target exists. + + Runs before mapping so ``_build_import_payload`` picks the + ``build_extend_json`` branch and the vault write goes through + ``PAMProjectExtendCommand`` (which grafts records onto an + existing project) instead of ``PAMProjectImportCommand`` + (which creates a fresh ``project #2`` folder). + + Skipped when: + - ``--sync-mode create`` — the user explicitly asked for the + legacy always-create behavior. + - ``--config`` was provided — the caller already told us + which project to extend. + - No PAM project with this name exists — we're a fresh import. + - ``--dry-run`` / ``--output`` / ``--list-safes`` / + ``--estimate`` — those never touch the vault; no need to + resolve a config UID. + """ + opts = self.options + if (opts.sync_mode or "").lower() == "create": + return + if opts.config_uid: + return + if opts.dry_run or opts.output_file or opts.list_safes or opts.estimate_only: + return + # sync_down here so folder_cache / record_cache reflect any + # project the operator (or a peer) just created via the UI. + from ... import api as _api + try: + _api.sync_down(self.params) + except Exception: # noqa: BLE001 — best-effort refresh + return + wrapper_uids = self._find_project_wrapper_uids(opts.project_name) + if not wrapper_uids: + return + resolved = self._cmd._find_config_uid(self.params, opts.project_name) + if not resolved: + logging.debug( + "Project '%s' folder tree exists but no PAM configuration " + "record was found — proceeding as a fresh import.", + opts.project_name, + ) + return + self.options.config_uid = resolved + print_formatted_text(HTML( + f"Detected existing PAM project '{_esc(opts.project_name)}'" + f" — switching to extend mode (config: {_esc(resolved)})." + )) + + def _prepare_idempotency(self, mapped: MappedImportResult) -> Optional[dict]: + """Scan the target project and partition ``mapped`` records. + + Returns ``None`` when idempotency is disabled (``--sync-mode + create``) or when the target project doesn't exist yet (fresh + import — nothing to reconcile). Otherwise returns a dict + holding the :class:`ExistingRecordIndex`, the ordered list of + :class:`RecordDecision`, and the summary counters that the + confirmation prompt and post-import report consume. + """ + opts = self.options + if (opts.sync_mode or "").lower() == "create": + return None + if opts.dry_run or opts.output_file: + # Both paths skip the vault write; there's nothing to + # de-dupe against and the pre-scan would only slow the + # dry-run down. + return None + + from ... import api as _api + try: + _api.sync_down(self.params) + except Exception: # noqa: BLE001 — sync is best-effort + pass + + wrapper_uids = self._find_project_wrapper_uids(opts.project_name) + if not wrapper_uids: + # New project — no records exist yet. Bail so we don't + # pay the full-vault scan cost for nothing. + return None + + # Collect all shared folders directly under the project + # wrapper user folder(s). Records live inside these (and + # inside their Resources / Users subfolders for the + # safe-per-folder layout). + shared_folder_uids: list[str] = [] + seen: set = set() + for wrapper_uid in wrapper_uids: + wrapper = self.params.folder_cache.get(wrapper_uid) + if not wrapper: + continue + for child_uid in getattr(wrapper, "subfolders", []) or []: + child = self.params.folder_cache.get(child_uid) + if child is None: + continue + if getattr(child, "type", "") != "shared_folder": + continue + if child.uid in seen: + continue + seen.add(child.uid) + shared_folder_uids.append(child.uid) + + existing = build_existing_index(self.params, shared_folder_uids) + if not existing.by_account_id and not existing.by_title: + # Project exists but is empty (unusual — probably a + # partially-cleaned-up prior run). Nothing to reconcile. + return None + + decisions = partition_records( + mapped.pam_resources, mapped.pam_users, existing, + diff_fn=self._diff_incoming_vs_existing, + ) + summary = summarize(decisions) + return { + "existing": existing, + "decisions": decisions, + "partition_summary": summary, + "wrapper_uids": wrapper_uids, + } + + def _find_project_wrapper_uids(self, project_name: str) -> list[str]: + """Locate the project wrapper user-folder(s) under PAM Environments. + + Delegates to :class:`CyberArkPAMCleanupCommand` so cleanup / + idempotency / reporting all share the same discovery rules + (they need to agree on which folder tree constitutes "this + project"). + """ + return CyberArkPAMCleanupCommand._find_project_wrapper_folder_uids( + self.params, project_name, + ) + + @staticmethod + def _get_field_value(record, field_type: str, label: str = "") -> str: + """Return the first stringified value of a typed field. + + ``field_type`` matches ``TypedField.type`` (e.g. ``"login"``, + ``"password"``, ``"pamHostname"``). ``label`` is used only + for the ``text``/``checkbox`` families where the type alone + isn't enough to disambiguate (both ``operatingSystem`` and + ``instanceName`` are stored as ``text``, for instance). + """ + if record is None: + return "" + for f in getattr(record, "fields", []) or []: + ftype = getattr(f, "type", "") or "" + flabel = getattr(f, "label", "") or "" + if ftype != field_type: + continue + if label and flabel != label: + continue + vals = getattr(f, "value", None) or [] + if not vals: + return "" + raw = vals[0] + if isinstance(raw, str): + return raw + if isinstance(raw, dict): + # pamHostname is stored as {hostName, port}. We rejoin + # them into "host:port" for a stable diff key; the + # incoming record dict emits ``host`` and ``port`` + # separately so callers hit the specialized branch + # below rather than this one. + if "hostName" in raw: + return str(raw.get("hostName") or "") + return str(raw) + return str(raw) + return "" + + @staticmethod + def _get_hostname_port(record) -> tuple[str, str]: + """Extract ``(hostName, port)`` from a pamHostname typed field.""" + for f in getattr(record, "fields", []) or []: + if (getattr(f, "type", "") or "") != "pamHostname": + continue + vals = getattr(f, "value", None) or [] + if not vals: + return "", "" + raw = vals[0] + if isinstance(raw, dict): + return (str(raw.get("hostName") or ""), + str(raw.get("port") or "")) + if isinstance(raw, str): + return raw, "" + return "", "" + + def _diff_incoming_vs_existing(self, existing_rec, incoming: dict) -> list[str]: + """Return the list of field names where the two records differ. + + Only the fields the CyberArk mapper actually writes are + compared: title, notes body (marker line stripped so it is + never a diff driver), credentials (login / password / + SSH key), host + port, operating_system, distinguished_name, + connect_database and url. Rotation settings, pam_settings + options and workflow permissions are intentionally **not** + diffed because operators legitimately customize them in the + Keeper UI after the initial import — clobbering those on a + re-run would surprise everyone. + + An empty list means the record is unchanged. Any non-empty + return puts the record on the update path. + """ + changes: list[str] = [] + + rtype = incoming.get("type", "") or "" + + # Title + incoming_title = (incoming.get("title") or "").strip() + existing_title = (getattr(existing_rec, "title", "") or "").strip() + if incoming_title and incoming_title != existing_title: + changes.append("title") + + # Notes (marker line excluded so it never causes a false diff) + incoming_notes = strip_id_marker(incoming.get("notes") or "").strip() + existing_notes = strip_id_marker(getattr(existing_rec, "notes", "") or "").strip() + if incoming_notes != existing_notes: + changes.append("notes") + + # Login-family fields + if rtype in ("login", "pamUser"): + if (incoming.get("login") or "") != self._get_field_value(existing_rec, "login"): + changes.append("login") + # Password change is the whole point of a re-sync when CyberArk + # rotated the credential. Only diff when the incoming value is + # non-empty — a redacted / omitted password should not clobber + # what's already stored. + incoming_password = incoming.get("password") or "" + if incoming_password and incoming_password != self._get_field_value(existing_rec, "password"): + changes.append("password") + if incoming.get("private_pem_key"): + if incoming["private_pem_key"] != self._get_field_value(existing_rec, "secret", "privatePEMKey"): + changes.append("private_pem_key") + if rtype == "pamUser": + if (incoming.get("distinguished_name") or "") != self._get_field_value( + existing_rec, "text", "distinguishedName"): + changes.append("distinguished_name") + if (incoming.get("connect_database") or "") != self._get_field_value( + existing_rec, "text", "connectDatabase"): + changes.append("connect_database") + if rtype == RECORD_TYPE_LOGIN: + if (incoming.get("url") or "") != self._get_field_value(existing_rec, "url"): + changes.append("url") + + # Resource-family fields + if rtype in (RECORD_TYPE_PAM_MACHINE, RECORD_TYPE_PAM_DATABASE, RECORD_TYPE_PAM_DIRECTORY): + e_host, e_port = self._get_hostname_port(existing_rec) + i_host = (incoming.get("host") or "").strip() + i_port = (incoming.get("port") or "").strip() + if i_host and i_host != e_host: + changes.append("host") + if i_port and i_port != e_port: + changes.append("port") + if rtype == RECORD_TYPE_PAM_MACHINE: + if (incoming.get("operating_system") or "") != self._get_field_value( + existing_rec, "text", "operatingSystem"): + changes.append("operating_system") + + return changes + + def _apply_idempotency_filter(self, import_data: dict, + mapped: MappedImportResult, + idempotency_ctx: dict) -> None: + """Rewrite the import payload to only include CREATE records. + + Mutates the underlying lists in place (``mapped.pam_resources``, + ``mapped.pam_users``, and each resource's ``users`` sublist) + so both ``import_data["pam_data"]`` (used by the single-batch + path) and the ``resources`` / ``users`` args threaded through + ``_execute_vault_import`` → ``_multi_batch_import`` (which + rebuilds the payload from those lists) see the same filtered + set. ``pam_data.resources`` and ``mapped.pam_resources`` + already point at the same list object thanks to how + ``build_import_json`` constructs the payload, so a slice + assignment on either propagates to the other. + """ + decisions_by_id: dict[int, RecordDecision] = { + id(d.incoming): d for d in idempotency_ctx.get("decisions", []) + } + + def _keep(record: dict) -> bool: + dec = decisions_by_id.get(id(record)) + if dec is None: + # Defensive: a record the partition didn't see (e.g. + # added post-partition by another mapper hook) is + # left as-is so we don't accidentally drop new data. + return True + return dec.decision is IdempotencyDecision.CREATE + + # Filter nested pamUsers first so a resource that survives + # the outer filter carries only its CREATE-worthy children. + for res in mapped.pam_resources or []: + nested = res.get("users") + if isinstance(nested, list): + nested[:] = [u for u in nested if _keep(u)] + + # Slice assignment (list[:] = ...) mutates the list object, + # so the alias ``import_data["pam_data"]["resources"]`` sees + # the same filtered view. + mapped.pam_resources[:] = [r for r in mapped.pam_resources if _keep(r)] + mapped.pam_users[:] = [u for u in mapped.pam_users if _keep(u)] + + def _apply_record_updates(self, idempotency_ctx: dict) -> dict: + """Push UPDATE decisions to the vault via record_management. + + Uses ``vault.KeeperRecord.load`` to grab a fresh copy of the + existing record, applies only the fields the mapper actually + produced (title, notes, login, password, host, port, ...) and + calls ``record_management.update_record`` to persist. Every + exception is caught and counted — a single stale record must + never take down the whole re-sync. + """ + from ... import record_management, vault + summary = {"updated": 0, "failed": 0, "details": []} + for dec in idempotency_ctx.get("decisions", []): + if dec.decision is not IdempotencyDecision.UPDATE: + continue + existing = dec.existing + if existing is None or not getattr(existing, "record_uid", ""): + continue + try: + fresh = vault.KeeperRecord.load(self.params, existing.record_uid) + if fresh is None: + summary["failed"] += 1 + continue + self._apply_field_changes(fresh, dec.incoming, dec.change_fields) + record_management.update_record(self.params, fresh) + summary["updated"] += 1 + summary["details"].append({ + "record_uid": fresh.record_uid, + "title": getattr(fresh, "title", ""), + "fields": dec.change_fields, + "status": "ok", + }) + except Exception as e: # noqa: BLE001 — surface, don't crash + summary["failed"] += 1 + err_text = str(e) if str(e) else type(e).__name__ + logging.warning( + "Idempotency update failed for record %s (%s): %s " + "(fields: %s)", + getattr(existing, "record_uid", "?"), + getattr(existing, "title", "?"), + err_text, + ", ".join(dec.change_fields) or "(none)", + ) + summary["details"].append({ + "record_uid": getattr(existing, "record_uid", ""), + "title": getattr(existing, "title", ""), + "fields": dec.change_fields, + "status": "failed", + "error": err_text, + }) + return summary + + def _apply_field_changes(self, record, incoming: dict, + changed_fields: list[str]) -> None: + """Mutate ``record`` (a loaded KeeperRecord) in place. + + Only the fields listed in ``changed_fields`` are touched — + anything else on the record (custom fields, workflow + settings, rotation history) is left alone so operator + customizations are preserved across re-imports. + """ + + def _set_field(field_type: str, label: str, value): + for f in getattr(record, "fields", []) or []: + ftype = getattr(f, "type", "") or "" + flabel = getattr(f, "label", "") or "" + if ftype != field_type: + continue + if label and flabel != label: + continue + f.value = [value] if value not in (None, "") else [] + return + # If the record didn't originally carry the field, we + # silently drop the update — creating a typed field from + # scratch would require record-type schema knowledge that + # we don't want to duplicate here. In practice every + # PAM record already has the standard fields provisioned. + + for name in changed_fields: + if name == "title": + record.title = (incoming.get("title") or "").strip() + elif name == "notes": + record.notes = incoming.get("notes") or "" + elif name == "login": + _set_field("login", "", incoming.get("login") or "") + elif name == "password": + _set_field("password", "", incoming.get("password") or "") + elif name == "private_pem_key": + _set_field("secret", "privatePEMKey", incoming.get("private_pem_key") or "") + elif name == "distinguished_name": + _set_field("text", "distinguishedName", incoming.get("distinguished_name") or "") + elif name == "connect_database": + _set_field("text", "connectDatabase", incoming.get("connect_database") or "") + elif name == "url": + _set_field("url", "", incoming.get("url") or "") + elif name in ("host", "port"): + host = (incoming.get("host") or "").strip() + port = (incoming.get("port") or "").strip() + _set_field("pamHostname", "", {"hostName": host, "port": port}) + elif name == "operating_system": + _set_field("text", "operatingSystem", incoming.get("operating_system") or "") + + def _print_idempotency_summary(self, idempotency_ctx: dict) -> None: + summary: PartitionSummary = idempotency_ctx.get("partition_summary") + update_summary = idempotency_ctx.get("update_summary") or {} + details = update_summary.get("details") or [] + print() + print(f"{bcolors.OKBLUE}Sync results (--sync-mode=" + f"{self.options.sync_mode}):{bcolors.ENDC}") + if summary is not None: + print(f" Created (new): {summary.created}") + print(f" Updated in place: {update_summary.get('updated', summary.updated)}") + print(f" Unchanged: {summary.unchanged}") + failed = update_summary.get("failed", 0) + if failed: + print(f" {bcolors.WARNING}Update failures: {failed}{bcolors.ENDC}") + if details: + print() + print(f"{bcolors.OKBLUE}Idempotency field updates:{bcolors.ENDC}") + for item in details: + title = item.get("title") or item.get("record_uid") or "?" + fields = item.get("fields") or [] + field_text = ", ".join(fields) if fields else "(none)" + status = item.get("status", "") + if status == "failed": + err = item.get("error", "") + print(f" {bcolors.WARNING}✗{bcolors.ENDC} {title}") + print(f" fields: {field_text}") + if err: + print(f" error: {err}") + else: + print(f" {bcolors.OKGREEN}✓{bcolors.ENDC} {title}") + print(f" fields: {field_text}") + print() + + def _apply_service_dependent_mappings( + self, mapped: MappedImportResult, project_result: dict, + unmapped_items: list[dict], + ) -> Optional[dict]: + """Replay CyberArk dependents as KeeperPAM service-account mappings. + + Iterates over ``mapped.dependents`` collected during the mapping phase + and invokes ``PAMActionServiceAddCommand`` once per (machine, user, type) + tuple that resolves to imported records. Categories with no Keeper + equivalent, missing host machines, and non-Windows OS hosts are all + skipped silently and accounted for in the returned summary so the + import report can surface them. + + Returns a summary dict, or ``None`` when nothing to do. + """ + opts = self.options + if opts.skip_dependents or not mapped.dependents: + return None + + gateway = opts.gateway_name or "" + config_uid = project_result.get("config_uid") or opts.config_uid or "" + if not gateway and not config_uid: + logging.debug( + "Cannot map CyberArk dependents — gateway/config UID unknown", + ) + return { + "total": len(mapped.dependents), "added": 0, + "skipped_unsupported": 0, "skipped_non_windows": 0, + "skipped_missing_machine": 0, "skipped_missing_user": 0, + "skipped_other": len(mapped.dependents), + "details": [{"reason": "no gateway/config UID available"}], + } + + try: + from ... import api, vault, vault_extensions + from ...utils import base64_url_encode + from ..pam_service.add import PAMActionServiceAddCommand + from ..discover import GatewayContext, MultiConfigurationException + except ImportError as e: + logging.warning("Dependent service mapping unavailable: %s", + type(e).__name__) + return None + + api.sync_down(self.params) + + try: + if config_uid: + gateway_context = GatewayContext.from_configuration_uid( + params=self.params, configuration_uid=config_uid, + ) + else: + gateway_context = GatewayContext.from_gateway( + params=self.params, gateway=gateway, + ) + except MultiConfigurationException: + logging.warning( + "Multiple PAM configurations match this gateway — skipping " + "dependent mapping. Re-run 'pam action service add' manually.", + ) + return { + "total": len(mapped.dependents), "added": 0, + "skipped_other": len(mapped.dependents), + "skipped_unsupported": 0, "skipped_non_windows": 0, + "skipped_missing_machine": 0, "skipped_missing_user": 0, + "details": [{"reason": "multiple matching PAM configurations"}], + } + if gateway_context is None: + logging.warning( + "Gateway context unavailable — skipping CyberArk dependent mapping. " + "Re-run 'pam action service add' manually for each pair.", + ) + return None + + machine_index, user_index = self._build_record_indexes( + mapped, vault, vault_extensions, + ) + + summary = { + "total": len(mapped.dependents), + "added": 0, + "skipped_unsupported": 0, + "skipped_non_windows": 0, + "skipped_missing_machine": 0, + "skipped_missing_user": 0, + "skipped_other": 0, + "details": [], + } + try: + gateway_uid = base64_url_encode(gateway_context.gateway.controllerUid) + except Exception: + gateway_uid = gateway_context.gateway.controllerName + + add_cmd = PAMActionServiceAddCommand() + + for dep in mapped.dependents: + service_type = dep.get("service_type") + if service_type not in ("service", "task", "iis"): + summary["skipped_unsupported"] += 1 + continue + + machine_record = self._find_machine_record( + dep.get("machine_address", ""), machine_index, + ) + if machine_record is None: + summary["skipped_missing_machine"] += 1 + summary["details"].append({ + "service": dep.get("service_name", ""), + "host": dep.get("machine_address", ""), + "type": dep.get("raw_type", ""), + "reason": "no PAM Machine record imported for this host", + }) + continue + + if not self._is_windows_machine(machine_record): + summary["skipped_non_windows"] += 1 + unmapped_items.append({ + "category": "CyberArk dependent", + "item": (f"{dep.get('service_name') or dep.get('raw_type')} " + f"on {dep.get('machine_address')}"), + "action": "Host is not Windows — Keeper PAM can only rotate " + "Windows service / task / IIS credentials", + }) + continue + + user_record = user_index.get(dep.get("master_user_title", "")) + if user_record is None: + summary["skipped_missing_user"] += 1 + summary["details"].append({ + "service": dep.get("service_name", ""), + "host": dep.get("machine_address", ""), + "type": dep.get("raw_type", ""), + "reason": "PAM User record not found in vault after import", + }) + continue + + try: + add_cmd.execute( + self.params, + gateway=gateway_uid, + configuration_uid=gateway_context.configuration.record_uid, + machine_uid=machine_record.record_uid, + user_uid=user_record.record_uid, + type=service_type, + ) + summary["added"] += 1 + except Exception as e: # noqa: BLE001 — never block reporting + summary["skipped_other"] += 1 + logging.warning( + "Failed to register %s mapping for %s on %s: %s", + service_type, dep.get("service_name", "?"), + dep.get("machine_address", "?"), type(e).__name__, + ) + summary["details"].append({ + "service": dep.get("service_name", ""), + "host": dep.get("machine_address", ""), + "type": dep.get("raw_type", ""), + "reason": f"pam action service add failed: {type(e).__name__}", + }) + + return summary + + def _build_record_indexes(self, mapped: MappedImportResult, vault, vault_extensions + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Build ``(machine, user)`` record lookups from freshly imported vault data. + + Indexes pamMachine records by lowercased host AND lowercased title, and + pamUser records by lowercased title — the same identifiers + ``AccountMapper`` writes during the mapping phase. Restricting the scan + to titles the importer just produced keeps the lookup O(imported) rather + than O(vault). + """ + machine_index: dict[str, Any] = {} + user_index: dict[str, Any] = {} + imported_machine_titles = { + (r.get("title") or "").casefold() + for r in mapped.pam_resources if r.get("type") == RECORD_TYPE_PAM_MACHINE + } + imported_user_titles = set() + for r in mapped.pam_resources: + for u in r.get("users") or []: + t = (u.get("title") or "").casefold() + if t: + imported_user_titles.add(t) + for rec in vault_extensions.find_records(self.params, record_version=3): + rtype = getattr(rec, "record_type", "") or "" + title = (getattr(rec, "title", "") or "").casefold() + if rtype == RECORD_TYPE_PAM_MACHINE and title in imported_machine_titles: + loaded = vault.KeeperRecord.load(self.params, rec.record_uid) + if loaded is None: + continue + machine_index[title] = loaded + host_field = next( + (f for f in loaded.fields + if (getattr(f, "type", "") or "") == "pamHostname" + or (getattr(f, "label", "") or "").lower() == "host"), + None, + ) + if host_field and host_field.value: + raw = host_field.value[0] + host_str = "" + if isinstance(raw, dict): + host_str = (raw.get("hostName") or raw.get("host") or "") + elif isinstance(raw, str): + host_str = raw + if host_str: + machine_index[host_str.casefold()] = loaded + elif rtype == "pamUser" and title in imported_user_titles: + loaded = vault.KeeperRecord.load(self.params, rec.record_uid) + if loaded is not None: + user_index[title] = loaded + return machine_index, user_index + + @staticmethod + def _find_machine_record(address: str, machine_index: dict[str, Any] + ) -> Any: + """Resolve a CyberArk dependent ``Address`` to a Keeper PAM Machine. + + Tries an exact host/title hit first, then a hostname-prefix match + (CyberArk stores FQDNs but resources may be titled with the short + name and vice-versa). Returns ``None`` when nothing matches. + """ + if not address: + return None + key = address.casefold() + if key in machine_index: + return machine_index[key] + short = key.split(".", 1)[0] + if short and short in machine_index: + return machine_index[short] + for k, rec in machine_index.items(): + if k.startswith(short) or short.startswith(k.split(".", 1)[0]): + return rec + return None + + @staticmethod + def _is_windows_machine(machine_record) -> bool: + """Mirror PAMActionServiceAddCommand's OS check so we can skip + non-Windows hosts before the call (avoiding noisy print output).""" + os_field = next( + (f for f in machine_record.fields + if (f.label or "") == "operatingSystem"), + None, + ) + if os_field is None or not os_field.value: + return False + return str(os_field.value[0]).lower() == "windows" + + @staticmethod + def _print_dependent_summary(summary: dict): + added = summary.get("added", 0) + skipped = (summary.get("skipped_unsupported", 0) + + summary.get("skipped_non_windows", 0) + + summary.get("skipped_missing_machine", 0) + + summary.get("skipped_missing_user", 0) + + summary.get("skipped_other", 0)) + print() + print(f"{bcolors.OKBLUE}CyberArk dependents → " + f"'pam action service add':{bcolors.ENDC}") + print(f" Linked: {added}") + if summary.get("skipped_unsupported"): + print(f" Unsupported type: {summary['skipped_unsupported']}") + if summary.get("skipped_non_windows"): + print(f" Non-Windows host: {summary['skipped_non_windows']}") + if summary.get("skipped_missing_machine"): + print(f" Missing PAM Machine: {summary['skipped_missing_machine']}") + if summary.get("skipped_missing_user"): + print(f" Missing PAM User: {summary['skipped_missing_user']}") + if summary.get("skipped_other"): + print(f" Other failures: {summary['skipped_other']}") + if skipped == 0 and added == 0: + print(f" {bcolors.WARNING}No dependents resolvable to imported records.{bcolors.ENDC}") + print() + + @staticmethod + def _build_resource_counts(mapped: MappedImportResult) -> dict[str, dict[str, int]]: + resource_counts: dict[str, dict[str, int]] = { + "pamMachine": {"ok": 0, "skip": 0, "err": 0}, + "pamDatabase": {"ok": 0, "skip": 0, "err": 0}, + "pamUser": {"ok": 0, "skip": 0, "err": 0}, + "login": {"ok": 0, "skip": 0, "err": 0}, + } + for r in mapped.pam_resources: + rtype = r.get("type", "pamMachine") + resource_counts.setdefault(rtype, {"ok": 0, "skip": 0, "err": 0}) + resource_counts[rtype]["ok"] += 1 + for u in r.get("users", []): + resource_counts["pamUser"]["ok"] += 1 + for u in mapped.pam_users: + resource_counts["login"]["ok"] += 1 + return resource_counts + + def _attach_report_files(self, notes_text: str, report_config_uid: str, + user_team_matcher: Optional[UserTeamMatcher]): + tmp_files: list[str] = [] + try: + from ..record_edit import RecordUploadAttachmentCommand + attachments: list[str] = [] + report_tmp = tempfile.NamedTemporaryFile( + mode='w', suffix='.md', prefix='CyberArk-Import-Report-', + delete=False, encoding='utf-8', + ) + report_tmp.write(notes_text) + report_tmp.close() + tmp_files.append(report_tmp.name) + attachments.append(report_tmp.name) + if user_team_matcher and user_team_matcher.unmatched: + csv_content = user_team_matcher.generate_csv() + if csv_content: + csv_tmp = tempfile.NamedTemporaryFile( + mode='w', suffix='.csv', prefix='ca_users_to_provision_', + delete=False, encoding='utf-8', + ) + csv_tmp.write(csv_content) + csv_tmp.close() + tmp_files.append(csv_tmp.name) + attachments.append(csv_tmp.name) + if attachments: + RecordUploadAttachmentCommand().execute( + self.params, record=report_config_uid, file=attachments, + ) + try: + print(f"Report saved to PAM config record: {report_config_uid}") + if len(attachments) > 1: + print("CSV (ca_users_to_provision) attached") + except (BrokenPipeError, OSError): + pass + except Exception as e: + logging.warning("Failed to save report attachment: %s", type(e).__name__) + finally: + for path in tmp_files: + try: + os.unlink(path) + except OSError: + pass + + +class CyberArkPAMImportCommand(Command): + parser = argparse.ArgumentParser( + prog="pam project cyberark-import", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + # Dry-run from Privilege Cloud + pam project cyberark-import tenant.cyberark.cloud --dry-run + + # List available safes + pam project cyberark-import pvwa.example.com --list-safes + + # Import specific safes only + pam project cyberark-import pvwa.example.com --safes "Windows*,Unix*" --name "PAM Migration" + + # Exclude safes + pam project cyberark-import pvwa.example.com --exclude-safes "Test*,Dev*" + + # Save JSON for review + pam project cyberark-import pvwa.example.com --dry-run --output /tmp/review.json + + # Full import with custom platform mapping + pam project cyberark-import pvwa.example.com --platform-map platforms.json --name "Prod" + + # Non-interactive batch mode + pam project cyberark-import pvwa.example.com --name "Auto Import" --yes + + # Self-hosted with SSL verification disabled + pam project cyberark-import pvwa.internal.com --no-verify-ssl --name "Internal" + ''') + parser.add_argument("server", action="store", help="CyberArk PVWA host (e.g. mycompany.cyberark.cloud or pvwa.example.com)") + parser.add_argument("--name", "-n", required=False, dest="project_name", action="store", + default="", help="PAM project name (default: CyberArk Migration)") + parser.add_argument("--config", "-c", required=False, dest="config", action="store", + default="", help="Extend existing PAM project (PAM Configuration UID or title)") + parser.add_argument("--gateway", "-g", required=False, dest="gateway", action="store", + default="", help="Gateway UID or name (new project only)") + parser.add_argument("--folder-mode", required=False, dest="folder_mode", action="store", + choices=["safe", "ksm", "exact", "flat"], default="safe", + help="Safe → folder mapping mode (default: safe). " + "'safe' creates one Keeper shared folder per CyberArk safe " + "with that safe's permission set, enabling per-safe access " + "control. 'ksm'/'exact' nest safe-named subfolders under the " + "legacy Resources/Users shared folders. 'flat' puts every " + "record into the two legacy shared folders with aggregated " + "permissions.") + parser.add_argument("--safes", required=False, dest="safes", action="store", + default="", help="Include only matching Safes (comma-separated, supports globs)") + parser.add_argument("--exclude-safes", required=False, dest="exclude_safes", action="store", + default="", help="Exclude matching Safes (comma-separated, supports globs)") + parser.add_argument("--list-safes", required=False, dest="list_safes", action="store_true", + default=False, help="List Safes with account counts and exit") + parser.add_argument("--dry-run", "-d", required=False, dest="dry_run", action="store_true", + default=False, help="Preview import without modifying vault") + parser.add_argument("--output", "-o", required=False, dest="output", action="store", + default="", help="Save generated import JSON to file") + parser.add_argument("--include-credentials", required=False, dest="include_credentials", + action="store_true", default=False, + help="Include passwords in --output or --dry-run display") + parser.add_argument("--estimate", required=False, dest="estimate", action="store_true", + default=False, help="Count accounts and estimate import time, then exit") + parser.add_argument("--yes", "-y", required=False, dest="yes", action="store_true", + default=False, help="Skip confirmation prompts") + parser.add_argument("--skip-users", required=False, dest="skip_users", action="store_true", + default=False, help="Import machine/database records only, skip pamUser records") + parser.add_argument("--batch-size", required=False, dest="batch_size", action="store", + type=int, default=100, help="Records per import batch (default: 100)") + parser.add_argument("--batch-delay", required=False, dest="batch_delay", action="store", + type=float, default=0.5, help="Base delay between batches in seconds (default: 0.5)") + parser.add_argument("--platform-map", required=False, dest="platform_map", action="store", + default="", help="JSON file mapping CyberArk platformIds to KeeperPAM rotation types") + parser.add_argument("--state-filter", required=False, dest="state_filter", action="store", + default="", help="Comma-separated CPM states to include (default: all)") + parser.add_argument("--no-verify-ssl", required=False, dest="no_verify_ssl", action="store_true", + default=False, help="Disable SSL certificate verification for self-hosted PVWA (insecure)") + parser.add_argument("--include-system-safes", required=False, dest="include_system_safes", + action="store_true", default=False, + help="Include CyberArk system safes (VaultInternal, PVWAConfig, etc.)") + parser.add_argument("--skip-linked-accounts", required=False, dest="skip_linked_accounts", + action="store_true", default=False, + help="Skip logon/reconcile/enable linked account resolution") + parser.add_argument("--skip-members", required=False, dest="skip_members", + action="store_true", default=False, + help="Skip safe member extraction and folder permissions") + parser.add_argument("--skip-dependents", required=False, dest="skip_dependents", + action="store_true", default=False, + help="Skip CyberArk dependents → 'pam action service' mapping after import") + parser.add_argument("--user-map", required=False, dest="user_map", action="store", + default="", help="JSON file mapping CyberArk users to Keeper emails") + parser.add_argument("--sync-mode", required=False, dest="sync_mode", + choices=["upsert", "create"], default="upsert", + help="upsert (default): idempotent — create missing " + "records, update changed ones, skip unchanged. " + "create: legacy always-create (may produce " + "duplicates on re-run).") + parser.add_argument("--strict-policies", required=False, dest="strict_policies", + action="store_true", default=False, + help="Fail the import when a platform's rotation policy " + "is inaccessible instead of inheriting the master " + "policy default") + + def get_parser(self): + return CyberArkPAMImportCommand.parser + + def execute(self, params, **kwargs): + server = kwargs.get("server", "") + project_name = kwargs.get("project_name", "") or "CyberArk Migration" + config_uid = kwargs.get("config", "") + gateway_name = kwargs.get("gateway", "") + folder_mode = kwargs.get("folder_mode", "flat") + safe_include = kwargs.get("safes", "") + safe_exclude = kwargs.get("exclude_safes", "") + list_safes = kwargs.get("list_safes", False) + dry_run = kwargs.get("dry_run", False) + output_file = kwargs.get("output", "") + include_creds = kwargs.get("include_credentials", False) + estimate_only = kwargs.get("estimate", False) + skip_confirm = kwargs.get("yes", False) + skip_users = kwargs.get("skip_users", False) + skip_linked = kwargs.get("skip_linked_accounts", False) + + batch_size = int(kwargs.get("batch_size") or 100) + batch_delay = float(kwargs.get("batch_delay") or 0.5) + platform_map_file = kwargs.get("platform_map", "") + state_filter_str = kwargs.get("state_filter", "") + + # Validate batch parameters + if batch_size < 1: + raise CommandError("pam project cyberark-import", "--batch-size must be >= 1") + if batch_delay < 0: + raise CommandError("pam project cyberark-import", "--batch-delay must be >= 0") + + if not server: + raise CommandError("pam project cyberark-import", "CyberArk PVWA server is required") + + # Load and validate platform map override + platform_map_override = None + if platform_map_file: + if not os.path.isfile(platform_map_file): + raise CommandError("pam project cyberark-import", + f"Platform map file not found: {platform_map_file}") + try: + with open(platform_map_file, encoding="utf-8") as f: + platform_map_override = json.load(f) + except json.JSONDecodeError as e: + raise CommandError("pam project cyberark-import", + f"Invalid JSON in platform map file: {e}") + if not isinstance(platform_map_override, dict): + raise CommandError("pam project cyberark-import", + "Platform map must be a JSON object mapping platformId to settings") + for key, entry in platform_map_override.items(): + if not isinstance(entry, dict) or "record_type" not in entry: + raise CommandError("pam project cyberark-import", + f"Platform map entry '{key}' must be an object with a 'record_type' field") + + no_verify_ssl = kwargs.get("no_verify_ssl", False) + state_filter = [s.strip() for s in state_filter_str.split(",") if s.strip()] if state_filter_str else None + + # ── Resolve gateway UID → name if needed ───────────── + if gateway_name and not config_uid: + try: + from ..pam.gateway_helper import get_all_gateways + from ...loginv3 import CommonHelperMethods + all_gw = get_all_gateways(params) + gw_uid_bytes = CommonHelperMethods.url_safe_str_to_bytes(gateway_name) + for gw in all_gw: + if gw.controllerUid == gw_uid_bytes: + gateway_name = gw.controllerName + logging.info(f"Resolved gateway UID to name: {gateway_name}") + break + except Exception: + pass # Use gateway_name as-is (may be a name already) + + # ── Phase 0: Authenticate ──────────────────────────── + try: + client = CyberArkPVWAClient(server, verify_ssl=not no_verify_ssl) + except ValueError as e: + raise CommandError("pam project cyberark-import", str(e)) + if not client.authenticate(): + raise CommandError("pam project cyberark-import", + "Authentication failed. Check credentials and try again.") + + try: + options = ImportRunOptions( + server=server, + project_name=project_name, + config_uid=config_uid, + gateway_name=gateway_name, + folder_mode=folder_mode, + dry_run=dry_run, + output_file=output_file, + include_creds=include_creds, + estimate_only=estimate_only, + skip_confirm=skip_confirm, + skip_users=skip_users, + skip_linked=skip_linked, + skip_members=kwargs.get("skip_members", False), + skip_dependents=kwargs.get("skip_dependents", False), + safe_include=safe_include, + safe_exclude=safe_exclude, + list_safes=list_safes, + batch_size=batch_size, + batch_delay=batch_delay, + platform_map_override=platform_map_override, + state_filter=state_filter, + include_system_safes=kwargs.get("include_system_safes", False), + user_map_file=kwargs.get("user_map", ""), + sync_mode=(kwargs.get("sync_mode") or "upsert").lower(), + strict_policies=bool(kwargs.get("strict_policies", False)), + raw_kwargs=kwargs, + ) + CyberArkImportOrchestrator(self, params, client, options).run() + finally: + client.logoff() + + def _execute_import(self, params, import_data: dict, project_name: str, + config_uid: str, batch_size: int, batch_delay: float, + resources: list[dict], users: list[dict]) -> Optional[dict]: + """Execute the vault import using pam project import/extend commands.""" + from .edit import PAMProjectImportCommand + from .extend import PAMProjectExtendCommand + + # Write JSON to temp file for the import command + total_records = len(resources) + len(users) + + if total_records <= batch_size: + # Single batch — use import or extend directly + return self._single_batch_import( + params, import_data, project_name, config_uid + ) + else: + # Multi-batch: first batch creates project, remaining extend + return self._multi_batch_import( + params, import_data, project_name, config_uid, + resources, users, batch_size, batch_delay, + ) + + def _single_batch_import(self, params, import_data: dict, + project_name: str, config_uid: str) -> dict: + """Import all records in a single batch.""" + from .edit import PAMProjectImportCommand + from .extend import PAMProjectExtendCommand + + tmp_path = _temp_store.write_json(import_data) + try: + if config_uid: + PAMProjectExtendCommand().execute( + params, config=config_uid, file_name=tmp_path, dry_run=False + ) + else: + PAMProjectImportCommand().execute( + params, project_name=project_name, file_name=tmp_path, dry_run=False + ) + finally: + _temp_store.remove(tmp_path) + + resolved_config_uid = config_uid or self._find_config_uid(params, project_name) + return {"project_name": project_name, "config_uid": resolved_config_uid} + + def _multi_batch_import(self, params, import_data: dict, + project_name: str, config_uid: str, + resources: list[dict], users: list[dict], + batch_size: int, batch_delay: float) -> dict: + """Import records in multiple batches with adaptive throttling.""" + from .edit import PAMProjectImportCommand + from .extend import PAMProjectExtendCommand + + throttler = AdaptiveThrottler(base_delay=batch_delay, batch_size=batch_size) + all_items = resources + users + + for i in range(0, len(all_items), batch_size): + batch = all_items[i:i + batch_size] + batch_resources = [r for r in batch if r.get("type") in ("pamMachine", "pamDatabase", "pamDirectory", "pamRemoteBrowser")] + batch_users = [r for r in batch if r.get("type") in ("login", "pamUser")] + batch_num = (i // batch_size) + 1 + total_batches = (len(all_items) + batch_size - 1) // batch_size + print(f"Processing batch {batch_num}/{total_batches} ({len(batch)} records)...") + + batch_start = time.time() + + if i == 0 and not config_uid: + # First batch: create project skeleton + records + first_batch_data = dict(import_data) + first_batch_data["pam_data"] = { + "resources": batch_resources, + "users": batch_users, + } + tmp_path = _temp_store.write_json(first_batch_data) + try: + PAMProjectImportCommand().execute( + params, project_name=project_name, file_name=tmp_path, dry_run=False + ) + finally: + _temp_store.remove(tmp_path) + + # For subsequent batches, we need the PAM config UID + # Try to find it by project name + if not config_uid: + config_uid = self._find_config_uid(params, project_name) + else: + # Subsequent batches: extend + extend_data = build_extend_json(batch_resources, batch_users) + tmp_path = _temp_store.write_json(extend_data) + try: + if config_uid: + PAMProjectExtendCommand().execute( + params, config=config_uid, file_name=tmp_path, dry_run=False + ) + else: + logging.error("Cannot extend: PAM configuration UID not found after initial import") + break + finally: + _temp_store.remove(tmp_path) + + batch_duration_ms = (time.time() - batch_start) * 1000 + throttler.record_response(batch_duration_ms, True) + if i + batch_size < len(all_items): + throttler.wait() + + return {"project_name": project_name, "config_uid": config_uid} + + def _find_config_uid(self, params, project_name: str) -> str: + """Find PAM configuration UID by project name after initial import. + Handles #N suffix deduplication from PAMProjectImportCommand.""" + from ... import api, vault_extensions + + api.sync_down(params) + config_base = f"{project_name} Configuration".casefold() + candidates = [] + for c in vault_extensions.find_records(params, record_version=6): + t = c.title.casefold() + if t == config_base or (t.startswith(config_base) and re.match(r' #\d+$', t[len(config_base):])): + candidates.append(c) + if not candidates: + logging.warning(f"PAM configuration not found for project '{project_name}' after import") + return "" + # Prefer highest suffix number (most recently created) + # Sort numerically, not lexicographically (so #10 > #9) + def _sort_key(c): + m = re.search(r' #(\d+)$', c.title) + return int(m.group(1)) if m else 0 + candidates.sort(key=_sort_key, reverse=True) + return candidates[0].record_uid + + @staticmethod + def _list_safes_detailed(safes: list[dict], system_excluded: int): + """List safes with details for --list-safes output.""" + print(f'\n{bcolors.OKBLUE}Available CyberArk Safes:{bcolors.ENDC}') + print('=' * 60) + print(f' {"#":<4s} {"Safe Name":<35s} {"CPM":>10s}') + print(' ' + '-' * 55) + for i, safe in enumerate(safes, 1): + name = safe.get("safeName", "?") + cpm = safe.get("managingCPM", "—") or "—" + print(f' {i:<4d} {name:<35s} {cpm:>10s}') + print(' ' + '-' * 55) + print(f' Total: {len(safes)} safes') + if system_excluded > 0: + print(f' ({system_excluded} system safes excluded — use --include-system-safes to show)') + print() + print(' Use --safes "Name1,Name2" to import specific safes') + print(' Use --exclude-safes "Name1,Name2" to exclude safes') + print(' Wildcards supported: --safes "Windows*,Unix*"') + print() + + @staticmethod + def _interactive_safe_picker(safes: list[dict]) -> Optional[str]: + """Show safes and let user select which to import. + + Returns comma-separated safe names for apply_safe_filter, + or None to import all. + """ + print(f'\n{bcolors.OKBLUE}CyberArk Safes Found:{bcolors.ENDC}') + print('─' * 50) + numbered = [] + for i, safe in enumerate(safes, 1): + name = safe.get("safeName", "?") + numbered.append(name) + print(f' [{i}] {name}') + print(f'\n [A] Import ALL safes ({len(safes)})') + print() + + try: + choice = input(f' Select safes (comma-separated numbers, or A for all) [A]: ').strip() + except EOFError: + return None + + if not choice or choice.upper() == 'A': + return None + + selected = [] + for part in choice.split(','): + part = part.strip() + try: + idx = int(part) - 1 + if 0 <= idx < len(numbered): + selected.append(numbered[idx]) + except ValueError: + continue + + if not selected: + return None + + logging.warning('Selected safes: %s', ', '.join(selected)) + return ','.join(selected) + + +class CyberArkPAMCleanupCommand(Command): + """Remove a CyberArk-imported project: records, folders, gateway, KSM app.""" + + parser = argparse.ArgumentParser( + prog="pam project cyberark-cleanup", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + # Preview what would be deleted + pam project cyberark-cleanup --name "CyberArk Migration" --dry-run + + # Delete by project name + pam project cyberark-cleanup --name "CyberArk Migration" --yes + + # Delete by PAM config UID + pam project cyberark-cleanup --config --yes + ''') + parser.add_argument("--name", "-n", dest="project_name", action="store", + default="", help="Project name (matches PAM config title prefix)") + parser.add_argument("--config", "-c", dest="config_uid", action="store", + default="", help="PAM config record UID") + parser.add_argument("--dry-run", "-d", dest="dry_run", action="store_true", + default=False, help="Show what would be deleted") + parser.add_argument("--yes", "-y", dest="auto_confirm", action="store_true", + default=False, help="Skip confirmation prompt") + + def get_parser(self): + return CyberArkPAMCleanupCommand.parser + + def execute(self, params, **kwargs): + project_name = kwargs.get("project_name", "") + config_uid = kwargs.get("config_uid", "") + dry_run = kwargs.get("dry_run", False) + auto_confirm = kwargs.get("auto_confirm", False) + + if not project_name and not config_uid: + raise CommandError("pam project cyberark-cleanup", + "Either --name or --config is required") + + from ... import api, utils, vault, vault_extensions + from ..pam import gateway_helper + from ..pam.config_helper import configuration_controller_get + from ...loginv3 import CommonHelperMethods + + api.sync_down(params) + + # Find PAM config by name or UID + if config_uid: + config_rec = vault.KeeperRecord.load(params, config_uid) + if not config_rec: + raise CommandError("pam project cyberark-cleanup", + f"PAM config record '{config_uid}' not found") + project_name = config_rec.title.replace(" Configuration", "") + else: + config_base = f"{project_name} Configuration".casefold() + config_rec = None + for c in vault_extensions.find_records(params, record_version=6): + if c.title.casefold().startswith(config_base): + config_rec = c + config_uid = c.record_uid + break + if not config_rec: + raise CommandError("pam project cyberark-cleanup", + f"PAM config for project '{project_name}' not found") + + # Resolve gateway linked to this PAM config + gateway_uid = None + gateway_name = None + gw_match = None + try: + controller = configuration_controller_get( + params, CommonHelperMethods.url_safe_str_to_bytes( + config_rec.record_uid)) + if controller and controller.controllerUid: + gateway_uid = controller.controllerUid + all_gw = gateway_helper.get_all_gateways(params) + gw_match = next((g for g in all_gw + if g.controllerUid == gateway_uid), None) + if gw_match: + gateway_name = gw_match.controllerName + except Exception as e: + logging.debug("Could not resolve gateway: %s", e) + + # Resolve KSM application linked to the gateway + ksm_app_uid = None + ksm_app_name = None + if gw_match and gw_match.applicationUid: + ksm_app_uid = utils.base64_url_encode(gw_match.applicationUid) + app_rec = vault.KeeperRecord.load(params, ksm_app_uid) + if app_rec: + ksm_app_name = getattr(app_rec, "title", ksm_app_uid) + + # Find shared folders. The new safe-per-folder layout creates one + # shared folder per CyberArk safe under the project wrapper folder + # plus an admin Config folder; each safe folder has two + # ``Resources``/``Users`` shared_folder_folder subfolders that + # hold the records. The legacy layout creates exactly two folders + # ("{project} - Resources" and "{project} - Users") with safe-named + # subfolders inside. Discover everything by walking the project + # wrapper user-folder under PAM Environments so cleanup handles + # both shapes (and any subset thereof) without hardcoding names. + sf_uids = [] + sf_names: list = [] + all_record_uids: set = set() + res_name = f"{project_name} - Resources" + usr_name = f"{project_name} - Users" + config_name = f"{project_name} - Config" + + def _collect_records_recursive(folder_uid: str): + """Walk the folder subtree and accumulate every record UID + (records living directly in this folder + records living in + any descendant ``shared_folder_folder``).""" + stack = [folder_uid] + while stack: + fuid = stack.pop() + for ruid in params.subfolder_record_cache.get(fuid, set()) or set(): + all_record_uids.add(ruid) + folder = params.folder_cache.get(fuid) + if not folder: + continue + for sub_uid in getattr(folder, "subfolders", []) or []: + stack.append(sub_uid) + + def _delete_folder(folder_uid: str) -> bool: + folder = params.folder_cache.get(folder_uid) + if not folder: + return False + del_obj = { + "delete_resolution": "unlink", + "object_uid": folder.uid, + "object_type": folder.type, + } + parent = params.folder_cache.get(folder.parent_uid) + if parent: + del_obj["from_uid"] = parent.uid + del_obj["from_type"] = parent.type + else: + del_obj["from_type"] = "user_folder" + rq = {"command": "pre_delete", "objects": [del_obj]} + rs = api.communicate(params, rq) + if rs.get("result") != "success": + return False + pdr = rs.get("pre_delete_response", {}) + del_rq = { + "command": "delete", + "pre_delete_token": pdr.get("pre_delete_token", ""), + } + api.communicate(params, del_rq) + return True + + project_folder_uids = self._find_project_wrapper_folder_uids( + params, project_name, + ) + if project_folder_uids: + sf_uids_seen: set = set() + for project_uid in project_folder_uids: + project_folder = params.folder_cache.get(project_uid) + if not project_folder: + continue + for child_uid in getattr(project_folder, "subfolders", []) or []: + child = params.folder_cache.get(child_uid) + if not child: + continue + # Only collect shared folders that we created — i.e. + # the type is SharedFolderType. + if getattr(child, "type", "") != "shared_folder": + continue + if child.uid in sf_uids_seen: + continue + sf_uids_seen.add(child.uid) + sf_uids.append(child.uid) + sf_names.append(getattr(child, "name", "") or "") + _collect_records_recursive(child.uid) + else: + # Fallback: scan the shared-folder cache by name. Catches the + # legacy two-folder layout when the project wrapper folder was + # deleted/renamed manually post-import. + for sf_uid, sf in params.shared_folder_cache.items(): + name = sf.get("name_unencrypted", "") + if name in (res_name, usr_name, config_name): + sf_uids.append(sf_uid) + sf_names.append(name) + _collect_records_recursive(sf_uid) + + # Ensure PAM config is included even if it lives outside the + # discovered shared-folder tree. + if config_uid: + all_record_uids.add(config_uid) + + print(f"\nCyberArk PAM Project Cleanup") + print("=" * 50) + print(f" Project: {project_name}") + print(f" PAM Config: {config_uid}") + print(f" Gateway: {gateway_name or '(not found)'}") + print(f" KSM App: {ksm_app_name or ksm_app_uid or '(not found)'}") + print(f" Folders: {len(sf_uids)}") + for sf_name in sf_names: + if sf_name: + print(f" • {sf_name}") + if project_folder_uids: + print(f" Wrappers: {len(project_folder_uids)}") + print(f" Records: {len(all_record_uids)}") + + if dry_run: + print(" (dry run — no changes made)") + print("=" * 50) + return + + if not auto_confirm: + answer = input("\n Delete all of the above? [y/N]: ").strip().lower() + if answer not in ("y", "yes"): + print(" Cancelled.") + return + + deleted = 0 + failed = 0 + + # Delete records in batches (same API as api.delete_record) + if all_record_uids: + logging.warning("Deleting %d records...", len(all_record_uids)) + uid_list = list(all_record_uids) + batch_size = 50 + for i in range(0, len(uid_list), batch_size): + batch = uid_list[i:i + batch_size] + try: + rq = {"command": "record_update", "delete_records": batch} + api.communicate(params, rq) + deleted += len(batch) + except Exception as e: + failed += len(batch) + logging.warning("Failed to delete record batch: %s", e) + + # Delete shared folders (safe folders + Config folder) + if sf_uids: + logging.warning("Removing %d shared folder(s)...", len(sf_uids)) + for sf_uid in sf_uids: + try: + if not _delete_folder(sf_uid): + failed += 1 + logging.warning("Failed to remove shared folder %s", + sf_uid) + except Exception as e: + failed += 1 + logging.warning("Failed to remove shared folder %s: %s", + sf_uid, e) + + # Delete project wrapper user-folder(s) under PAM Environments + if project_folder_uids: + logging.warning("Removing %d project wrapper folder(s)...", + len(project_folder_uids)) + for wrapper_uid in project_folder_uids: + try: + if not _delete_folder(wrapper_uid): + failed += 1 + logging.warning("Failed to remove wrapper folder %s", + wrapper_uid) + except Exception as e: + failed += 1 + logging.warning("Failed to remove wrapper folder %s: %s", + wrapper_uid, e) + + # Remove gateway + if gateway_uid: + logging.warning("Removing gateway \"%s\"...", + gateway_name or gateway_uid) + try: + gateway_helper.remove_gateway(params, gateway_uid) + except Exception as e: + failed += 1 + logging.warning("Failed to remove gateway: %s", e) + + # Remove KSM application + if ksm_app_uid: + logging.warning("Removing KSM app \"%s\"...", + ksm_app_name or ksm_app_uid) + try: + from ..ksm import KSMCommand + KSMCommand.remove_v5_app(params, ksm_app_uid, + purge=True, force=True) + except Exception as e: + failed += 1 + logging.warning("Failed to remove KSM app: %s", e) + + api.sync_down(params) + msg = f"\nCleanup complete: {deleted} records deleted" + if failed: + msg += f" ({failed} failed — see warnings above)" + print(msg) + print("=" * 50) + + PAM_ROOT_FOLDER_NAME = "PAM Environments" + + @classmethod + def _find_project_wrapper_folder_uids(cls, params, project_name: str) -> list: + """Return UIDs of every project wrapper user-folder under + ``PAM Environments`` whose name matches ``project_name`` (or + ``project_name #N`` when the project was imported multiple times). + + The wrapper folder is a *user folder* (not shared), and per-safe + shared folders are created as direct children of it. Returning a + list keeps cleanup correct in the rare case where two projects + share a name (PAMProjectImportCommand allows duplicates via the + ``#N`` suffix). + """ + wrapper_uids: list = [] + folders = params.folder_cache if params and params.folder_cache else {} + if not isinstance(folders, dict): + return wrapper_uids + + # Locate root "PAM Environments" user folder(s). + root_uids: list = [] + for uid, f in folders.items(): + if not f or getattr(f, "parent_uid", None): + continue + if getattr(f, "type", "") != "user_folder": + continue + if getattr(f, "name", "") == cls.PAM_ROOT_FOLDER_NAME: + root_uids.append(uid) + if not root_uids: + return wrapper_uids + + # PAMProjectImportCommand emits "{project_name}" or + # "{project_name} #N" for the wrapper user folder, so match both + # shapes here. + base = project_name + for root_uid in root_uids: + root_folder = folders.get(root_uid) + if not root_folder: + continue + for child_uid in getattr(root_folder, "subfolders", []) or []: + child = folders.get(child_uid) + if not child or getattr(child, "type", "") != "user_folder": + continue + name = getattr(child, "name", "") or "" + if name == base or re.match(rf"^{re.escape(base)} #\d+$", name): + wrapper_uids.append(child.uid) + return wrapper_uids diff --git a/keepercommander/commands/pam_import/edit.py b/keepercommander/commands/pam_import/edit.py index 1e99f07cb..0087b1ae9 100644 --- a/keepercommander/commands/pam_import/edit.py +++ b/keepercommander/commands/pam_import/edit.py @@ -177,10 +177,28 @@ def _has_perms(section_key): perms = section.get("permissions") if isinstance(section, dict) else None return isinstance(perms, list) and len(perms) > 0 + def _has_safe_folder_perms(): + # CyberArk per-safe folders carry their own permission lists. + # Any non-empty ``permissions`` array means we need enterprise + # data to resolve the principals before creating folders. + data = project["data"] if isinstance(project["data"], dict) else {} + sf = data.get("safe_folders") if isinstance(data, dict) else None + if not isinstance(sf, list): + return False + for entry in sf: + if not isinstance(entry, dict): + continue + perms = entry.get("permissions") + if isinstance(perms, list) and len(perms) > 0: + return True + return False + needs_enterprise_data = ( not project["options"]["dry_run"] and not project["options"]["sample_data"] - and (_has_perms("shared_folder_users") or _has_perms("shared_folder_resources")) + and (_has_perms("shared_folder_users") + or _has_perms("shared_folder_resources") + or _has_safe_folder_perms()) ) if not params.enterprise and needs_enterprise_data: try: @@ -254,7 +272,10 @@ def process_folders(self, params, project: dict) -> dict: "resources_folder": f"""{project["options"]["project_name"]} - Resources""", "resources_folder_uid": "", "users_folder": f"""{project["options"]["project_name"]} - Users""", - "users_folder_uid": "" + "users_folder_uid": "", + # CyberArk --folder-mode safe: per-safe folder UID lookup by folder_path. + "safe_folder_map": {}, + "safe_folders": [], } # Project structure: @@ -263,6 +284,13 @@ def process_folders(self, params, project: dict) -> dict: # if project["data"].get("tool_version", "") != "": # CLI generated export else: # Manually generated import file + data = project["data"] if isinstance(project.get("data"), dict) else {} + safe_folders_def = data.get("safe_folders") if isinstance(data, dict) else None + use_safe_layout = ( + isinstance(safe_folders_def, list) + and any(isinstance(x, dict) and x.get("name") for x in safe_folders_def) + ) + # FolderListCommand().execute(params, folders_only=True, pattern="/") use_nsf = project["options"].get("use_nsf", False) is True allowed_types = self._pam_folder_types(use_nsf) @@ -305,24 +333,27 @@ def process_folders(self, params, project: dict) -> dict: res["project_folder_uid"] = fuid puid = res["project_folder_uid"] - sfn = project["data"].get("shared_folder_resources", None) - fname = sfn["folder_name"] if isinstance(sfn, dict) and isinstance(sfn.get("folder_name", None), str) else "" - fname = fname.strip() or f"""{res["project_folder"]} - Resources""" - fperm, rperm = self.get_folder_permissions(users_folder=False, data=project["data"]) - fuid = self.create_subfolder(params, folder_name=fname, parent_uid=puid, permissions=fperm, use_nsf=use_nsf) - res["resources_folder_uid"] = fuid - - sfn = project["data"].get("shared_folder_users", None) - fname = sfn["folder_name"] if isinstance(sfn, dict) and isinstance(sfn.get("folder_name", None), str) else "" - fname = fname.strip() or f"""{res["project_folder"]} - Users""" - fperm, uperm = self.get_folder_permissions(users_folder=True, data=project["data"]) - fuid = self.create_subfolder(params, folder_name=fname, parent_uid=puid, permissions=fperm, use_nsf=use_nsf) - res["users_folder_uid"] = fuid - - # add users and teams - self.verify_users_and_teams(params, rperm + uperm) - self.add_folder_permissions(params, res["resources_folder_uid"], rperm) - self.add_folder_permissions(params, res["users_folder_uid"], uperm) + if use_safe_layout: + self._create_safe_folders(params, project, puid, res, safe_folders_def) + else: + sfn = project["data"].get("shared_folder_resources", None) + fname = sfn["folder_name"] if isinstance(sfn, dict) and isinstance(sfn.get("folder_name", None), str) else "" + fname = fname.strip() or f"""{res["project_folder"]} - Resources""" + fperm, rperm = self.get_folder_permissions(users_folder=False, data=project["data"]) + fuid = self.create_subfolder(params, folder_name=fname, parent_uid=puid, permissions=fperm, use_nsf=use_nsf) + res["resources_folder_uid"] = fuid + + sfn = project["data"].get("shared_folder_users", None) + fname = sfn["folder_name"] if isinstance(sfn, dict) and isinstance(sfn.get("folder_name", None), str) else "" + fname = fname.strip() or f"""{res["project_folder"]} - Users""" + fperm, uperm = self.get_folder_permissions(users_folder=True, data=project["data"]) + fuid = self.create_subfolder(params, folder_name=fname, parent_uid=puid, permissions=fperm, use_nsf=use_nsf) + res["users_folder_uid"] = fuid + + # add users and teams + self.verify_users_and_teams(params, rperm + uperm) + self.add_folder_permissions(params, res["resources_folder_uid"], rperm) + self.add_folder_permissions(params, res["users_folder_uid"], uperm) break if project["options"].get("dry_run", False) is True: @@ -331,6 +362,14 @@ def process_folders(self, params, project: dict) -> dict: else: print(f"""Will create new {"NSF " if use_nsf else ""}PAM root folder: {res["root_folder_target"]}""") print(f"""Will create new {"NSF " if use_nsf else ""}Project folder: {res["project_folder"]}""") + if use_safe_layout: + safe_names = [str(x.get("name") or "").strip() + for x in safe_folders_def if isinstance(x, dict)] + safe_names = [n for n in safe_names if n] + print(f"Will create {len(safe_names) + 1} shared folders under project " + f"(one per safe + 1 admin Config folder)") + for n in safe_names: + print(f" • {n}") else: if use_nsf: from .nsf_helpers import sync_down_preserving_nsf_keys @@ -340,6 +379,137 @@ def process_folders(self, params, project: dict) -> dict: return res + def _create_safe_folders(self, params, project: dict, project_folder_uid: str, + res: dict, safe_folders_def: list) -> None: + """Create per-safe shared folders and the admin-only Config folder. + + Fills ``res["safe_folder_map"]`` so process_data can route by ``folder_path``. + Config folder UID is also stored in the legacy resources/users slots. + """ + safe_folder_map: dict = {} + safe_folder_records: list = [] + use_nsf = project["options"].get("use_nsf", False) is True + + # Default folder-level permissions for safe folders. + default_fperm = { + "manage_users": True, + "manage_records": True, + "can_edit": True, + "can_share": True, + } + + # Collect all user/team permission entries across safes so we can + # verify them up-front in a single batch (process_data does the + # same for legacy mode). + all_user_perms: list = [] + for entry in safe_folders_def: + if not isinstance(entry, dict): + continue + folder_name = str(entry.get("name") or "").strip() + if not folder_name: + continue + fperm = dict(default_fperm) + for key in ("manage_users", "manage_records", "can_edit", "can_share"): + if key in entry: + fperm[key] = bool(entry.get(key)) + + uperm: list = [] + perm_list = entry.get("permissions") if isinstance(entry.get("permissions"), list) else [] + for item in perm_list: + if not isinstance(item, dict): + continue + uid_ = item.get("uid", None) + name_ = item.get("name", None) + if uid_ is None and name_ is None: + logging.warning( + "Safe folder '%s' permission entry missing both uid and name (skipped)", + folder_name, + ) + continue + uperm.append({ + "uid": uid_, + "name": name_, + "manage_users": True if str(item.get("manage_users", False)).upper() == "TRUE" else False, + "manage_records": True if str(item.get("manage_records", False)).upper() == "TRUE" else False, + }) + all_user_perms.extend(uperm) + safe_folder_records.append({ + "name": folder_name, + "safe_name": str(entry.get("safe_name") or folder_name), + "fperm": fperm, + "uperm": uperm, + }) + + # Admin-only "Config" folder that holds the PAM Configuration v6 + # record. Cannot live in any safe folder, or the safe's members + # would gain access to the central config record. + config_folder_name = f"""{res["project_folder"]} - Config""" + config_uid = self.create_subfolder( + params, folder_name=config_folder_name, + parent_uid=project_folder_uid, permissions=dict(default_fperm), + use_nsf=use_nsf, + ) + res["resources_folder"] = config_folder_name + res["users_folder"] = config_folder_name + res["resources_folder_uid"] = config_uid + res["users_folder_uid"] = config_uid + res["config_folder_uid"] = config_uid + res["config_folder"] = config_folder_name + + # Verify principals once (avoids one round-trip per safe). + if all_user_perms: + self.verify_users_and_teams(params, all_user_perms) + + # Create one shared folder per safe under the project wrapper and + # apply its specific permission set. Inside each safe folder, + # create two organizational subfolders named + # ``{safe} - Resources`` (for the asset records) and + # ``{safe} - Users`` (for the credential records) so the + # imported records mirror the legacy two-folder split, but with + # the access boundary still drawn at the per-safe level. The + # subfolders inherit the safe's permission set automatically + # because they're ``shared_folder_folder`` children — we don't + # need to re-attach permissions per subfolder. Prefixing the + # subfolder names with the safe name keeps them + # self-identifying in the Keeper UI even when listed flat. + for record in safe_folder_records: + folder_uid = self.create_subfolder( + params, folder_name=record["name"], + parent_uid=project_folder_uid, permissions=record["fperm"], + use_nsf=use_nsf, + ) + # Top-level lookup key (no slash) maps to the safe folder + # itself for callers that still emit ``folder_path = ""`` + # (e.g. older versions of the importer or hand-written JSON). + safe_folder_map[record["name"]] = folder_uid + + res_sub_name = f"{record['name']} - Resources" + usr_sub_name = f"{record['name']} - Users" + res_sub_uid = self.create_subfolder( + params, folder_name=res_sub_name, parent_uid=folder_uid, + use_nsf=use_nsf, + ) + usr_sub_uid = self.create_subfolder( + params, folder_name=usr_sub_name, parent_uid=folder_uid, + use_nsf=use_nsf, + ) + safe_folder_map[f"{record['name']}/{res_sub_name}"] = res_sub_uid + safe_folder_map[f"{record['name']}/{usr_sub_name}"] = usr_sub_uid + + res["safe_folders"].append({ + "name": record["name"], + "safe_name": record["safe_name"], + "uid": folder_uid, + "resources_subfolder": res_sub_name, + "resources_subfolder_uid": res_sub_uid, + "users_subfolder": usr_sub_name, + "users_subfolder_uid": usr_sub_uid, + }) + if record["uperm"]: + self.add_folder_permissions(params, folder_uid, record["uperm"]) + + res["safe_folder_map"] = safe_folder_map + def process_ksm_app(self, params, project: dict) -> dict: res = { "app_name_target": "", @@ -382,13 +552,36 @@ def process_ksm_app(self, params, project: dict) -> dict: if preserved is not None: # create_ksm_app sync_down clears NSF caches; restore before NSF secret share. restore_nsf_folder_keys(params, preserved) - for sf_uid in [project["folders"].get("resources_folder_uid", ""), - project["folders"].get("users_folder_uid", "")]: - if sf_uid.strip(): - KSMCommand().execute(params, - command=("secret", "add"), - app=res["app_uid"], - secret=[sf_uid], editable=True) + + # The KSM app must have access to every shared folder containing + # PAM records. Legacy mode emits exactly two folders (Resources + + # Users). Safe-per-folder mode emits one folder per CyberArk safe + # plus the admin Config folder; collect them all here (dedup'd in + # case the same UID surfaces twice — e.g. config == resources in + # safe mode where resources_folder_uid mirrors the config folder). + sf_uids_to_grant = [] + seen_sf_uids = set() + + def _add_sf(sf_uid: str): + sf_uid = (sf_uid or "").strip() + if not sf_uid or sf_uid in seen_sf_uids: + return + seen_sf_uids.add(sf_uid) + sf_uids_to_grant.append(sf_uid) + + folders = project["folders"] + _add_sf(folders.get("resources_folder_uid", "")) + _add_sf(folders.get("users_folder_uid", "")) + _add_sf(folders.get("config_folder_uid", "")) + for entry in folders.get("safe_folders", []) or []: + if isinstance(entry, dict): + _add_sf(entry.get("uid", "")) + + for sf_uid in sf_uids_to_grant: + KSMCommand().execute(params, + command=("secret", "add"), + app=res["app_uid"], + secret=[sf_uid], editable=True) if use_nsf or any(is_nested_share_folder(params, uid) for uid in (project["folders"].get("resources_folder_uid", ""), @@ -600,7 +793,21 @@ def process_pam_config(self, params, project: dict) -> dict: if pce.oci_tenancy: args["oci_tenancy"] = pce.oci_tenancy if pce.oci_region: args["oci_region"] = pce.oci_region - if pce.default_rotation_schedule: args["default_schedule"] = pce.default_rotation_schedule + # `default_schedule` for PAMConfigurationNewCommand is a CRON string (or absent for + # on-demand). PamConfigEnvironment normalizes it into a dict ({"type": "ON_DEMAND"} or + # {"type": "CRON", "cron": "...", "tz": "..."}), so unpack back into the expected form + # — passing the dict through causes AttributeError: 'dict' object has no attribute 'strip' + # inside validate_cron_expression(). + sched = pce.default_rotation_schedule + if isinstance(sched, dict): + sched_type = str(sched.get("type", "")).lower().replace("_", "-") + if sched_type == "cron": + cron_expr = str(sched.get("cron", "") or "").strip() + if cron_expr: + args["default_schedule"] = cron_expr + # on-demand / ON_DEMAND → omit args["default_schedule"] → server defaults to On-Demand + elif isinstance(sched, str) and sched.strip(): + args["default_schedule"] = sched.strip() res["pam_config_uid"] = PAMConfigurationNewCommand().execute(params, **args) users_folder_uid = project["folders"].get("users_folder_uid", "") @@ -1014,6 +1221,17 @@ def process_data(self, params, project): shfres = project["folders"].get("resources_folder_uid", "") shfusr = project["folders"].get("users_folder_uid", "") + # Per-safe folder routing (CyberArk --folder-mode safe); empty for legacy layout. + safe_folder_map = project["folders"].get("safe_folder_map") or {} + + def _resolve_folder_uid(obj, default_uid: str) -> str: + if not safe_folder_map: + return default_uid + fp = getattr(obj, "folder_path", None) or "" + fp = fp.strip() if isinstance(fp, str) else "" + if not fp: + return default_uid + return safe_folder_map.get(fp, default_uid) usrs = pam_data["users"] if "users" in pam_data and isinstance(pam_data["users"], list) else [] rsrs = pam_data["resources"] if "resources" in pam_data and isinstance(pam_data["resources"], list) else [] @@ -1270,7 +1488,7 @@ def process_data(self, params, project): if users: logging.warning(f"Processing external users: {len(users)}") for n, user in enumerate(users): # standalone users - user.create_record(params, shfusr) + user.create_record(params, _resolve_folder_uid(user, shfusr)) if n % pdelta == 0: print(f"{n}/{len(users)}") print(f"{len(users)}/{len(users)}\n") @@ -1282,7 +1500,8 @@ def process_data(self, params, project): # Machine - create machine first to avoid error: # Resource does not belong to the configuration admin_uid = get_admin_credential(mach, True) - mach.create_record(params, shfres) + mach_folder_uid = _resolve_folder_uid(mach, shfres) + mach.create_record(params, mach_folder_uid) tdag.link_resource_to_config(mach.uid) if isinstance(mach, PamRemoteBrowserObject): # RBI args = parse_command_options(mach, True) @@ -1350,7 +1569,11 @@ def process_data(self, params, project): if (isinstance(user, PamUserObject) and user.rotation_settings and user.rotation_settings.rotation.lower() == "general"): user.rotation_settings.resourceUid = mach.uid # DAG only - user.create_record(params, shfusr) + # Nested users default to the same safe folder as their + # owning machine so safe-level permissions apply uniformly + # to every record originating from that CyberArk safe. + user_folder_uid = _resolve_folder_uid(user, mach_folder_uid) + user.create_record(params, user_folder_uid) if isinstance(user, PamUserObject): # rotation setup tdag.link_user_to_resource(user.uid, mach.uid, admin_uid==user.uid, True) if user.rotation_settings: @@ -1359,12 +1582,19 @@ def process_data(self, params, project): key = {"on": "enable", "off": "disable"}.get(enabled, "") if key: args[key] = True # args["schedule_config"] = True # Schedule from Configuration + # Schedule type is case-insensitive; CyberArk + # importer emits "CRON" (uppercase) per Keeper's + # on-the-wire convention, while older import paths + # use "cron" (lowercase). Compare in lowercase so + # both shapes are honored. schedule_type = user.rotation_settings.schedule.type if user.rotation_settings.schedule and user.rotation_settings.schedule.type else "" - if schedule_type == "on-demand": + schedule_type_lc = (schedule_type or "").lower().replace("_", "-") + if schedule_type_lc == "on-demand": args["on_demand"] = True - elif schedule_type == "cron": + elif schedule_type_lc == "cron": if user.rotation_settings.schedule.cron: - args["schedule_cron_data"] = user.rotation_settings.schedule.cron + # Must be a list for parse_schedule_data (CLI uses action=append). + args["schedule_cron_data"] = [user.rotation_settings.schedule.cron] else: logging.warning(f"{bcolors.WARNING}schedule.type=cron but schedule.cron is empty (skipped){bcolors.ENDC} ") if user.rotation_settings.password_complexity: diff --git a/keepercommander/commands/pam_import/extend.py b/keepercommander/commands/pam_import/extend.py index b0a10b175..b2dfa371f 100644 --- a/keepercommander/commands/pam_import/extend.py +++ b/keepercommander/commands/pam_import/extend.py @@ -1486,12 +1486,15 @@ def process_data(self, params, project): key = {"on": "enable", "off": "disable"}.get(enabled, "") if key: args[key] = True + # Schedule type comparison is case-insensitive; + # Case-insensitive schedule type; cron value must be a list. schedule = getattr(rs, "schedule", None) schedule_type = getattr(schedule, "type", "") if schedule else "" - if schedule_type == "on-demand": + schedule_type_lc = (schedule_type or "").lower().replace("_", "-") + if schedule_type_lc == "on-demand": args["on_demand"] = True - elif schedule_type == "cron" and schedule and getattr(schedule, "cron", None): - args["schedule_cron_data"] = rs.schedule.cron + elif schedule_type_lc == "cron" and schedule and getattr(schedule, "cron", None): + args["schedule_cron_data"] = [rs.schedule.cron] if getattr(rs, "password_complexity", None): args["pwd_complexity"] = rs.password_complexity prc.execute(params, silent=True, **args) @@ -1521,12 +1524,16 @@ def process_data(self, params, project): key = {"on": "enable", "off": "disable"}.get(enabled, "") if key: args[key] = True + # See note on the new-resources branch above for + # why this comparison is lowercase and why + # ``schedule_cron_data`` is wrapped in a list. schedule = getattr(rs, "schedule", None) schedule_type = getattr(schedule, "type", "") if schedule else "" - if schedule_type == "on-demand": + schedule_type_lc = (schedule_type or "").lower().replace("_", "-") + if schedule_type_lc == "on-demand": args["on_demand"] = True - elif schedule_type == "cron" and schedule and getattr(schedule, "cron", None): - args["schedule_cron_data"] = rs.schedule.cron + elif schedule_type_lc == "cron" and schedule and getattr(schedule, "cron", None): + args["schedule_cron_data"] = [rs.schedule.cron] if getattr(rs, "password_complexity", None): args["pwd_complexity"] = rs.password_complexity prc.execute(params, silent=True, **args) diff --git a/keepercommander/importer/cyberark/cyberark_pam.py b/keepercommander/importer/cyberark/cyberark_pam.py new file mode 100644 index 000000000..63d08dbd1 --- /dev/null +++ b/keepercommander/importer/cyberark/cyberark_pam.py @@ -0,0 +1,30 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Dict[str, Any]: + """Flatten the platform rotation-policy payload to a single dict. + + CyberArk has been observed to return the policy in two shapes: + + - flat: ``{"rotateEveryXDays": 30, "rotateAutomatically": true}`` + - grouped: ``{"credentialsManagement": {"rotateEveryXDays": 30, + ...}, "rotationPolicy": {...}}`` + + The grouped shape mirrors ``Get-Platforms``. We look one level deep + so leaf settings end up in a single key/value table the resolver + below can search with the ``_PLATFORM_*_KEYS`` aliases. + """ + flat: Dict[str, Any] = {} + if not isinstance(data, dict): + return flat + for k, v in data.items(): + if isinstance(v, dict): + # CyberArk Self-Hosted /Policies/N format keeps {"Value": x} + if "Value" in v and len(v) == 1: + flat.setdefault(k, v["Value"]) + continue + for subk, subv in v.items(): + if isinstance(subv, dict) and "Value" in subv: + flat.setdefault(subk, subv["Value"]) + elif isinstance(subv, (bool, int, float, str)): + flat.setdefault(subk, subv) + elif isinstance(v, (bool, int, float, str)): + flat.setdefault(k, v) + return flat + + # Boolean flags CyberArk has been observed (or documented via platform + # exception audit tooling) to use for "this platform's change-interval + # deviates from the Master Policy default": ``overridesMasterPolicy`` + # (ISPSS rotation-policy), ``isException`` / ``IsAnException`` / + # ``IsRequirePasswordEveryXDaysAnException`` (legacy Get-Platforms / + # Get-Platform-Details), ``changeFrequencyIsException`` / + # ``changeIntervalIsException`` (observed on some tenants' bulk + # Get-Platforms responses). + _OVERRIDE_FLAG_KEYS = ( + "overridesMasterPolicy", "OverridesMasterPolicy", + "isException", "IsException", "isAnException", "IsAnException", + "isRequirePasswordEveryXDaysAnException", + "IsRequirePasswordEveryXDaysAnException", + "changeFrequencyIsException", "changeIntervalIsException", + "ChangeIntervalIsException", + ) + + @staticmethod + def _schedule_from_change_block(change: dict) -> Optional[dict]: + """Translate ISPSS ``change`` group → Keeper ``schedule`` dict. + + Mirrors how the Master Policy itself is converted + (``MasterPolicyMapper.days_to_cron``) — the interval value alone + drives the cadence. CyberArk's ``allowedPeriodic`` / + ``performPeriodicChange`` bookkeeping flag is informational (it + reflects whether *CyberArk's* CPM auto-triggers the change) and is + intentionally NOT used to force ``on-demand`` here: an admin who + set an explicit platform exception (e.g. 30 days instead of the + master's 90) wants Keeper to rotate on that cadence, the same way + the Master Policy default itself is always applied as a CRON + schedule regardless of any periodic flag. + """ + if not isinstance(change, dict): + return None + try: + days = int(change.get("interval") or 0) + except (TypeError, ValueError): + days = 0 + return MasterPolicyMapper.change_settings_to_schedule(days) + + @classmethod + def _override_flag(cls, change: dict) -> Optional[bool]: + """Return the first recognized override/exception flag, if any.""" + for key in cls._OVERRIDE_FLAG_KEYS: + if key in change: + return bool(change[key]) + return None + + def _resolve_platform_schedule(self, platform_id: str) -> Optional[dict]: + """Return Keeper ``schedule`` dict for ``platform_id`` (cached). + + Calls ``/api/platforms/{platformId}/rotation-policy/`` once per + platform and translates the response into a Quartz CRON via + ``MasterPolicyMapper.days_to_cron``. Returns ``None`` when the + platform has no custom policy — callers fall back to the master + policy default schedule. + + Honors CyberArk's cascade: + + 1. **Per-platform with override** — a recognized exception flag + (``overridesMasterPolicy``, ``isException``, etc.) is true, OR + no flag is present but the interval differs from the Master + Policy's own interval → use ``change.interval`` verbatim, + converted to CRON exactly like the Master Policy default is + (``allowedPeriodic`` does NOT force on-demand — see + ``_schedule_from_change_block``). + 2. **Master-policy exception** — platform listed in + ``/api/platforms/master-rotation-policy/exceptions/`` with a + custom change interval (common for Win Local Admins and other + platforms that inherit master but have a Master Policy + exception) — used when the per-platform flag says "no + override" but this separate bulk endpoint disagrees. + 3. **Per-platform without override** — + no exception detected by (1) or (2): inherit the master + policy's own schedule (``None`` → caller applies + ``default_rotation_schedule``). + 4. **Legacy flat shapes** — older PVWA responses with + ``rotateEveryXDays`` / ``passwordChangeDays`` at top level. + """ + if not platform_id or not self._client: + return None + if platform_id in self._platform_schedule_cache: + return self._platform_schedule_cache[platform_id] + + raw = self._client.fetch_platform_rotation_policy(platform_id) + if not raw: + msg = ( + f"Platform '{platform_id}' rotation-policy not accessible — " + "inheriting master policy default." + ) + if self.strict_policies: + logging.error(msg) + raise StrictPolicyError(msg) + logging.info(msg) + self._platform_schedule_cache[platform_id] = None + return None + + # ── New ISPSS shape ──────────────────────────────────── + change = raw.get("change") if isinstance(raw.get("change"), dict) else None + if change is not None: + try: + interval_days = int(change.get("interval") or 0) + except (TypeError, ValueError): + interval_days = 0 + overrides = self._override_flag(change) + # Fallback signal when no recognized flag is present: if the + # platform's own interval differs from the Master Policy's + # configured interval, CyberArk is evidently applying a + # platform-specific value regardless of what this tenant calls + # the flag — honor the number it actually returned. + interval_mismatch = ( + overrides is None + and self._master_change_days > 0 + and interval_days > 0 + and interval_days != self._master_change_days + ) + logging.info( + "Platform '%s' rotation-policy: change.interval=%s " + "allowedPeriodic=%s override_flag=%s master_days=%s%s", + platform_id, interval_days, change.get("allowedPeriodic"), + overrides, self._master_change_days or "?", + " (interval differs from master -> treating as exception)" + if interval_mismatch else "", + ) + + if overrides is True or interval_mismatch: + schedule = self._schedule_from_change_block(change) + self._platform_schedule_cache[platform_id] = schedule + if schedule: + logging.info( + "Platform '%s' rotation policy override -> %s", + platform_id, schedule, + ) + return schedule + + if overrides is False: + exc = self._master_exception_schedules.get(platform_id) + if exc is not None: + schedule = copy.deepcopy(exc) + self._platform_schedule_cache[platform_id] = schedule + logging.info( + "Platform '%s' rotation: master-policy exception -> %s", + platform_id, schedule, + ) + return schedule + # No override, no separate master exception on record — + # this platform's cadence matches the Master Policy default. + # Inherit it (the caller applies ``default_rotation_schedule``, + # itself a CRON built from the same master interval), rather + # than forcing on-demand based on the informational + # ``allowedPeriodic`` flag. + logging.info( + "Platform '%s' rotation policy: no override detected " + "(inheriting master policy)", platform_id, + ) + self._platform_schedule_cache[platform_id] = None + return None + + # overrides is None — this tenant's rotation-policy response + # doesn't carry any recognized exception flag at all. When we + # know the Master Policy's own interval, use it as the source + # of truth: a matching interval means no exception exists (the + # platform is simply mirroring master); a differing interval + # is already handled above via ``interval_mismatch``. + if self._master_change_days > 0: + logging.info( + "Platform '%s' rotation policy: interval matches master " + "(%d days) — inheriting master policy", platform_id, + self._master_change_days, + ) + self._platform_schedule_cache[platform_id] = None + return None + + # No flag and no master baseline to compare against — fall back + # to treating the platform's own ``change`` block as authoritative + # (legacy behavior for tenants where this is the only info we get). + schedule = self._schedule_from_change_block(change) + self._platform_schedule_cache[platform_id] = schedule + if schedule: + logging.info( + "Platform '%s' rotation policy: %s", platform_id, schedule, + ) + return schedule + + # No ``change`` block — check master-policy exceptions before legacy. + exc = self._master_exception_schedules.get(platform_id) + if exc is not None: + schedule = copy.deepcopy(exc) + self._platform_schedule_cache[platform_id] = schedule + logging.info( + "Platform '%s' rotation: master-policy exception -> %s", + platform_id, schedule, + ) + return schedule + + # ── Legacy flat shapes (PVWA Self-Hosted, Get-Platforms) ─── + flat = self._flatten_rotation_policy(raw) + days = 0 + for key in self._PLATFORM_ROTATE_DAYS_KEYS: + if key in flat: + try: + days = int(flat[key] or 0) + except (TypeError, ValueError): + days = 0 + break + + auto_rotate: Optional[bool] = None + for key in self._PLATFORM_ROTATE_AUTO_KEYS: + if key in flat: + auto_rotate = bool(flat[key]) + break + + if auto_rotate is False: + schedule = {"type": SCHEDULE_ON_DEMAND} + else: + cron = MasterPolicyMapper.days_to_cron(days) + schedule = {"type": "CRON", "cron": cron} if cron else None + + self._platform_schedule_cache[platform_id] = schedule + if schedule: + logging.debug( + "Platform '%s' rotation policy: every %d day(s) -> %s", + platform_id, days, schedule, + ) + return schedule + + def _resolve_platform_password_complexity(self, platform_id: str) -> Optional[str]: + """Translate ``passwordGenRules`` into Keeper's password_complexity. + + CyberArk's ``/api/platforms/{id}/secrets-policy/`` endpoint exposes + password generation under a ``passwordGenRules`` block, e.g.:: + + { + "passwordGenRules": { + "passwordLen": 12, "minUppercase": 2, "minLowercase": 2, + "minDigit": 1, "minSpecial": 1 + }, + ... + } + + Keeper PAM's ``rotation_settings.password_complexity`` accepts a + comma-separated string ``"length,upper,lower,digits,symbols"`` + (matching the format generated by the legacy import path). We + cache the result per-platform so subsequent accounts under the + same platform reuse it without another API call. + + Returns ``None`` when: + - ``passwordGenRules`` is absent / not a dict, or + - ``passwordLen`` is missing / non-positive (Keeper requires a + length to honor the complexity string). + """ + if not platform_id or not self._client: + return None + if platform_id in self._platform_complexity_cache: + return self._platform_complexity_cache[platform_id] + + raw = self._client.fetch_platform_secrets_policy(platform_id) + if not isinstance(raw, dict): + self._platform_complexity_cache[platform_id] = None + return None + + rules = raw.get("passwordGenRules") + if not isinstance(rules, dict): + self._platform_complexity_cache[platform_id] = None + return None + + try: + length = int(rules.get("passwordLen") or 0) + upper = int(rules.get("minUppercase") or 0) + lower = int(rules.get("minLowercase") or 0) + digits = int(rules.get("minDigit") or 0) + symbols = int(rules.get("minSpecial") or 0) + except (TypeError, ValueError): + self._platform_complexity_cache[platform_id] = None + return None + + if length <= 0: + self._platform_complexity_cache[platform_id] = None + return None + + complexity = f"{length},{upper},{lower},{digits},{symbols}" + self._platform_complexity_cache[platform_id] = complexity + logging.debug("Platform '%s' password complexity: %s", platform_id, complexity) + return complexity + + def _resolve_platform_metadata(self, platform_id: str) -> List[dict]: + """Return CyberArk operational metadata as Keeper custom fields. + + Translates fields like ``rotationNoticePeriod``, + ``headstartInterval``, ``maxRetries`` from the + ``/api/platforms/{id}/rotation-policy/`` and + ``/api/platforms/{id}/workflows-policy/`` responses into the + ``custom = [{type, label, value}, ...]`` list shape that Keeper + PAM resource records support. Returns ``[]`` when the platform + has no usable metadata. Each entry is ready to ``extend()`` onto + ``resource["custom"]`` in ``map_account``. + + Cached per platformId so we only compute the field list once. + """ + if not platform_id or not self._client: + return [] + if platform_id in self._platform_metadata_cache: + return self._platform_metadata_cache[platform_id] + + out: List[dict] = [] + + def _add(label: str, val): + if val in (None, "", [], {}): + return + if len(out) >= MAX_PLATFORM_METADATA_FIELDS: + return + text = str(val) + if len(text) > MAX_PLATFORM_METADATA_VALUE_LEN: + text = text[:MAX_PLATFORM_METADATA_VALUE_LEN] + # Strip control chars that could corrupt record payloads. + text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text) + out.append({ + "type": "text", "label": label[:120], "value": [text], + }) + + rotation_raw = self._client.fetch_platform_rotation_policy(platform_id) + if isinstance(rotation_raw, dict): + change = rotation_raw.get("change") if isinstance(rotation_raw.get("change"), dict) else {} + verify = rotation_raw.get("verify") if isinstance(rotation_raw.get("verify"), dict) else {} + + # Rotation cadence operational settings — preserved on the + # Keeper record so a user can reproduce them in CyberArk. + _add("CyberArk Rotation Interval (days)", change.get("interval")) + if change.get("allowedPeriodic") is not None: + _add("CyberArk Rotation Allowed Periodic", + "Yes" if change.get("allowedPeriodic") else "No") + _add("CyberArk Rotation Headstart (days)", change.get("headstartInterval")) + _add("CyberArk Rotation Notice Period (min)", + rotation_raw.get("rotationNoticePeriod")) + if rotation_raw.get("enableRotationNoticePeriod") is not None: + _add("CyberArk Rotation Notice Enabled", + "Yes" if rotation_raw.get("enableRotationNoticePeriod") else "No") + _add("CyberArk Rotation Max Retries", rotation_raw.get("maxRetries")) + _add("CyberArk Rotation Retry Delay (min)", + rotation_raw.get("minDelayBetweenRetries")) + _add("CyberArk Verify Interval (days)", verify.get("interval")) + if verify.get("allowedPeriodic") is not None: + _add("CyberArk Verify Allowed Periodic", + "Yes" if verify.get("allowedPeriodic") else "No") + if rotation_raw.get("timezone"): + _add("CyberArk Rotation Timezone", rotation_raw.get("timezone")) + if rotation_raw.get("origin"): + _add("CyberArk Platform Origin", rotation_raw.get("origin")) + + # Workflows policy carries minimum-validity-period style settings. + wf_raw = self._client.fetch_platform_workflows_policy(platform_id) + if isinstance(wf_raw, dict): + if "minValidityPeriod" in wf_raw and wf_raw.get("minValidityPeriod") not in (None, ""): + _add("CyberArk Min Validity Period (min)", + wf_raw.get("minValidityPeriod")) + if wf_raw.get("unlockIfFail") is not None: + _add("CyberArk Unlock If Fail", + "Yes" if wf_raw.get("unlockIfFail") else "No") + + self._platform_metadata_cache[platform_id] = out + if out: + logging.debug("Platform '%s' metadata captured: %d custom field(s)", + platform_id, len(out)) + return out + + def _resolve_platform_session_recording( + self, platform_id: str) -> Optional[Tuple[str, str]]: + """Return ``("on"|"off", "on"|"off")`` for (graphical, text) recording. + + Probes the per-platform endpoints in order (secrets-policy → + platform details → legacy .asmx). When the platform exposes a + ``recordAndSaveSessionActivity`` rule we honor it; that single + boolean drives both Keeper recording flags because CyberArk does + not split graphical vs. text. Returns ``None`` when no endpoint + answered with usable data — the resource then inherits whatever + the master policy / Keeper default decides. + """ + if not platform_id or not self._client: + return None + if platform_id in self._platform_session_cache: + return self._platform_session_cache[platform_id] + + # Session-recording flags can live in any of three CyberArk + # surfaces depending on tenant type — none of them is a single + # source of truth, so we query and merge: + # + # 1. ``/PasswordVault/API/Platforms/{id}`` — Get-Platform-Details. + # On older PVWA Self-Hosted this carries the + # ``sessionManagement.recordAndSaveSessionActivity`` boolean. + # On Privilege Cloud / ISPSS this DOES NOT include the + # session-recording flag (only the PSMServer reference and + # connection components), but we still pull + # ``PSMServerId``/``PSMServerName`` from here as a fallback + # indicator (PSM-attached platforms record by default). + # 2. ``/PasswordVault/services/PoliciesMgt.asmx/ + # GetPolicyRulesSessionMonitoring`` — the PVWA admin-UI grid + # service. On Privilege Cloud this is the *canonical* place + # where ``RecordSession`` / ``MonitorSession`` rules are + # surfaced (the user explicitly pointed us here). + # 3. PSMServer presence — heuristic fallback when neither (1) + # nor (2) has the explicit flag: a platform that has a PSM + # ConnectionComponent (``PSM-RDP``, ``PSM-SSH``, ...) + # records sessions by default in CyberArk. + if platform_id not in self._details_cache: + self._details_cache[platform_id] = self._client.fetch_platform_details(platform_id) + details = self._details_cache.get(platform_id) + asmx = None + if hasattr(self._client, 'fetch_platform_session_monitoring'): + asmx = self._client.fetch_platform_session_monitoring(platform_id) + + if not details and not asmx: + self._platform_session_cache[platform_id] = None + return None + + # ── Build a flat ``rules`` dict from every envelope we got ──── + rules: Dict[str, Any] = {} + for raw in (details, asmx): + if not isinstance(raw, dict) or not raw: + continue + rules.update(MasterPolicyMapper.normalize(raw)) + rules.update(flatten_asmx_grid(raw)) + + # Walk every known PVWA / Privilege Cloud envelope shape and + # all of its nested groups (sessionManagement, + # credentialsManagement, privilegedAccessWorkflows, ...). + envelopes: List[dict] = [] + for key in ("Platforms", "Platform", "Details"): + val = raw.get(key) + if isinstance(val, list) and val and isinstance(val[0], dict): + envelopes.append(val[0]) + elif isinstance(val, dict): + envelopes.append(val) + envelopes.append(raw) # top-level fallback + + for envelope in envelopes: + if not isinstance(envelope, dict): + continue + for grp in envelope.values(): + if isinstance(grp, dict): + for k, v in grp.items(): + if isinstance(v, (bool, int, float, str)): + rules.setdefault(k, v) + # Two-level walk for connection components and + # other deeply nested PVWA groups (e.g. + # ``Properties.Required[]``). + for nested in grp.values(): + if isinstance(nested, dict): + for k, v in nested.items(): + if isinstance(v, (bool, int, float, str)): + rules.setdefault(k, v) + + record_val: Optional[bool] = None + # 1) Explicit RecordSession / RecordAndSaveSessionActivity. + for k in PLATFORM_RECORD_KEYS: + if k in rules: + record_val = truthy(rules[k]) + break + # 2) PSM monitoring + isolation implies recording. + if record_val is None: + for k in PLATFORM_MONITOR_KEYS: + if k in rules and truthy(rules[k]): + record_val = True + break + # 3) Heuristic: PSMServer reference present → PSM is engaged + # and CyberArk records by default. Used only when neither + # (1) nor (2) provided an answer. + if record_val is None: + for k in ("PSMServerId", "PSMServerID", "psmServerId", + "PSMServerName", "psmServerName"): + v = rules.get(k) + if isinstance(v, str) and v.strip(): + logging.debug( + "Platform '%s' has PSMServer=%s — assuming " + "session recording is on (PSM default)", + platform_id, v, + ) + record_val = True + break + + if record_val is None: + self._platform_session_cache[platform_id] = None + return None + + flag = "on" if record_val else "off" + result = (flag, flag) + self._platform_session_cache[platform_id] = result + logging.debug( + "Platform '%s' session recording: graphical=%s text=%s", + platform_id, flag, flag, + ) + return result + + def _resolve_platform_workflows(self, platform_id: str) -> Optional[dict]: + """Return the platform's privileged-access workflows policy. + + Caches the response per-platform and emits one unmapped-item entry + for each *active* workflow rule (dual control, exclusive checkout, + one-time password access) the *first* time we see it on a given + platform. The accumulated list is exposed via + ``self.platform_workflow_unmapped`` for the import report. + """ + if not platform_id or not self._client: + return None + if platform_id in self._platform_workflows_cache: + return self._platform_workflows_cache[platform_id] + + # ISPSS workflows-policy endpoint (when present) carries + # minValidityPeriod / unlockIfFail style settings, but the actual + # dual-control / exclusive-checkout / one-time-password rules live + # in the platform's ``privilegedAccessWorkflows`` group on the + # generic ``/PasswordVault/API/Platforms/{id}`` endpoint. We query + # both and merge so we don't miss either source. + rules: Dict[str, Any] = {} + wf_raw = self._client.fetch_platform_workflows_policy(platform_id) + if isinstance(wf_raw, dict) and wf_raw: + rules.update(MasterPolicyMapper.normalize(wf_raw)) + + # Reuse the shared details_cache populated by port discovery / + # session-recording resolver so we issue at most one Platforms/{id} + # call per platform across the whole import. + if platform_id not in self._details_cache: + self._details_cache[platform_id] = self._client.fetch_platform_details(platform_id) + details_raw = self._details_cache.get(platform_id) + if isinstance(details_raw, dict) and details_raw: + for envelope_key in ("Platforms", "Platform", "Details"): + env = details_raw.get(envelope_key) + if isinstance(env, list) and env and isinstance(env[0], dict): + env = env[0] + if isinstance(env, dict): + for grp in env.values(): + if isinstance(grp, dict): + for k, v in grp.items(): + if isinstance(v, (bool, int, float, str)): + rules.setdefault(k, v) + for grp in details_raw.values(): + if isinstance(grp, dict): + for k, v in grp.items(): + if isinstance(v, (bool, int, float, str)): + rules.setdefault(k, v) + + if not rules: + self._platform_workflows_cache[platform_id] = None + return None + + # Translate first time we see each platform; later accounts on the + # same platform reuse the cache without re-emitting unmapped items. + if platform_id not in self._platform_workflows_seen: + self._platform_workflows_seen.add(platform_id) + for keys, label, action in ( + (PLATFORM_DUAL_CONTROL_KEYS, + "Dual control approval", + "Use ticketing integration (ServiceNow/Jira) or Keeper " + "Compliance approval workflows"), + (PLATFORM_EXCLUSIVE_KEYS, + "Exclusive checkout", + "Use time-limited record sharing in KeeperPAM"), + (PLATFORM_ONETIME_KEYS, + "One-time password access", + "Enable post-use rotation in KeeperPAM rotation settings"), + ): + for k in keys: + if k in rules and truthy(rules[k]): + self.platform_workflow_unmapped.append({ + "category": "Platform workflow", + "item": f"{label} (platform: {platform_id})", + "action": action, + }) + break + + self._platform_workflows_cache[platform_id] = rules + return rules + + def _resolve_from_platform_metadata(self, platform_id: str) -> Optional[dict]: + """Map a custom platformId via PVWA's PlatformBaseID / SystemType. + + Order: + 1. PlatformBaseID matches a built-in (e.g. WinDomain) — use that mapping. + 2. SystemType matches our SYSTEM_TYPE_MAP (e.g. Windows → RDP). + 3. None — caller falls through to keyword guessing. + """ + meta = self._platform_index.get(platform_id) + if not meta: + return None + base = meta.get("base_id") + if base and base in DEFAULT_PLATFORM_MAP: + return dict(DEFAULT_PLATFORM_MAP[base]) + system_type = (meta.get("system_type") or "").lower() + if system_type: + for key, mapping in _SYSTEM_TYPE_MAP.items(): + if key in system_type: + return dict(mapping) + return None + + @staticmethod + def _infer_operating_system(platform_id: str, + protocol: Optional[str]) -> Optional[str]: + """Derive ``operating_system`` for a pamMachine from platformId/protocol. + + Keyword scan on platformId (Win→windows, Unix/Linux→linux), then + protocol fallback (rdp→windows, ssh→linux). Returns ``None`` when + ambiguous so the field is left unset. + """ + pid = (platform_id or "").lower() + if pid: + if "win" in pid: + return "windows" + if any(tok in pid for tok in ("unix", "linux", "aix", "solaris", + "ubuntu", "redhat", "rhel", + "centos", "debian", "macos")): + return "linux" + proto = (protocol or "").lower() + if proto == "rdp": + return "windows" + if proto == "ssh": + # Network appliances also use SSH; only commit when the platform + # is unrecognized but clearly a host (not a database platform). + if pid and any(tok in pid for tok in ("network", "cisco", + "juniper", "paloalto", + "f5", "checkpoint", + "fortinet", "arista")): + return None + return "linux" if pid else None + return None + + def _enrich_port_from_details(self, platform_id: str, mapping: dict) -> dict: + """Overlay the platform's own default Port (from /Platforms/{id}) onto + a mapping dict, when available. Cached to avoid re-fetching.""" + if not platform_id or not self._client: + return mapping + if platform_id not in self._details_cache: + self._details_cache[platform_id] = self._client.fetch_platform_details(platform_id) + details = self._details_cache.get(platform_id) + if details: + port = _port_from_platform_details(details) + if port: + mapping = dict(mapping) + mapping["port"] = port + return mapping + + def map_account(self, account: dict, password: Optional[str] = None, + safe_name: str = "") -> Optional[dict]: + """Convert a CyberArk account dict → pam_data record dict. + + Returns None if the platformId is completely unknown and has no default. + """ + platform_id = account.get("platformId", "") + mapping = self.platform_map.get(platform_id) if platform_id else None + mapping_source = "platform-map" if mapping else None + + if mapping is None: + # Resolution order for unknown / customer-renamed platforms: + # 1. PVWA platform metadata — PlatformBaseID → DEFAULT_PLATFORM_MAP, + # or SystemType → _SYSTEM_TYPE_MAP. Authoritative. + # 2. Substring keyword match on platformId / name. + # 3. pamMachine/SSH fallback. + label = platform_id if platform_id else "(empty)" + self.unmapped_platforms[label] = self.unmapped_platforms.get(label, 0) + 1 + + via_pvwa = self._resolve_from_platform_metadata(platform_id) if platform_id else None + if via_pvwa: + mapping = via_pvwa + mapping_source = "pvwa-platform" + logging.warning( + "Unknown platformId '%s' for account '%s' — resolved via PVWA " + "platform metadata to %s/%s (port %s). Add it to --platform-map " + "to lock in.", + platform_id, account.get("name", ""), + mapping.get("record_type"), mapping.get("protocol") or "n/a", + mapping.get("port") or "n/a", + ) + else: + guessed = _guess_platform_mapping(platform_id, account.get("name", "")) + if guessed: + mapping = guessed + mapping_source = "keyword-guess" + if platform_id: + logging.warning( + "Unknown platformId '%s' for account '%s' — pattern-matched " + "to %s/%s (port %s). Add it to --platform-map to lock in.", + platform_id, account.get("name", ""), + mapping.get("record_type"), mapping.get("protocol") or "n/a", + mapping.get("port") or "n/a", + ) + else: + logging.debug( + "Empty platformId for account '%s' — pattern-matched to %s/%s.", + account.get("name", ""), + mapping.get("record_type"), mapping.get("protocol") or "n/a", + ) + else: + mapping = dict(FALLBACK_PLATFORM_MAP) + mapping_source = "fallback-ssh" + if platform_id: + logging.warning( + "Unknown platformId '%s' for account '%s' — defaulting to " + "pamMachine/SSH. Use --platform-map to override.", + platform_id, account.get("name", "")) + else: + logging.debug("Empty platformId for account '%s' — defaulting to pamMachine/SSH.", + account.get("name", "")) + + # When the mapping came from a fallback path we trust PVWA's + # per-platform Details endpoint over our static defaults for the + # port. Cheap with caching — one call per unique custom platform. + if mapping_source in ("pvwa-platform", "keyword-guess", "fallback-ssh"): + mapping = self._enrich_port_from_details(platform_id, mapping) + + record_type = mapping.get("record_type", RECORD_TYPE_PAM_MACHINE) + props = account.get("platformAccountProperties", {}) or {} + + # Extract fields + address = account.get("address", "") + user_name = account.get("userName", "") + # CyberArk occasionally stores "host:port" in `address`. Split so the + # host field stays clean; _extract_port() picks up the embedded port if + # platformAccountProperties has no explicit one. + address_host, _addr_port = _split_host_port(address) + if _addr_port: + address = address_host + + # Build title: strip CyberArk category and platform prefixes; when the + # resulting name is still long (e.g. the CPM policy name is embedded + # rather than the platformId), fall back to {address}-{userName}. + raw_name = account.get("name", "") + stripped = _CATEGORY_PREFIX_RE.sub("", raw_name) + if platform_id: + stripped = re.sub(rf"^.*{re.escape(platform_id)}[\-_ ]", "", stripped) + if len(stripped) > 40 and address and user_name: + title = f"{address}-{user_name}" + else: + title = stripped + logon_domain = props.get("LogonDomain", "") + login = f"{logon_domain}\\{user_name}" if logon_domain and user_name else user_name + url = props.get("URL", "") + item_name = props.get("ItemName", "") + port, port_source = _extract_port( + props, account.get("address", ""), mapping.get("port", "") or "", + ) + logging.debug( + "CyberArk account '%s' (platformId=%s) port=%s (source=%s)", + account.get("name", "?"), platform_id or "?", port or "(empty)", port_source, + ) + + if record_type == RECORD_TYPE_LOGIN: + # BusinessWebsite → login record (not pamMachine) + record = { + "type": RECORD_TYPE_LOGIN, + "title": item_name or title, + "login": login, + "password": password or "", + } + if url: + record["url"] = url + return record + + if record_type in (RECORD_TYPE_PAM_MACHINE, RECORD_TYPE_PAM_DATABASE): + secret_type_check = account.get("secretType", "password").lower() + # No target host and no SSH key material → route to login. A + # pamMachine without a host can never be reached by the gateway, + # so the credential is more useful as a standalone login record. + # SSH keys keep pamMachine semantics even without an address so + # the private_pem_key field is preserved. + if not address and secret_type_check != "key": + note = (f"CyberArk platform: {platform_id}\n" + "No address — imported as login (not pamMachine)" + if platform_id else + "CyberArk account had no address — imported as login") + return { + "type": RECORD_TYPE_LOGIN, + "title": title or raw_name, + "login": login, + "password": password or "", + "notes": note, + } + # Build pamUser nested inside the resource + user_record = { + "type": "pamUser", + "title": f"{login}@{title}" if login else f"user@{title}", + "login": login, + "password": password or "", + } + # SSH key detection: check platform name OR secretType field from API + # (ark-sdk-python uses secretType: "key" for any platform with SSH keys) + platform_id = account.get("platformId", "") + secret_type = account.get("secretType", "password").lower() + is_ssh_key = (platform_id in ("UnixSSHKey", "UnixSSHKeys") + or secret_type == "key") + if is_ssh_key and password: + # CyberArk exports SSH keys with \r\r\n line endings (a PVWA + # artifact); normalize to \n so OpenSSH libraries accept the PEM. + user_record["private_pem_key"] = password.replace("\r\r\n", "\n") + user_record["password"] = "" + # Map Database property for database platforms (MSSql, MySQL, Oracle, PostgreSQL) + database_name = props.get("Database", "") + if database_name and record_type == RECORD_TYPE_PAM_DATABASE: + user_record["connect_database"] = database_name + # Map DistinguishedName for Active Directory accounts + dn = props.get("DistinguishedName", "") or props.get("distinguishedName", "") + if dn: + user_record["distinguished_name"] = dn + # Rotation settings — derive from CyberArk secretManagement state + secret_mgmt = account.get("secretManagement", {}) + cpm_enabled = secret_mgmt.get("automaticManagementEnabled", True) + if mapping.get("rotation"): + # Resolve the per-user schedule with this priority: + # 1. Platform-level rotation-policy (CyberArk: + # /api/platforms/{platformId}/rotation-policy/) when + # ``overridesMasterPolicy=true``. + # 2. Master-policy exception for the platform (CyberArk: + # /api/platforms/master-rotation-policy/exceptions/). + # 3. Master Policy default + # (passwordChangeDays / changeInterval). + # 4. on-demand fallback when none apply or CPM is disabled. + # + # If CyberArk has CPM auto-management disabled on the + # account itself we always fall back to on-demand so we + # don't schedule rotations against credentials the admin + # explicitly opted out of. + platform_id_for_sched = account.get("platformId", "") + if cpm_enabled: + platform_sched = self._resolve_platform_schedule( + platform_id_for_sched) + if platform_sched: + schedule = copy.deepcopy(platform_sched) + self.platform_schedule_overrides[platform_id_for_sched] = ( + self.platform_schedule_overrides.get( + platform_id_for_sched, 0) + 1 + ) + else: + schedule = copy.deepcopy(self._default_rotation_schedule) + else: + schedule = {"type": SCHEDULE_ON_DEMAND} + user_record["rotation_settings"] = { + "rotation": mapping["rotation"], + "enabled": "on" if cpm_enabled else "off", + "schedule": schedule, + } + # Pull CyberArk's per-platform passwordGenRules into the + # Keeper rotation settings so a rotated password meets the + # same complexity requirements the source system enforced. + complexity = self._resolve_platform_password_complexity( + platform_id_for_sched) + if complexity: + user_record["rotation_settings"]["password_complexity"] = complexity + self.platform_complexity_overrides[platform_id_for_sched] = ( + self.platform_complexity_overrides.get( + platform_id_for_sched, 0) + 1 + ) + reason = secret_mgmt.get("manualManagementReason", "") + if not cpm_enabled: + existing = user_record.get("notes", "") + line = f"CyberArk CPM disabled: {reason}" + user_record["notes"] = f"{existing}\n{line}".strip() + cpm_status = secret_mgmt.get("status", "") + if cpm_status and cpm_status.lower() == "failure": + existing = user_record.get("notes", "") + line = f"CyberArk CPM status: FAILURE ({reason})" + user_record["notes"] = f"{existing}\n{line}".strip() + if password: + user_record["managed"] = True + + resource_title = title or address or raw_name + resource = { + "type": record_type, + "title": resource_title, + "host": address, + "port": str(port) if port else "", + "users": [user_record], + } + # Map LogonDomain → domain_name on resource (Windows AD domain) + if logon_domain and record_type == RECORD_TYPE_PAM_MACHINE: + resource["domain_name"] = logon_domain + # Derive operating_system on pamMachine so downstream consumers + # (notably ``pam action service add``, which only mounts on Windows + # hosts) can dispatch on it. Inferred from platformId keywords, + # fallback to protocol (rdp ⇒ windows, ssh ⇒ linux). + if record_type == RECORD_TYPE_PAM_MACHINE: + inferred_os = self._infer_operating_system( + platform_id, mapping.get("protocol"), + ) + if inferred_os: + resource["operating_system"] = inferred_os + # Tag pamDatabase resources with database_type so Keeper renders + # the correct DB icon/template. Mapping carries it from the + # platform tables in constants.py / platform_mapping.py. Skipped + # for non-DB record types and when the mapping has no entry. + if record_type == RECORD_TYPE_PAM_DATABASE: + db_type = mapping.get("database_type") or "" + if db_type: + resource["database_type"] = db_type + if mapping.get("protocol"): + # Resolve per-platform overrides for session recording and + # workflows. Both are looked up once per unique platformId + # thanks to the caches on AccountMapper. + platform_id_for_settings = account.get("platformId", "") + recording = self._resolve_platform_session_recording( + platform_id_for_settings) + # Trigger workflows resolution (records unmapped items in + # self.platform_workflow_unmapped — return value not used + # here because Keeper has no per-resource workflow toggles). + self._resolve_platform_workflows(platform_id_for_settings) + + graphical_recording = "off" + if recording: + graphical_recording = recording[0] + self.platform_recording_overrides[platform_id_for_settings] = ( + self.platform_recording_overrides.get( + platform_id_for_settings, 0) + 1 + ) + resource["pam_settings"] = { + "options": { + "rotation": "on" if cpm_enabled else "off", + "connections": "on", + "tunneling": "off", + "graphical_session_recording": graphical_recording, + }, + "connection": { + "protocol": mapping["protocol"], + "port": str(port) if port else "", + "launch_credentials": user_record["title"], + # Default to self-rotation: the nested user IS the admin + # when CyberArk does not provide a separate reconcile / + # enable linked account. resolve_linked_accounts() will + # overwrite this when a real admin credential is found. + # Without it, PAMCreateRecordRotationCommand aborts with + # "PAM Resource ... does not have admin credentials". + "administrative_credentials": user_record["title"], + } + } + + # Preserve CyberArk operational metadata (rotationNoticePeriod, + # headstartInterval, maxRetries, ...) on the resource record so + # admins can reproduce the source-system policy in Keeper. Each + # call is cached per-platform; later accounts on the same + # platform get the same custom-field list copied in. + metadata_fields = self._resolve_platform_metadata( + account.get("platformId", "")) + if metadata_fields: + resource.setdefault("custom", []).extend( + copy.deepcopy(metadata_fields)) + return resource + + logging.warning('Unsupported record_type "%s" for platform "%s" — account skipped', + record_type, account.get("platformId", "Unknown")) + return None + + def is_incomplete(self, account: dict) -> Tuple[bool, str]: + """Check if a CyberArk account is missing required fields for PAM import.""" + reasons = [] + if not account.get("address"): + reasons.append("missing address/host") + if not account.get("userName"): + reasons.append("missing userName") + if reasons: + return True, "; ".join(reasons) + return False, "" + diff --git a/keepercommander/importer/cyberark/pam/application_mapper.py b/keepercommander/importer/cyberark/pam/application_mapper.py new file mode 100644 index 000000000..84fdda147 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/application_mapper.py @@ -0,0 +1,25 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' dict: + raise NotImplementedError( + "ApplicationMapper is not implemented; applications are skipped during import." + ) diff --git a/keepercommander/importer/cyberark/pam/client.py b/keepercommander/importer/cyberark/pam/client.py new file mode 100644 index 000000000..e8c982834 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/client.py @@ -0,0 +1,1256 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[str]: + """Resolve tenant subdomain to identity endpoint via CyberArk platform discovery. + + Uses the same discovery service as ark-sdk-python: + https://platform-discovery.cyberark.cloud/api/identity-endpoint/{subdomain} + + Returns the identity host (e.g. 'abc1234.id.cyberark.cloud') or None on failure. + Falls back silently — caller uses the manually constructed URL. + """ + try: + response = requests.get( + f"https://platform-discovery.cyberark.cloud/api/identity-endpoint/{tenant_subdomain}", + timeout=5, + ) + if response.status_code == 200: + data = response.json() + # Response format: {"identity_user_portal": {"api": "https://..."}} + identity_url = (data.get("identity_user_portal", {}).get("api", "") + or data.get("identity", {}).get("api", "")) + if identity_url: + host = identity_url.removeprefix("https://").removeprefix("http://").rstrip("/") + if host: + return host + except (_requests_module.RequestException, ValueError, KeyError): + logging.debug("Platform discovery unavailable for tenant '%s' — using fallback", + tenant_subdomain) + return None + + def authenticate(self) -> bool: + """Authenticate to CyberArk PVWA. Returns True on success.""" + if self.pvwa_host.endswith(".cyberark.cloud"): + return self._auth_privilege_cloud() + else: + return self._auth_self_hosted() + + def _auth_privilege_cloud(self) -> bool: + """Authenticate to CyberArk Privilege Cloud (ISPSS). + + Two methods are supported: + 1. Service account — OAuth2 ``client_credentials`` against + ``/oauth2/platformtoken`` (non-interactive, no MFA). + 2. Interactive user login with MFA / 2FA via the CyberArk Identity + ``StartAuthentication`` / ``AdvanceAuthentication`` flow. + + Both produce a Bearer token that the Privilege Cloud REST API accepts. + The method is selected via the ``KEEPER_CYBERARK_AUTH_METHOD`` env var + (``service`` / ``interactive``) or an interactive prompt. + """ + id_host = self._resolve_identity_host() + if not id_host: + return False + if self._choose_cloud_auth_method() == "interactive": + return self._auth_privilege_cloud_interactive(id_host) + return self._auth_privilege_cloud_service(id_host) + + def _resolve_identity_host(self) -> Optional[str]: + """Resolve the CyberArk Identity host for the configured tenant. + + Tenant ID formats accepted (aligned with cyberark_portal discovery): + - 'abc1234' → abc1234.id.cyberark.cloud (short subdomain ID) + - 'mycompany' → mycompany.cyberark.cloud (named tenant) + - 'abc1234.id' → abc1234.id.cyberark.cloud (already qualified) + - 'https://...' → extracted hostname used directly + - 'tenant.my.idaptive.app' → tenant.my.idaptive.app (legacy Idaptive) + """ + id_tenant_raw = environ.get("KEEPER_CYBERARK_ID_TENANT") or prompt("CyberArk Identity Tenant ID: ") + id_tenant_raw = id_tenant_raw.strip() + if id_tenant_raw.startswith("https://"): + id_tenant_raw = id_tenant_raw[len("https://"):] + if id_tenant_raw.startswith("http://"): + id_tenant_raw = id_tenant_raw[len("http://"):] + id_tenant_raw = id_tenant_raw.rstrip("/") + + if "." in id_tenant_raw: + + id_host = id_tenant_raw + # But for the OAuth2 URL we need *.cyberark.cloud domain + if id_tenant_raw.endswith(".my.idaptive.app"): + # Legacy Idaptive — extract subdomain for cyberark.cloud OAuth2 + id_host = id_tenant_raw.split(".")[0] + ".id.cyberark.cloud" + logging.info("Legacy Idaptive tenant detected, using %s for OAuth2", id_host) + elif not id_tenant_raw.endswith(".cyberark.cloud"): + id_host = id_tenant_raw.split(".")[0] + ".cyberark.cloud" + else: + id_tenant = id_tenant_raw + if re.match(r"^[A-Za-z]{3}\d{4}$", id_tenant): + id_tenant += ".id" + id_host = f"{id_tenant}.cyberark.cloud" + + # Validate the base portion (before first dot) + base_part = id_host.split(".")[0] + if not re.match(r'^[a-zA-Z0-9]+$', base_part): + print_formatted_text(HTML("Invalid tenant ID format")) + return None + + # Platform discovery — resolve tenant to correct identity endpoint + discovered_host = self._discover_identity_endpoint(base_part) + if discovered_host: + id_host = discovered_host + logging.info("Platform discovery resolved tenant to %s", id_host) + return id_host + + @staticmethod + def _choose_cloud_auth_method() -> str: + """Return 'service' or 'interactive' for Privilege Cloud authentication. + """ + method = (environ.get("KEEPER_CYBERARK_AUTH_METHOD") or "").strip().lower() + if method in ("interactive", "identity", "user", "mfa", "2fa", "up"): + return "interactive" + if method in ("service", "service_account", "oauth", "oauth2", "client_credentials"): + return "service" + print_formatted_text(HTML( + "\nCyberArk Privilege Cloud authentication method:\n" + " [1] Service account (OAuth2 client credentials)\n" + " [2] User login with MFA / 2FA (CyberArk Identity)" + )) + try: + choice = prompt("Select authentication method [1/2] (default 1): ").strip() + except (EOFError, KeyboardInterrupt): + return "service" + return "interactive" if choice == "2" else "service" + + def _auth_privilege_cloud_service(self, id_host: str) -> bool: + """Authenticate to Privilege Cloud via OAuth2 service account (no MFA).""" + client_id = environ.get("KEEPER_CYBERARK_USERNAME") or prompt("CyberArk service user name: ") + client_secret = environ.get("KEEPER_CYBERARK_PASSWORD") or prompt( + "CyberArk service user password: ", is_password=True + ) + oauth2_url = f"https://{id_host}/oauth2/platformtoken" + logging.info("Authenticating to Privilege Cloud via %s", oauth2_url) + try: + response = requests.post( + oauth2_url, + data={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret}, + timeout=self.TIMEOUT, + ) + except _requests_module.RequestException as e: + print_formatted_text(HTML(f"OAuth2 request failed: connection error")) + logging.debug(f"OAuth2 connection error: {type(e).__name__}") + return False + if response.status_code != 200: + print_formatted_text(HTML( + f"OAuth2 authorization token request failed with status code {response.status_code}" + )) + print_formatted_text(HTML( + "Tip: the OAuth2 client-credentials flow only works for " + "CyberArk service accounts. To sign in as a regular user with MFA / 2FA, " + "re-run and choose authentication method 2 " + "(or set KEEPER_CYBERARK_AUTH_METHOD=interactive)." + )) + return False + try: + self.auth_token = f"Bearer {response.json()['access_token']}" + except (KeyError, ValueError): + print_formatted_text(HTML("Failed to parse OAuth2 response")) + print_formatted_text(HTML( + "Tip: the OAuth2 client-credentials flow only works for " + "CyberArk service accounts. To sign in as a regular user with MFA / 2FA, " + "re-run and choose authentication method 2 " + "(or set KEEPER_CYBERARK_AUTH_METHOD=interactive)." + )) + return False + print_formatted_text(HTML("Log on successful")) + return True + + def _auth_privilege_cloud_interactive(self, id_host: str) -> bool: + """Authenticate to Privilege Cloud as an interactive user with MFA / 2FA. + """ + identity_base_url = f"https://{id_host}" + tenant_name = id_host.split(".")[0] + username = environ.get("KEEPER_CYBERARK_USERNAME") or prompt("CyberArk username: ") + + headers = {"X-IDAP-NATIVE-CLIENT": "true"} + start_payload = {"TenantId": tenant_name, "User": username, "Version": "1.0"} + identity_base_url, result = self._start_identity_authentication( + identity_base_url, start_payload, headers, + ) + if result is None: + return False + + if result.get("IdpRedirectUrl") or result.get("IdpRedirectShortUrl"): + print_formatted_text(HTML( + "This account signs in through SSO / an external identity provider, " + "which the interactive importer does not support. Use a CyberArk service account " + "(authentication method 1) instead." + )) + return False + + session_id = result.get("SessionId") + challenges = result.get("Challenges") or [] + if not session_id or not challenges: + print_formatted_text(HTML("Unexpected authentication response from CyberArk Identity")) + logging.debug("StartAuthentication result missing SessionId/Challenges") + return False + + password = environ.get("KEEPER_CYBERARK_PASSWORD") + advance_result = None + for challenge in challenges: + mechanisms = challenge.get("Mechanisms") or [] + if not mechanisms: + continue + mechanism = self._select_identity_mechanism(mechanisms) + if mechanism is None: + return False + advance_result = self._answer_identity_mechanism( + identity_base_url, tenant_name, session_id, mechanism, password=password, + ) + if advance_result is None: + return False + if advance_result.get("Summary") == IDENTITY_LOGIN_SUCCESS: + break + + if not advance_result or advance_result.get("Summary") != IDENTITY_LOGIN_SUCCESS: + summary = (advance_result or {}).get("Summary") or "unknown" + print_formatted_text(HTML( + f"Authentication did not complete successfully (status: {_esc(summary)})" + )) + return False + + token = advance_result.get("Token") + if not token: + print_formatted_text(HTML("CyberArk Identity did not return a session token")) + return False + self.auth_token = f"Bearer {token}" + print_formatted_text(HTML("Log on successful")) + return True + + def _start_identity_authentication(self, identity_base_url: str, start_payload: dict, + headers: dict) -> Tuple[Optional[str], Optional[dict]]: + """POST ``/Security/StartAuthentication``, following HTTP and pod redirects. + """ + url = f"{identity_base_url}/Security/StartAuthentication" + for _ in range(4): + try: + response = requests.post( + url, json=start_payload, headers=headers, + timeout=self.TIMEOUT, allow_redirects=False, + ) + except _requests_module.RequestException: + print_formatted_text(HTML("Authentication request failed: connection error")) + logging.debug("StartAuthentication connection error", exc_info=True) + return None, None + + if response.status_code in (301, 302, 303, 307, 308): + redirect_url = response.headers.get("Location", "") + if redirect_url: + parsed = urlparse(urljoin(identity_base_url, redirect_url)) + if parsed.hostname: + identity_base_url = f"https://{parsed.hostname}" + url = f"{identity_base_url}/Security/StartAuthentication" + logging.info( + "StartAuthentication redirected; retrying on %s", identity_base_url, + ) + continue + print_formatted_text(HTML( + f"Authentication failed with redirect status " + f"{response.status_code} and no usable Location header" + )) + return None, None + + result = self._identity_result(response) + if result is None: + return None, None + + pod = result.get("PodFqdn") + if pod: + identity_base_url = f"https://{pod}" + url = f"{identity_base_url}/Security/StartAuthentication" + logging.info("CyberArk Identity redirected to preferred pod %s", pod) + continue + + return identity_base_url, result + + print_formatted_text(HTML( + "Too many redirects while starting CyberArk Identity authentication" + )) + return None, None + + @staticmethod + def _identity_result(response) -> Optional[dict]: + """Parse a CyberArk Identity API response, returning its ``Result`` dict. + """ + if response.status_code != 200: + print_formatted_text(HTML( + f"Authentication failed with status code {response.status_code}" + )) + return None + try: + body = response.json() + except ValueError: + print_formatted_text(HTML("Failed to parse authentication response")) + return None + if body.get("success") is False: + msg = body.get("Message") or body.get("MessageID") or "authentication failed" + print_formatted_text(HTML(f"{_esc(msg)}")) + return None + return body.get("Result") or {} + + @staticmethod + def _select_identity_mechanism(mechanisms: List[dict]) -> Optional[dict]: + """Prompt the user to pick an MFA mechanism when more than one is offered.""" + if len(mechanisms) == 1: + return mechanisms[0] + print_formatted_text(HTML("Select an authentication method:")) + for i, m in enumerate(mechanisms, 1): + label = m.get("PromptSelectMech") or m.get("Name") or f"Option {i}" + print_formatted_text(HTML(f" [{i}] {_esc(label)}")) + while True: + try: + choice = prompt(f"Enter option number (1-{len(mechanisms)}): ").strip() + except (EOFError, KeyboardInterrupt): + return None + try: + idx = int(choice) + except ValueError: + continue + if 1 <= idx <= len(mechanisms): + return mechanisms[idx - 1] + + def _answer_identity_mechanism(self, identity_base_url: str, tenant_name: str, + session_id: str, mechanism: dict, + password: Optional[str] = None) -> Optional[dict]: + """Drive one CyberArk Identity challenge mechanism to completion. + """ + url = f"{identity_base_url}/Security/AdvanceAuthentication" + headers = {"X-IDAP-NATIVE-CLIENT": "true"} + name = mechanism.get("Name") or "" + mech_id = mechanism.get("MechanismId") + answer_type = (mechanism.get("AnswerType") or "").lower() + prompt_label = (mechanism.get("PromptSelectMech") + or mechanism.get("PromptMechChosen") or name) + base = {"TenantId": tenant_name, "SessionId": session_id, "MechanismId": mech_id} + + def _post(body: dict) -> Optional[dict]: + try: + resp = requests.post(url, json=body, headers=headers, timeout=self.TIMEOUT) + except _requests_module.RequestException as e: + print_formatted_text(HTML("Authentication request failed: connection error")) + logging.debug("AdvanceAuthentication connection error: %s", type(e).__name__) + return None + return self._identity_result(resp) + + # Text answer — password (UP) or a typed code (OTP / authenticator). + if name == "UP" or answer_type == "text": + if name == "UP": + answer = password or prompt("CyberArk password: ", is_password=True) + else: + answer = prompt(f"{prompt_label}: ") + return _post(dict(base, Action="Answer", Answer=answer)) + + result = _post(dict(base, Action="StartOOB")) + if result is None: + return None + if result.get("Summary") == IDENTITY_LOGIN_SUCCESS: + return result + print_formatted_text(HTML( + f"Out-of-band authentication started via {_esc(prompt_label)}." + )) + try: + code = prompt( + "Approve the request on your device then press Enter, " + "or type the code you received: " + ).strip() + except (EOFError, KeyboardInterrupt): + return None + if code: + return _post(dict(base, Action="Answer", Answer=code)) + # No code entered — poll for a push approval. + waited = 0 + while waited < self.IDENTITY_OOB_POLL_TIMEOUT: + time.sleep(self.IDENTITY_OOB_POLL_INTERVAL) + waited += self.IDENTITY_OOB_POLL_INTERVAL + result = _post(dict(base, Action="Poll")) + if result is None: + return None + if result.get("Summary") != "OobPending": + return result + print_formatted_text(HTML("Timed out waiting for out-of-band approval")) + return result + + def _auth_self_hosted(self) -> bool: + login_type = environ.get("KEEPER_CYBERARK_LOGON_TYPE") or prompt( + "CyberArk logon type (Cyberark, LDAP, RADIUS or Windows): " + ) + # Validate login_type to prevent URL path injection (case-insensitive) + if login_type.lower() not in VALID_LOGON_TYPES: + print_formatted_text(HTML( + f"Invalid logon type: must be one of Cyberark, LDAP, RADIUS, Windows" + )) + return False + username = environ.get("KEEPER_CYBERARK_USERNAME") or prompt("CyberArk username: ") + password = environ.get("KEEPER_CYBERARK_PASSWORD") or prompt("CyberArk password: ", is_password=True) + try: + response = requests.post( + self._get_url("logon").format(type=login_type), + json={"username": username, "password": password}, + timeout=self.TIMEOUT, + verify=self.verify_ssl, + ) + except _requests_module.RequestException as e: + print_formatted_text(HTML(f"CyberArk Log on failed: connection error")) + logging.debug(f"Logon connection error: {type(e).__name__}") + return False + if response.status_code != 200: + print_formatted_text(HTML( + f"CyberArk Log on failed with status code {response.status_code}" + )) + return False + # CyberArk's Logon endpoint returns the token as a JSON string. + # Use json() for proper quote/escape handling rather than strip('"'). + try: + token = response.json() + except ValueError: + token = response.text.strip('"') + self.auth_token = token if isinstance(token, str) else "" + print_formatted_text(HTML("Log on successful")) + return True + + def _paginate(self, url: str, params: Optional[dict] = None, + items_key: str = "value", limit: int = 100, + filter_fn=None) -> List[dict]: + """Generic paginated fetch following nextLink (ark-sdk-python pattern). + + Follows the server's nextLink URL for subsequent pages instead of + manually incrementing offset — more robust against API changes. + """ + results = [] + page_params = dict(params or {}, limit=str(limit), offset="0") + next_url = url + while True: + time.sleep(self.DELAY) + response = self._get(next_url, params=page_params) + if response.status_code != 200: + logging.debug('Paginated fetch failed: %s status %d', next_url, response.status_code) + break + try: + data = response.json() + except (ValueError, KeyError): + break + batch = data.get(items_key, []) + if filter_fn: + batch = [item for item in batch if filter_fn(item)] + results.extend(batch) + if len(results) >= MAX_FETCH_RECORDS: + logging.warning('Pagination cap reached (%d) — truncating', MAX_FETCH_RECORDS) + break + # Follow nextLink if present, otherwise check batch size + next_link = data.get("nextLink") + if next_link: + + try: + base_host = urlparse(url).netloc + next_host = urlparse(next_link).netloc + except ValueError: + logging.warning('Invalid nextLink URL — stopping pagination') + break + if next_host and next_host != base_host: + logging.warning('nextLink points to a different host (%s vs %s) — ' + 'stopping pagination to avoid token leakage', + next_host, base_host) + break + # nextLink is a full URL — use it directly, no params needed + next_url = next_link + page_params = {} + else: + # No nextLink and batch smaller than limit = last page + raw_count = len(data.get(items_key, [])) + if raw_count < limit: + break + # No nextLink but full page — fallback to manual offset + current_offset = int(page_params.get("offset", "0")) + page_params = dict(params or {}, limit=str(limit), + offset=str(current_offset + limit)) + next_url = url + return results + + def fetch_safes(self) -> List[dict]: + """Fetch list of safes from PVWA or local sources.""" + safes_file = environ.get("KEEPER_CYBERARK_SAFES_PATH", "safes.txt") + if path.isfile(safes_file): + with open(safes_file, "r", encoding="utf-8") as f: + names = [line.strip() for line in f if line.strip()] + if not names: + print_formatted_text(HTML(f"Safes file {_esc(safes_file)} is empty")) + return [] + print_formatted_text(HTML(f"Safes from file {_esc(safes_file)}: {_esc(', '.join(names))}")) + return [{"safeName": n} for n in names] + elif "KEEPER_CYBERARK_SAFES" in environ: + names = [x.strip() for x in environ["KEEPER_CYBERARK_SAFES"].split(",") if x.strip()] + print_formatted_text(HTML(f"Safes from environment variable KEEPER_CYBERARK_SAFES: {_esc(', '.join(names))}")) + return [{"safeName": n} for n in names] + else: + user_input = prompt( + "CyberArk safes as a comma-separated list (leave empty to get safes from the server): " + ) + names = [x.strip() for x in user_input.split(",") if x.strip()] + if names: + return [{"safeName": n} for n in names] + # Fetch from server — now with pagination + print_formatted_text(HTML("Getting safes from the server...")) + safes = self._paginate(self._get_url("safes"), limit=200) + if not safes: + print_formatted_text(HTML(f"No Safes on server {_esc(self.pvwa_host)}")) + return safes + + def fetch_platforms(self) -> List[dict]: + """Fetch platform definitions from PVWA ``GET /Platforms``. + """ + # _paginate already handles "value"; try that first, then fall back to + # the explicitly-keyed response shape. + platforms = self._paginate(self._get_url("platforms"), + items_key="Platforms", limit=200) + if platforms: + return platforms + # Fallback: some tenants/versions paginate under "value" + return self._paginate(self._get_url("platforms"), limit=200) + + def fetch_platform_details(self, platform_id: str) -> Optional[dict]: + """Fetch the full platform definition (ConnectionComponents, Properties). + """ + if not platform_id or not re.match(r'^[A-Za-z0-9_\-]+$', str(platform_id)): + return None + try: + url = self._get_url("platform_details").format(platform_id=platform_id) + resp = self._get(url) + if resp.status_code == 200: + try: + body = resp.json() + except (ValueError, KeyError): + logging.debug('Platform detail response not valid JSON for %s', + platform_id) + return None + if logging.getLogger().isEnabledFor(logging.DEBUG): + try: + logging.debug( + 'Platform detail raw response for %s:\n%s', + platform_id, + json.dumps(body, indent=2, sort_keys=True), + ) + except (TypeError, ValueError): + pass + return body + logging.debug('Platform detail fetch failed for %s: status %d', + platform_id, resp.status_code) + except (_requests_module.RequestException, ValueError) as e: + logging.debug('Platform detail fetch error for %s: %s', + platform_id, type(e).__name__) + return None + + @staticmethod + def _encode_safe_path(safe_url_id: str) -> Optional[str]: + """Normalize and URL-encode a CyberArk safe identifier for path use. + + Validates *before* any percent-decoding so encoded separators + (``%2f``, ``%5c``, ``%2e%2e``) cannot bypass the path-traversal + guards. + """ + if not safe_url_id or not isinstance(safe_url_id, str): + return None + s = safe_url_id.strip() + if not s or len(s) > 200: + return None + # Reject raw and percent-encoded path separators / traversal before + # any decoding so crafted inputs cannot slip through unquote(). + lower = s.lower() + if ( + any(ch in s for ch in ("/", "\\", "\x00")) + or s in ("..", ".") + or "%2f" in lower + or "%5c" in lower + or "%00" in lower + or "%2e%2e" in lower + ): + logging.warning( + 'Safe URL ID rejected (contains path separator/traversal): %s', + re.sub(r'[^A-Za-z0-9_. \-%]', '?', s), + ) + return None + try: + decoded = unquote(s) + except (TypeError, ValueError): + decoded = s + if ( + any(ch in decoded for ch in ("/", "\\", "\x00")) + or decoded in ("..", ".") + or ".." in decoded + ): + logging.warning( + 'Safe URL ID rejected after decode (contains separator/traversal): %s', + re.sub(r'[^A-Za-z0-9_. \-%]', '?', s), + ) + return None + return quote(decoded, safe='') + + def fetch_safe_members(self, safe_url_id: str) -> List[dict]: + """Fetch members of a safe. Returns list of member dicts. + + Excludes predefined system members. Safe names with spaces, unicode, + or pre-encoded characters are normalized via ``_encode_safe_path``. + """ + encoded = self._encode_safe_path(safe_url_id) + if encoded is None: + return [] + url = f"{self._get_url('safes')}/{encoded}/Members" + return self._paginate( + url, limit=100, + filter_fn=lambda m: not m.get("isPredefinedUser", False), + ) + + def fetch_users(self) -> List[dict]: + """Fetch all vault users. Excludes component users (CPM, PSM, etc.).""" + base = self._get_url('safes').rsplit('/Safes', 1)[0] + return self._paginate( + f"{base}/Users", + params={"componentUser": "false"}, + items_key="Users", + limit=100, + ) + + def fetch_user_groups(self) -> List[dict]: + """Fetch all vault user groups.""" + base = self._get_url('safes').rsplit('/Safes', 1)[0] + return self._paginate(f"{base}/UserGroups", limit=100) + + _PLATFORM_ID_RE = re.compile(r'^[A-Za-z0-9_\-]{1,80}$') + + def _fetch_platform_policy(self, platform_id: str, suffix: str, + label: str) -> Optional[dict]: + """Shared GET helper for the ``/api/platforms/{id}//`` family. + + """ + if not platform_id or not self._PLATFORM_ID_RE.match(str(platform_id)): + logging.debug('Skipping %s fetch for invalid platformId: %s', + label, re.sub(r'[^A-Za-z0-9_\-]', '?', str(platform_id))) + return None + url = (f"https://{self.pvwa_host}/api/platforms/" + f"{platform_id}/{suffix}/") + try: + response = self._get(url) + except _requests_module.RequestException as e: + logging.debug('Platform %s fetch error for %s: %s', + label, platform_id, type(e).__name__) + return None + if response.status_code == 200: + try: + data = response.json() + except (ValueError, KeyError): + logging.debug('Platform %s response not valid JSON for %s', + label, platform_id) + return None + if isinstance(data, dict) and data: + logging.debug('Platform %s fetched for %s', label, platform_id) + try: + logging.debug('Platform %s raw response for %s:\n%s', + label, platform_id, + json.dumps(data, indent=2, sort_keys=True)) + except (TypeError, ValueError): + logging.debug('Platform %s raw response for %s ' + '(unserializable)', label, platform_id) + return data + else: + logging.debug('Platform %s not accessible for %s: status %d', + label, platform_id, response.status_code) + return None + + def fetch_platform_rotation_policy(self, platform_id: str) -> Optional[dict]: + """``GET /api/platforms/{platformId}/rotation-policy/``. + """ + return self._fetch_platform_policy( + platform_id, "rotation-policy", "rotation-policy") + + def fetch_platform_secrets_policy(self, platform_id: str) -> Optional[dict]: + """``GET /api/platforms/{platformId}/secrets-policy/``. + + """ + return self._fetch_platform_policy( + platform_id, "secrets-policy", "secrets-policy") + + def fetch_platform_workflows_policy(self, platform_id: str) -> Optional[dict]: + """``GET /api/platforms/{platformId}/workflows-policy/``. + + """ + return self._fetch_platform_policy( + platform_id, "workflows-policy", "workflows-policy") + + # CyberArk policy IDs are case-sensitive; the legacy PVWA UI .asmx + # service expects them quoted. Validation regex defined above. + def fetch_platform_session_monitoring(self, platform_id: str) -> Optional[dict]: + """Resolve the platform's session-monitoring rules from the legacy + PVWA admin-UI ``.asmx`` web-service. + + """ + if not platform_id or not self._PLATFORM_ID_RE.match(str(platform_id)): + return None + + + url = (f"https://{self.pvwa_host}/PasswordVault/services/" + f"PoliciesMgt.asmx/GetPolicyRulesSessionMonitoring") + params = { + "platformId": f'"{platform_id}"', + "page": 1, "start": 0, "limit": 100, + } + try: + response = self._get(url, params=params) + except _requests_module.RequestException as e: + logging.debug('Platform session-monitoring (.asmx) fetch error for %s: %s', + platform_id, type(e).__name__) + return None + if response.status_code != 200: + logging.debug('Platform session-monitoring (.asmx) for %s: status %d', + platform_id, response.status_code) + return None + try: + data = response.json() + except (ValueError, KeyError): + logging.debug('Platform session-monitoring (.asmx) response not valid JSON for %s', + platform_id) + return None + if isinstance(data, dict) and data: + logging.debug('Platform session-monitoring fetched for %s (.asmx)', + platform_id) + try: + logging.debug('Platform session-monitoring raw response for %s (.asmx):\n%s', + platform_id, json.dumps(data, indent=2, sort_keys=True)) + except (TypeError, ValueError): + pass + return data + return None + + def fetch_master_policy(self) -> Optional[dict]: + """Fetch the CyberArk Master Policy settings. + + """ + # ISPSS APIs sit at the root of the privilegecloud host (not under + # /PasswordVault/API/), so build a separate base for them. + ispss_base = f"https://{self.pvwa_host}" + legacy_base = self._get_url("safes").rsplit("/Safes", 1)[0] + # Trailing slash on the ISPSS endpoint matches the docs guidance + # ("If the URL includes a dot, add a forward slash at the end"). + endpoints = ( + f"{ispss_base}/api/platforms/master-rotation-policy/", + f"{legacy_base}/Policies/1", + f"{legacy_base}/Policy/MasterPolicy", + ) + for url in endpoints: + try: + response = self._get(url) + except _requests_module.RequestException as e: + logging.debug('Master Policy fetch error at %s: %s', + url, type(e).__name__) + continue + if response.status_code == 200: + try: + data = response.json() + except (ValueError, KeyError): + logging.debug('Master Policy response not valid JSON at %s', url) + continue + if isinstance(data, dict) and data: + # Log the source URL at DEBUG — helps operators tell which + # deployment path was taken when verbose logging is on. + logging.debug('Master Policy fetched from %s', url) + try: + logging.debug('Master Policy raw response from %s:\n%s', + url, json.dumps(data, indent=2, sort_keys=True)) + except (TypeError, ValueError): + logging.debug('Master Policy raw response from %s (unserializable)', url) + return data + else: + logging.debug('Master Policy not accessible at %s: status %d', + url, response.status_code) + return None + + + _MASTER_POLICY_ONLY_KEYS = frozenset({ + "changeIntervalExceptionsCount", "verifyIntervalExceptionsCount", + }) + + @staticmethod + def _looks_like_base_master_policy(data: Any) -> bool: + return (isinstance(data, dict) + and any(k in data for k in + CyberArkPVWAClient._MASTER_POLICY_ONLY_KEYS) + and "exceptions" not in data + and "value" not in data) + + def fetch_master_rotation_policy_exceptions(self) -> Optional[Any]: + """``GET /api/platforms/master-rotation-policy/exceptions/``. + + """ + url = (f"https://{self.pvwa_host}" + f"/api/platforms/master-rotation-policy/exceptions/") + try: + response = self._get(url) + except _requests_module.RequestException as e: + logging.debug('Master rotation policy exceptions fetch error: %s', + type(e).__name__) + return None + if response.status_code != 200: + logging.debug( + 'Master rotation policy exceptions not accessible: status %d', + response.status_code, + ) + return None + try: + data = response.json() + except (ValueError, KeyError): + logging.debug( + 'Master rotation policy exceptions response not valid JSON', + ) + return None + if not data: + return None + try: + raw_json = json.dumps(data, indent=2, sort_keys=True) + except (TypeError, ValueError): + raw_json = repr(data) + logging.debug('CyberArk master-rotation-policy exceptions:\n%s', raw_json) + if self._looks_like_base_master_policy(data): + logging.debug( + "This tenant's /master-rotation-policy/exceptions/ endpoint " + "returned the base policy object (not a per-platform " + "exceptions list) — falling back to the per-platform " + "rotation-policy check for each imported account's platform." + ) + return None + return data + + def fetch_master_session_monitoring(self) -> Optional[dict]: + """Fetch global session-monitoring rules (master policy level). + + """ + url = (f"https://{self.pvwa_host}/PasswordVault/services/" + f"PoliciesMgt.asmx/GetPolicyRulesSessionMonitoring") + # Empty platformId — the global / master-level rule set. Quoted + # form (``""``) matches what the PVWA UI sends; unquoted form is + # rejected by the .asmx parser on some self-hosted builds. + params = { + "platformId": '""', + "page": 1, "start": 0, "limit": 100, + } + try: + response = self._get(url, params=params) + except _requests_module.RequestException as e: + logging.debug('Master session-monitoring (.asmx) fetch error: %s', + type(e).__name__) + return None + if response.status_code != 200: + logging.debug('Master session-monitoring (.asmx) status %d', + response.status_code) + return None + try: + data = response.json() + except (ValueError, KeyError): + logging.error('Master session-monitoring (.asmx) response not valid JSON') + return None + if isinstance(data, dict) and data: + logging.debug('Master session-monitoring fetched (.asmx)') + try: + logging.debug('Master session-monitoring raw response (.asmx):\n%s', + json.dumps(data, indent=2, sort_keys=True)) + except (TypeError, ValueError): + pass + return data + return None + + def fetch_accounts(self, safe_names: List[str], query_params: Optional[dict] = None, + state_filter: Optional[List[str]] = None) -> Dict[str, List[dict]]: + """Fetch accounts for given safes with pagination. Returns {safe_name: [account_dicts]}.""" + result = {} + base_params = query_params or self.query_params or {} + for safe in safe_names: + params = dict(base_params, filter=f"safeName eq {safe}") + accounts = self._paginate( + self._get_url("accounts"), params=params, + limit=1000, # CyberArk max for accounts endpoint + ) + if not accounts: + print_formatted_text(HTML(f"No accounts in safe {_esc(safe)}")) + continue + # Apply state filter if provided + if state_filter: + state_filter_lower = [s.lower() for s in state_filter] + accounts = [ + a for a in accounts + if (a.get("secretManagement", {}).get("status", "").lower() in state_filter_lower + or not a.get("secretManagement", {}).get("status")) + ] + result[safe] = accounts + print_formatted_text(HTML(f"Found {len(accounts)} accounts in safe {_esc(safe)}")) + return result + + def fetch_account_dependents(self, account_id: str) -> List[dict]: + """Fetch dependents (Windows services, scheduled tasks, IIS app pools) + attached to a CyberArk account. + + Privilege Cloud exposes this under the lowercase ISPSS REST surface at + ``GET /api/accounts/{accountId}/account-dependents`` (sibling of the + ``/api/platforms/{id}/rotation-policy/`` endpoints already used here). + Self-hosted PVWA still serves the older + ``/PasswordVault/API/Accounts/{id}/Dependents`` path, so we try the + Privilege Cloud URL first and fall back to PVWA on 404 / 405. + + """ + if not re.match(r'^[a-zA-Z0-9_]+$', str(account_id)): + logging.warning('Invalid account ID for dependents fetch: %s', + re.sub(r'[^a-zA-Z0-9_]', '?', str(account_id))) + return [] + + candidate_urls = [ + f"https://{self.pvwa_host}/api/accounts/{account_id}/account-dependents", + f"{self._get_url('accounts')}/{account_id}/Dependents", + ] + response = None + used_url = "" + for url in candidate_urls: + try: + response = self._get(url) + except _requests_module.RequestException as e: + logging.debug('Dependents fetch error for %s @ %s: %s', + account_id, url, type(e).__name__) + response = None + continue + if response is None: + continue + if response.status_code == 200: + used_url = url + break + if response.status_code in (404, 405): + logging.debug('Dependents endpoint not available at %s ' + '(status %d) — trying fallback', url, + response.status_code) + continue + logging.debug('Dependents fetch returned %d for account %s @ %s', + response.status_code, account_id, url) + return [] + + if response is None or response.status_code != 200: + return [] + + try: + data = response.json() + except ValueError: + logging.debug('Dependents response for %s is not JSON', account_id) + return [] + if isinstance(data, dict): + items = (data.get("value") or data.get("Dependencies") + or data.get("dependents") or data.get("dependencies") + or data.get("accountDependents") + or data.get("items") or []) + elif isinstance(data, list): + items = data + else: + items = [] + items = [d for d in items if isinstance(d, dict)] + if items: + logging.debug('Fetched %d dependent(s) for account %s via %s', + len(items), account_id, used_url) + try: + logging.debug( + 'CyberArk dependents (raw) for account %s:\n%s', + account_id, + json.dumps(items, indent=2, sort_keys=True), + ) + except (TypeError, ValueError): + logging.debug( + 'CyberArk dependents (raw) for account %s: %r', + account_id, items, + ) + return items + + def fetch_account_details(self, account_id: str) -> Optional[dict]: + """Fetch single account details including linkedAccounts. + + LinkedAccounts (logonAccount, reconcileAccount, enableAccount) are + only available via the single-account GET, not the list endpoint. + Returns the full account dict or None on failure. + """ + # Validate account_id format (alphanumeric + underscore only) + if not re.match(r'^[a-zA-Z0-9_]+$', account_id): + logging.warning('Invalid account ID format: %s — skipping detail fetch', + re.sub(r'[^a-zA-Z0-9_]', '?', account_id)) + return None + try: + response = self._get(f"{self._get_url('accounts')}/{account_id}") + if response.status_code == 200: + return response.json() + logging.debug('Account detail fetch failed for %s: status %d', + account_id, response.status_code) + except _requests_module.RequestException as e: + logging.debug('Account detail fetch error for %s: %s', + account_id, type(e).__name__) + return None + + def retrieve_password(self, account_id: str, account_name: str = "", + safe_name: str = "", skip_all: Optional[dict] = None) -> Optional[str]: + """Retrieve password for an account. Returns password string or None.""" + if skip_all is None: + skip_all = {} + # Validate account_id format before URL interpolation + if not re.match(r'^[a-zA-Z0-9_]+$', str(account_id)): + logging.warning('Invalid account ID for password retrieval: %s', + re.sub(r'[^a-zA-Z0-9_]', '?', str(account_id))) + return None + retry = True + while retry is True: + try: + response = requests.post( + self._get_url("account_password").format(account_id=account_id), + headers={"Authorization": self.auth_token, "Content-Type": "application/json"}, + json={ + "reason": "Keeper Commander Import", + **({"TicketingSystemName": environ["KEEPER_CYBERARK_TICKETING_SYSTEM"]} + if "KEEPER_CYBERARK_TICKETING_SYSTEM" in environ else {}), + **({"TicketId": environ["KEEPER_CYBERARK_TICKET_ID"]} + if "KEEPER_CYBERARK_TICKET_ID" in environ else {}), + }, + timeout=self.TIMEOUT, + verify=self.verify_ssl, + ) + except _requests_module.RequestException as e: + logging.debug('Password retrieval network error for %s: %s', + account_id, type(e).__name__) + return None + if response.status_code == 200: + # Password endpoint returns a JSON string; parse properly + # to avoid edge cases in embedded quotes or escapes. + try: + pw = response.json() + except ValueError: + pw = response.text.strip('"') + return pw if isinstance(pw, str) else None + elif 400 <= response.status_code < 500: + try: + error = response.json() + except ValueError: + error = {"ErrorCode": "UNKNOWN", "ErrorMessage": "Non-JSON error response"} + error_code = error.get("ErrorCode") + if error_code in skip_all: + return None + retry = button_dialog( + title=f"{response.status_code}", + text=HTML( + f"Error {_esc(error_code)}: {_esc(error.get('ErrorMessage', ''))}\n" + f"Account {_esc(account_name)} with ID {_esc(account_id)} in Safe {_esc(safe_name)}" + ), + buttons=[("Retry", True), ("Skip", False), ("Skip All", None)], + style=Style.from_dict({"dialog": "bg:ansiblack"}), + ).run() + if retry is None: + skip_all[error_code] = True + return None + if retry is False: + return None + else: + print_formatted_text(HTML(f"Password retrieval aborted (status {response.status_code})")) + return None + return None + diff --git a/keepercommander/importer/cyberark/pam/constants.py b/keepercommander/importer/cyberark/pam/constants.py new file mode 100644 index 000000000..216c76352 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/constants.py @@ -0,0 +1,129 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' None: + """Dynamically add safe names to the system-safe exclusion set. + + Use when CyberArk introduces new internal safes, or when the PVWA API + surfaces safes flagged as system/predefined that are not in the static + baseline. Empty / non-string names are ignored. + """ + for name in names: + if isinstance(name, str): + cleaned = name.strip() + if cleaned: + SYSTEM_SAFES.add(cleaned) + + +def reset_system_safes() -> None: + """Restore SYSTEM_SAFES to the built-in default set (tests / re-runs).""" + SYSTEM_SAFES.clear() + SYSTEM_SAFES.update(_DEFAULT_SYSTEM_SAFES) + + +# Maximum custom metadata fields copied from a CyberArk platform onto a +# Keeper resource record (prevents record bloat from hostile/oversized APIs). +MAX_PLATFORM_METADATA_FIELDS = 50 + +# Maximum character length for a single custom metadata field value. +MAX_PLATFORM_METADATA_VALUE_LEN = 500 + +# Maximum safe name length for Keeper shared folder names +MAX_SAFE_NAME_LENGTH = 28 + +# Maximum total records per fetch operation (prevent OOM from malicious API) +MAX_FETCH_RECORDS = 50000 + +# Keeper PAM record types (case-sensitive on the wire) +RECORD_TYPE_LOGIN = "login" +RECORD_TYPE_PAM_MACHINE = "pamMachine" +RECORD_TYPE_PAM_DATABASE = "pamDatabase" +RECORD_TYPE_PAM_DIRECTORY = "pamDirectory" + +# Rotation schedule type emitted in import JSON / PAM settings +SCHEDULE_ON_DEMAND = "on-demand" + +# Platform-map diagnostic when no rotation mapping is known +ROTATION_UNMAPPED = "UNMAPPED" + +# CyberArk Identity AdvanceAuthentication Summary for a completed login +IDENTITY_LOGIN_SUCCESS = "LoginSuccess" + +# Default CyberArk platformId → KeeperPAM record mapping +DEFAULT_PLATFORM_MAP = { + # NIX + "UnixSSH": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + "UnixSSHKey": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + "UnixSSHKeys": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + # Windows + "WinDomain": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "rdp", "port": "3389"}, + "WinLocalAccount": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "rdp", "port": "3389"}, + "WinServerLocal": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "rdp", "port": "3389"}, + "WinDesktopLocal": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "rdp", "port": "3389"}, + # Database + # NOTE on protocol vs database_type: + # - pam_settings.connection.protocol accepts ONLY {sql-server,postgresql,mysql}. + # Anything else (oracle, mongodb, mssql, ...) is rejected by Keeper and the + # entire pam_settings.connection block gets dropped with a warning + # ("Connection skipped: unknown protocol ..."). + # - resource.database_type accepts the broader set + # {postgresql,postgresql-flexible,mysql,mysql-flexible,mariadb, + # mariadb-flexible,mssql,oracle,mongodb}. + # For DBs that have no Keeper connection protocol (Oracle, MongoDB) we set + # protocol=None so account_mapper skips the connection block entirely while + # still tagging the record with the correct database_type. + "Oracle": {"record_type": RECORD_TYPE_PAM_DATABASE, "rotation": "general", "protocol": "sql-server", "port": "1521", "database_type": "oracle"}, + "MySQL": {"record_type": RECORD_TYPE_PAM_DATABASE, "rotation": "general", "protocol": "mysql", "port": "3306", "database_type": "mysql"}, + "MSSql": {"record_type": RECORD_TYPE_PAM_DATABASE, "rotation": "general", "protocol": "sql-server", "port": "1433", "database_type": "mssql"}, + "PostgreSQL": {"record_type": RECORD_TYPE_PAM_DATABASE, "rotation": "general", "protocol": "postgresql", "port": "5432", "database_type": "postgresql"}, + # Network devices — SSH-managed + "PaloAltoNetworks": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + "CiscoIOS": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + "CiscoIOSEnable": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + "CiscoASA": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + "JuniperJunos": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + "F5BigIP": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + "CheckPointGAIA": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + # CyberArk internal — service accounts, import as pamMachine/SSH + "CyberArk": {"record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22"}, + # Web — login record, NOT pamMachine + "BusinessWebsite": {"record_type": RECORD_TYPE_LOGIN, "rotation": None, "protocol": None, "port": None}, +} + +# Fallback mapping for accounts with empty or unknown platformId +FALLBACK_PLATFORM_MAP = { + "record_type": RECORD_TYPE_PAM_MACHINE, "rotation": "general", "protocol": "ssh", "port": "22", +} diff --git a/keepercommander/importer/cyberark/pam/dependents.py b/keepercommander/importer/cyberark/pam/dependents.py new file mode 100644 index 000000000..1b03242f2 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/dependents.py @@ -0,0 +1,194 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[str]: + """Map CyberArk's ``Type`` string to Keeper's service verb or ``None``. + + Returning ``None`` means we recognize the dependent but do not have a + Keeper equivalent (e.g. COM+ application). Callers should record those in + the unmapped-items list so admins can act on them manually. + """ + if not raw_type: + return None + key = "".join(ch for ch in str(raw_type).lower() if ch.isalnum()) + return _DEPENDENT_TYPE_ALIASES.get(key) + + +def resolve_account_dependents(client: 'CyberArkPVWAClient', + account: dict, + master_user_title: str) -> List[dict]: + """Fetch dependents for a CyberArk master account and shape them for replay. + + Each returned dict carries everything the post-import phase needs to call + ``pam action service add`` without re-querying CyberArk: + + * ``machine_address`` — host where the service runs (used to find the + Keeper PAM Machine record). + * ``service_type`` — Keeper verb (service|task|iis) or ``None`` for + unsupported categories. + * ``raw_type`` — original CyberArk ``Type`` string (kept for reporting). + * ``service_name`` — informational, surfaced in the report only. + * ``master_user_title`` — Keeper title of the pamUser record that holds + the rotated credential (i.e. the user the service runs as). + * ``master_account_id`` / ``master_account_name`` — CyberArk source IDs + preserved for the audit report. + + Network / authorization failures surface as an empty list — dependents + are best-effort metadata and must never abort the import. + """ + account_id = account.get("id", "") or "" + if not account_id: + return [] + raw_dependents = client.fetch_account_dependents(account_id) + if not raw_dependents: + return [] + + master_account_name = account.get("name", "") or "" + results: List[dict] = [] + dropped: List[dict] = [] + for dep in raw_dependents: + # Privilege Cloud nests the actually-useful fields under + # ``platformDependentProperties`` ({"address": "...", "serviceName": + # "..."} / {"taskName": "..."} / {"appPoolName": "..."}). Self-hosted + # PVWA flattens them. We try both. + props = (dep.get("platformDependentProperties") + or dep.get("PlatformDependentProperties") or {}) + if not isinstance(props, dict): + props = {} + + address = (_first_nonempty( + dep, + ("Address", "address", "Host", "host", "MachineAddress", + "machineAddress", "TargetAddress", "targetAddress", + "ComputerName", "computerName"), + ) or _first_nonempty( + props, + ("address", "Address", "host", "Host", "machineAddress", + "MachineAddress"), + )) + # ``platformId`` (Privilege Cloud) is the most reliable type signal — + # it returns concise category codes like ``WinService`` / ``SchedTask`` + # / ``IISAppPool``. Self-hosted PVWA exposes a ``type`` field with + # human-readable strings; we accept both. + raw_type = _first_nonempty( + dep, + ("platformId", "PlatformId", "PlatformID", "Type", "type", + "DependencyType", "dependentType", "dependencyType"), + ) + name = (_first_nonempty( + props, + ("serviceName", "ServiceName", "taskName", "TaskName", + "appPoolName", "AppPoolName", "name", "Name"), + ) or _first_nonempty( + dep, + ("Name", "name", "DependencyName", "dependentName", + "ServiceName", "serviceName"), + )) + dep_id = _first_nonempty( + dep, ("id", "Id", "ID", "DependencyID", "dependencyId"), + ) + if not address: + dropped.append(dep) + continue + results.append({ + "machine_address": address, + "service_type": _normalize_dependent_type(raw_type), + "raw_type": raw_type, + "service_name": name, + "master_user_title": master_user_title, + "master_account_id": account_id, + "master_account_name": master_account_name, + "dependent_account_id": str(dep_id) if dep_id else "", + }) + + if dropped: + try: + pretty = json.dumps(dropped, indent=2, sort_keys=True) + except (TypeError, ValueError): + pretty = repr(dropped) + print_formatted_text(HTML( + f"⚠ Skipped {len(dropped)} dependent(s) on " + f"account {_esc(master_account_name or account_id)} " + f"— no recognizable address field.")) + logging.warning( + "Dropped %d dependent(s) on account %s — no address field matched. " + "Entries: %s", + len(dropped), account_id, pretty, + ) + + if results: + logging.debug( + "Resolved %d CyberArk dependent(s) for account '%s'", + len(results), master_account_name or account_id, + ) + return results + + +def _first_nonempty(source: dict, keys: tuple) -> str: + """Return the first non-empty stringified value for any of ``keys`` in + ``source``. Mirrors the case-insensitive field-name shopping CyberArk + forces on us — Privilege Cloud uses lowercase, self-hosted PVWA uses + PascalCase, and individual platform plugins occasionally invent their own. + """ + for key in keys: + val = source.get(key) + if val is None: + continue + text = str(val).strip() + if text: + return text + return "" diff --git a/keepercommander/importer/cyberark/pam/idempotency.py b/keepercommander/importer/cyberark/pam/idempotency.py new file mode 100644 index 000000000..4851c2188 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/idempotency.py @@ -0,0 +1,385 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' [^;\]\s]+)" + r"(?:\s*;\s*safe=(?P[^\]]*))?\s*\]\s*$", + re.MULTILINE, +) + + +def format_id_marker(account_id: str, safe: str = "") -> str: + """Build the machine-readable marker line for ``notes``. + + Callers are expected to append this to the record's ``notes`` + field on its own line (preceded by a blank line if there is any + existing content) so :func:`parse_id_marker` can find it later. + """ + account_id = (account_id or "").strip() + safe = (safe or "").strip() + if not account_id: + return "" + if safe: + return f"[CyberArk-ID: account_id={account_id}; safe={safe}]" + return f"[CyberArk-ID: account_id={account_id}]" + + +def parse_id_marker(notes: Optional[str]) -> Optional[Tuple[str, str]]: + """Extract ``(account_id, safe)`` from a notes blob. + + Returns ``None`` when no marker is present. Only the last marker + is honored if a record somehow accumulated multiple — this handles + the (unlikely) case where an admin manually pasted an old marker + into the notes before a re-import. + """ + if not notes or not isinstance(notes, str): + return None + matches = list(_MARKER_RE.finditer(notes)) + if not matches: + return None + last = matches[-1] + return (last.group("id") or "").strip(), (last.group("safe") or "").strip() + + +def strip_id_marker(notes: Optional[str]) -> str: + """Return ``notes`` with any CyberArk-ID marker line(s) removed. + + Used to compute the "user-visible notes" for diffing so a marker + line the importer appended does not by itself trigger an update + on the next run. + """ + if not notes: + return "" + stripped = _MARKER_RE.sub("", notes).rstrip() + return stripped + + +def annotate_record_with_marker(record: dict, account_id: str, safe: str) -> None: + """Append the CyberArk identity marker to ``record['notes']`` in place. + + Safe to call multiple times on the same dict — existing markers are + replaced with the new one so re-runs don't accumulate marker + lines. ``record`` is mutated; nothing is returned. + """ + marker = format_id_marker(account_id, safe) + if not marker: + return + existing = record.get("notes") or "" + body = strip_id_marker(existing) + if body: + record["notes"] = f"{body}\n\n{marker}" + else: + record["notes"] = marker + + +# --------------------------------------------------------------------------- +# Existing-record index +# --------------------------------------------------------------------------- + + +@dataclass +class ExistingRecordIndex: + """Lookup tables for records that already live in the target project. + + Built once per import run from a walk of the project's shared + folder tree. Two indexes are maintained so we can honor the + CyberArk-ID marker when it is present and fall back to a + deterministic-title match when it is not. + """ + + # account_id -> loaded KeeperRecord + by_account_id: Dict[str, Any] = field(default_factory=dict) + + # (record_type, casefold(title)) -> loaded KeeperRecord + by_title: Dict[Tuple[str, str], Any] = field(default_factory=dict) + + # record_uid -> parent folder UID (so callers can decide whether a + # matched record still lives under the expected safe folder). + folder_by_record: Dict[str, str] = field(default_factory=dict) + + # Every folder UID under the project tree we scanned. Callers + # use this to restrict "already exists" matches to the target + # project instead of the whole vault. + scanned_folder_uids: Set[str] = field(default_factory=set) + + def lookup(self, account_id: str, record_type: str, title: str) -> Optional[Any]: + """Find an existing Keeper record for an incoming CyberArk record. + + Preference order: + + 1. Match on ``account_id`` marker (strongest — survives title changes). + 2. Fallback tuple match on ``(record_type, casefold(title))`` + limited to the scanned project folders. + + Returns ``None`` when no match is found. + """ + if account_id: + hit = self.by_account_id.get(account_id.strip()) + if hit is not None: + return hit + key = ((record_type or "").strip(), (title or "").strip().casefold()) + return self.by_title.get(key) + + +def build_existing_index(params, folder_uids) -> ExistingRecordIndex: + """Scan every record under ``folder_uids`` and index it by marker + title. + + ``folder_uids`` is an iterable of the shared folders that make up + the target project (the Config folder plus every safe shared + folder). We recurse into each so records placed under the + ``Resources``/``Users`` subfolders are picked up. + + Reads are done via ``params.record_cache`` + ``KeeperRecord.load`` + so no network round-trip is needed as long as the caller has + ``sync_down``-ed recently. + """ + # ``idempotency.py`` lives at + # ``keepercommander.importer.cyberark.pam.idempotency``, so four dots + # are needed to reach the top-level ``keepercommander.vault`` module + # (three dots would land at ``keepercommander.importer``, where no + # ``vault`` symbol exists). + from .... import vault # local import: keep module import graph light + + index = ExistingRecordIndex() + + folder_uids = list(folder_uids or []) + if not folder_uids: + return index + + # Guard against a stale/partially initialized ``params``: both caches + # are populated by ``api.sync_down``, but a fresh session without a + # sync will have them as ``None`` or empty dicts. Bail early so the + # importer falls back to always-create mode instead of crashing. + subfolder_record_cache = getattr(params, "subfolder_record_cache", None) or {} + folder_cache = getattr(params, "folder_cache", None) or {} + if not folder_cache: + return index + + # Recursively collect record UIDs from every subfolder. + stack = list(folder_uids) + visited: Set[str] = set() + all_record_uids: Set[str] = set() + while stack: + fuid = stack.pop() + if not fuid or fuid in visited: + continue + visited.add(fuid) + index.scanned_folder_uids.add(fuid) + for ruid in (subfolder_record_cache.get(fuid) or set()): + all_record_uids.add(ruid) + index.folder_by_record[ruid] = fuid + folder = folder_cache.get(fuid) + for sub_uid in (getattr(folder, "subfolders", []) or []) if folder else []: + stack.append(sub_uid) + + for ruid in all_record_uids: + rec = vault.KeeperRecord.load(params, ruid) + if rec is None: + continue + rtype = getattr(rec, "record_type", "") or "" + title = getattr(rec, "title", "") or "" + + # Marker index + marker = parse_id_marker(getattr(rec, "notes", "") or "") + if marker and marker[0]: + # If duplicates exist (a bug or manual copy), keep the + # first — logging the collision so admins notice. + if marker[0] in index.by_account_id: + logging.debug( + "Duplicate CyberArk-ID marker %s on records %s and %s; " + "keeping the first for idempotency lookup.", + marker[0], + getattr(index.by_account_id[marker[0]], "record_uid", "?"), + ruid, + ) + else: + index.by_account_id[marker[0]] = rec + + # Title fallback index + if rtype and title: + key = (rtype, title.casefold()) + index.by_title.setdefault(key, rec) + + return index + + +# --------------------------------------------------------------------------- +# Partition decision +# --------------------------------------------------------------------------- + + +class IdempotencyDecision(Enum): + CREATE = "create" # No existing match → send through pam project import + UPDATE = "update" # Match exists but data differs → update in place + UNCHANGED = "unchanged" # Match exists and every mapped field matches → skip + + +@dataclass +class RecordDecision: + """Decision for a single top-level mapped record (or nested pamUser).""" + + decision: IdempotencyDecision + incoming: dict # The mapped-record dict from AccountMapper + existing: Optional[Any] = None # Loaded KeeperRecord when matched + account_id: str = "" # Parsed from marker embedded in ``notes`` + change_fields: List[str] = field(default_factory=list) # Diagnostic + + +def _extract_marker(incoming: dict) -> Tuple[str, str]: + """Return ``(account_id, safe)`` from an incoming mapped record.""" + marker = parse_id_marker(incoming.get("notes") or "") + if marker: + return marker + return "", "" + + +def partition_records(mapped_resources: List[dict], + mapped_users: List[dict], + existing: ExistingRecordIndex, + diff_fn) -> List[RecordDecision]: + """Categorize incoming CyberArk records against the existing index. + + ``mapped_resources`` and ``mapped_users`` are the two lists + ``AccountMapper`` populates. Nested ``users`` under a resource + are partitioned separately because Keeper stores them as their + own records; the parent resource's decision is independent of its + users' decisions. + + ``diff_fn(existing_record, incoming_dict)`` is a callable that + returns the list of field names where the two disagree. Passed + in so this module stays free of Keeper record-shape knowledge — + it lives in ``cyberark_import.py`` where the mapper's schema is + already known. + + Nested users are yielded as separate :class:`RecordDecision` + entries with their parent-resource dict re-flattened onto them: + the caller uses this list to build both the filtered import + payload and the post-import update batch. + """ + decisions: List[RecordDecision] = [] + + def _decide(incoming: dict) -> RecordDecision: + account_id, _ = _extract_marker(incoming) + rtype = incoming.get("type", "") or "" + title = incoming.get("title", "") or "" + existing_rec = existing.lookup(account_id, rtype, title) + if existing_rec is None: + return RecordDecision( + decision=IdempotencyDecision.CREATE, + incoming=incoming, + account_id=account_id, + ) + change_fields = diff_fn(existing_rec, incoming) or [] + if change_fields: + return RecordDecision( + decision=IdempotencyDecision.UPDATE, + incoming=incoming, + existing=existing_rec, + account_id=account_id, + change_fields=list(change_fields), + ) + return RecordDecision( + decision=IdempotencyDecision.UNCHANGED, + incoming=incoming, + existing=existing_rec, + account_id=account_id, + ) + + for res in mapped_resources or []: + decisions.append(_decide(res)) + for user in res.get("users") or []: + decisions.append(_decide(user)) + + for user in mapped_users or []: + decisions.append(_decide(user)) + + return decisions + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + + +@dataclass +class PartitionSummary: + created: int = 0 + updated: int = 0 + unchanged: int = 0 + + @property + def total(self) -> int: + return self.created + self.updated + self.unchanged + + def as_dict(self) -> Dict[str, int]: + return { + "created": self.created, + "updated": self.updated, + "unchanged": self.unchanged, + "total": self.total, + } + + +def summarize(decisions: List[RecordDecision]) -> PartitionSummary: + summary = PartitionSummary() + for d in decisions: + if d.decision is IdempotencyDecision.CREATE: + summary.created += 1 + elif d.decision is IdempotencyDecision.UPDATE: + summary.updated += 1 + elif d.decision is IdempotencyDecision.UNCHANGED: + summary.unchanged += 1 + return summary diff --git a/keepercommander/importer/cyberark/pam/import_builder.py b/keepercommander/importer/cyberark/pam/import_builder.py new file mode 100644 index 000000000..b0596a3ac --- /dev/null +++ b/keepercommander/importer/cyberark/pam/import_builder.py @@ -0,0 +1,468 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' dict: + """Aggregate safe member permissions into Keeper shared folder permission format. + + Merges permissions across all safes — if a user appears in multiple safes, + they get the highest permission tier across all of them. + + Returns dict with 'shared_folder_resources' and 'shared_folder_users' keys + matching the format expected by edit.py get_folder_permissions(). + """ + if not safe_member_map: + return {} + + # Aggregate: for each member, take the highest permission across safes + member_perms = {} # (name, member_type) → highest permissions dict + for safe_id, members in safe_member_map.items(): + for m in members: + name = m.get("name", "") + mtype = m.get("member_type", "user") + perms = m.get("permissions", {}) + key = (name.lower(), mtype) + + if key not in member_perms: + member_perms[key] = {"name": name, "member_type": mtype, "permissions": dict(perms)} + else: + # Merge — take the more permissive value for each field + existing = member_perms[key]["permissions"] + for field in ("manage_users", "manage_records", "can_edit", "can_share"): + if perms.get(field, False): + existing[field] = True + + # Build permission entries, matching to Keeper users/teams if possible + permission_entries = [] + for (name_lower, mtype), entry in member_perms.items(): + name = entry["name"] + perms = entry["permissions"] + + # Try to match to a Keeper user or team + matched_name = None + if user_team_matcher: + if mtype == "user": + matched_name = user_team_matcher.match_user(name) + elif mtype == "team": + matched_name = user_team_matcher.match_team(name) + + if not matched_name: + continue # Skip unmatched — they'll appear in the CSV report + + perm_entry = { + "name": matched_name, + "manage_users": perms.get("manage_users", False), + "manage_records": perms.get("manage_records", False), + } + permission_entries.append(perm_entry) + + if not permission_entries: + return {} + + # Both shared folders get the same permission set (resources and users folders) + folder_perms = { + "manage_users": True, + "manage_records": True, + "can_edit": True, + "can_share": True, + "permissions": permission_entries, + } + return { + "shared_folder_resources": dict(folder_perms), + "shared_folder_users": dict(folder_perms), + } + + +def build_safe_folders(safe_member_map: Dict[str, List[dict]], + folder_mapper: 'SafeFolderMapper', + user_team_matcher: Optional['UserTeamMatcher'] = None, + ) -> List[dict]: + """Build the per-safe ``safe_folders`` list for the import JSON. + + Each entry maps to a Keeper shared folder named after the CyberArk safe + (after ``SafeFolderMapper`` sanitization) and carries *only* that safe's + permission set — no cross-safe aggregation, so members of safe A do not + leak access to safe B. + + Only safes that had at least one account successfully mapped during + import appear in ``folder_mapper.iter_mapped()`` — empty CyberArk safes + (or safes whose accounts were all skipped) do not get a folder entry. + """ + if folder_mapper is None or folder_mapper.mode != "safe": + return [] + + # Resolved folder name → raw safe name (first wins so report ordering + # mirrors the order safes were processed in the orchestrator). + folder_to_safe: Dict[str, str] = {} + for raw, resolved in folder_mapper.iter_mapped(): + if not resolved: + continue + folder_to_safe.setdefault(resolved, raw) + + safe_folders: List[dict] = [] + for resolved, raw in folder_to_safe.items(): + members = safe_member_map.get(raw) or [] + permission_entries: List[dict] = [] + for m in members: + name = m.get("name", "") + mtype = m.get("member_type", "user") + perms = m.get("permissions", {}) or {} + matched_name = None + if user_team_matcher: + if mtype == "user": + matched_name = user_team_matcher.match_user(name) + elif mtype == "team": + matched_name = user_team_matcher.match_team(name) + if not matched_name: + # Unmatched principals are surfaced via the CSV report; do + # not silently grant them access to the safe folder. + continue + permission_entries.append({ + "name": matched_name, + "manage_users": bool(perms.get("manage_users", False)), + "manage_records": bool(perms.get("manage_records", False)), + }) + + safe_folders.append({ + "name": resolved, + "safe_name": raw, + "manage_users": True, + "manage_records": True, + "can_edit": True, + "can_share": True, + "permissions": permission_entries, + }) + return safe_folders + + +def validate_import_data(resources: List[dict], users: List[dict]) -> List[str]: + """Pre-import validation. Returns list of warning strings.""" + warnings = [] + + # Resources missing host/address + for r in resources: + if not r.get("host"): + warnings.append(f'Resource "{r.get("title", "?")}" has no host/address') + + # Nested users missing password (will be created without credentials) + no_pw = [] + for r in resources: + for u in r.get("users", []): + if not u.get("password") and not u.get("private_pem_key"): + no_pw.append(u.get("title", "?")) + # External users missing password + for u in users: + if not u.get("password") and not u.get("private_pem_key"): + no_pw.append(u.get("title", "?")) + if no_pw: + warnings.append(f'{len(no_pw)} user(s) have no password or SSH key ' + f'— will be created without credentials') + + # External (unnested) users without resource linkage + if users: + warnings.append(f'{len(users)} standalone login record(s) not linked to a resource') + + # Rotation enabled but no password (rotation will fail) + for r in resources: + for u in r.get("users", []): + rs = u.get("rotation_settings", {}) + if (rs.get("enabled") == "on" + and not u.get("password") + and not u.get("private_pem_key")): + warnings.append( + f'User "{u.get("title", "?")}" has rotation enabled but ' + f'no password/key — rotation will fail until credentials are set') + + return warnings + + +def build_import_json(project_name: str, gateway_name: Optional[str], + resources: List[dict], users: List[dict], + safe_member_map: Optional[Dict[str, List[dict]]] = None, + user_team_matcher: Optional['UserTeamMatcher'] = None, + master_policy_config: Optional[dict] = None, + folder_mapper: Optional['SafeFolderMapper'] = None, + ) -> dict: + """Build the pam project import JSON from mapped records.""" + mp = master_policy_config or {} + # Imported from CyberArk Master Policy "Require password change every X + # days" (PasswordChangeDays) when present — see MasterPolicyMapper. Falls + # back to on-demand so an admin without master-policy read access still + # gets a working PAM Configuration. + rotation_schedule = mp.get("default_rotation_schedule") or {"type": SCHEDULE_ON_DEMAND} + if not isinstance(rotation_schedule, dict): + rotation_schedule = {"type": SCHEDULE_ON_DEMAND} + pam_config = { + "environment": "local", + "title": f"{project_name} Configuration", + "connections": mp.get("connections", "on"), + "rotation": mp.get("rotation", "on"), + "tunneling": mp.get("tunneling", "on"), + "graphical_session_recording": mp.get("graphical_session_recording", "off"), + "text_session_recording": mp.get("text_session_recording", "off"), + # Keeper-specific features set explicitly so PamConfigEnvironment + # doesn't fall back to its on/off defaults that don't match the + # CyberArk migration intent. + "remote_browser_isolation": mp.get("remote_browser_isolation", "off"), + "ai_threat_detection": mp.get("ai_threat_detection", "off"), + "ai_terminate_session_on_detection": mp.get("ai_terminate_session_on_detection", "off"), + "default_rotation_schedule": rotation_schedule, + } + if gateway_name: + pam_config["gateway_name"] = gateway_name + + result = { + "project": project_name, + "pam_configuration": pam_config, + "pam_data": { + "resources": resources, + "users": users, + }, + } + + # ``safe`` mode emits one ``safe_folders`` entry per CyberArk safe so + # each one becomes its own Keeper shared folder with its own permission + # set. The legacy aggregated ``shared_folder_resources`` / + # ``shared_folder_users`` blocks are intentionally NOT emitted because + # they would cause edit.py's process_folders to create the old two- + # folder layout in addition to the new safe folders. + safe_mode = folder_mapper is not None and folder_mapper.mode == "safe" + if safe_mode: + safe_folders = build_safe_folders( + safe_member_map or {}, folder_mapper, user_team_matcher, + ) + if safe_folders: + result["safe_folders"] = safe_folders + elif safe_member_map: + sf_perms = build_shared_folder_permissions(safe_member_map, user_team_matcher) + if sf_perms: + result.update(sf_perms) + + return result + + +def build_extend_json(resources: List[dict], users: List[dict]) -> dict: + """Build the pam project extend JSON (pam_data only).""" + return { + "pam_data": { + "resources": resources, + "users": users, + } + } + + +def strip_credentials(data: dict): + """Remove passwords from import JSON for safe --output without --include-credentials.""" + pam_data = data.get("pam_data", {}) + for user in pam_data.get("users", []): + if "password" in user: + user["password"] = "***" + for resource in pam_data.get("resources", []): + for user in resource.get("users", []): + if "password" in user: + user["password"] = "***" + + +def format_duration(seconds: float) -> str: + """Format seconds as 'Xm Ys'. Handles negative, inf, nan.""" + if math.isnan(seconds) or math.isinf(seconds): + return "N/A" + seconds = max(0, min(seconds, 999999)) # clamp to ~11.5 days max + m = int(seconds) // 60 + s = int(seconds) % 60 + if m > 0: + return f"{m}m {s}s" + return f"{s}s" + + +def format_unmapped_section(unmapped_items: Optional[List[dict]]) -> str: + """Format the UNMAPPED manual-action section for debug logging.""" + if not unmapped_items: + return '' + lines = [ + ' UNMAPPED — REQUIRES MANUAL ACTION', + ' ' + '-' * 40, + ] + by_category = {} # type: Dict[str, List[dict]] + for item in unmapped_items: + cat = item.get("category", "Other") + by_category.setdefault(cat, []).append(item) + for cat, items in sorted(by_category.items()): + lines.append(f' {cat}:') + for item in items: + lines.append(f' {item.get("item", "")}') + lines.append(f' Action: {item.get("action", "")}') + lines.append('') + return "\n".join(lines) + + +def build_report(project_name: str, safes_processed: int, total_accounts: int, + resource_counts: Dict[str, Dict[str, int]], + platform_counts: Dict[str, Dict[str, Any]], + skipped: List[dict], incomplete_count: int, + duration: float, project_result: Optional[dict] = None, + unmapped_platforms: Optional[Dict[str, int]] = None, + unmapped_items: Optional[List[dict]] = None, + server: str = '') -> str: + """Build structured post-import report matching the spec.""" + lines = [] + lines.append('=' * 60) + lines.append(f' CyberArk PAM → KeeperPAM Migration Report') + lines.append(f' {project_name}') + lines.append('=' * 60) + lines.append('') + + # Source summary + lines.append(' SOURCE SUMMARY') + lines.append(' ' + '-' * 40) + if server: + lines.append(f' Server: {server}') + lines.append(f' Safes processed: {safes_processed}') + lines.append(f' Accounts found: {total_accounts}') + lines.append('') + + # Project assets + if project_result: + lines.append(' PROJECT ASSETS') + lines.append(' ' + '-' * 40) + gw = project_result.get("gateway", {}) + if gw: + gw_token = gw.get('gateway_token', '') + lines.append(f' Gateway: {gw.get("gateway_name", "N/A")} ({gw.get("gateway_uid", "N/A")})') + ksm = project_result.get("ksm_app", {}) + if ksm: + lines.append(f' KSM App: {ksm.get("app_uid", "N/A")}') + config = project_result.get("config_uid", "") + if config: + lines.append(f' Config UID: {config}') + folders = project_result.get("folders", {}) + if folders: + safe_entries = folders.get("safe_folders") or [] + if safe_entries: + # ``safe`` mode: one shared folder per CyberArk safe plus + # an admin-only Config folder. Each safe folder has two + # subfolders (``Resources`` for assets, ``Users`` for + # credentials) that inherit the safe's permission set. + # Surface them explicitly so the operator can confirm + # the per-safe permission separation is in place. + config_name = folders.get("config_folder", folders.get("resources_folder", "Config")) + config_uid_v = folders.get("config_folder_uid", folders.get("resources_folder_uid", "N/A")) + lines.append(f' Config: {config_name} ({config_uid_v})') + lines.append(f' Safe folders created: {len(safe_entries)} ' + f'(each contains " - Resources" and " - Users" subfolders)') + for entry in safe_entries: + if not isinstance(entry, dict): + continue + nm = entry.get("name", "?") + uid = entry.get("uid", "N/A") + safe_name = entry.get("safe_name", "") + suffix = f' [safe: {safe_name}]' if safe_name and safe_name != nm else '' + lines.append(f' • {nm} ({uid}){suffix}') + else: + lines.append(f' Resources: {folders.get("resources_folder", "N/A")} ({folders.get("resources_folder_uid", "N/A")})') + lines.append(f' Users: {folders.get("users_folder", "N/A")} ({folders.get("users_folder_uid", "N/A")})') + lines.append('') + + # Import results + lines.append(' IMPORT RESULTS') + lines.append(' ' + '-' * 40) + if not resource_counts: + resource_counts = {} + total_ok = total_skip = total_err = 0 + for rtype in ("pamMachine", "pamDatabase", "pamUser", "login"): + counts = resource_counts.get(rtype, {"ok": 0, "skip": 0, "err": 0}) + lines.append(f' {rtype:18s} {counts["ok"]:>4d} ok {counts["skip"]:>4d} skip {counts["err"]:>4d} err') + total_ok += counts["ok"] + total_skip += counts["skip"] + total_err += counts["err"] + lines.append(f' {"TOTAL":18s} {total_ok:>4d} ok {total_skip:>4d} skip {total_err:>4d} err') + lines.append(f' Duration: {format_duration(duration)}') + lines.append('') + + # Platform mapping + if platform_counts: + lines.append(' PLATFORM MAPPING') + lines.append(' ' + '-' * 40) + for pid, info in sorted(platform_counts.items()): + rotation = info.get("rotation", "N/A") + count = info.get("count", 0) + marker = "" + if unmapped_platforms and pid in unmapped_platforms: + marker = " ← use --platform-map" + rotation = "UNMAPPED" + lines.append(f' {pid:20s} → {rotation:12s} ({count} accounts){marker}') + lines.append('') + + # Skipped accounts + if skipped: + password_failed = sum(1 for s in (skipped or []) if s.get("reason") == "password retrieval failed") + cpm_disabled = sum(1 for s in (skipped or []) if s.get("reason") == "CPM disabled") + lines.append(' SKIPPED ACCOUNTS') + lines.append(' ' + '-' * 40) + if cpm_disabled: + lines.append(f' Manual mgmt (CPM disabled): {cpm_disabled}') + if password_failed: + lines.append(f' Password retrieval failed: {password_failed}') + if incomplete_count: + lines.append(f' Incomplete (missing fields): {incomplete_count}') + lines.append('') + + # UNMAPPED details are debug-only (see format_unmapped_section / logging.debug) + if unmapped_items: + logging.debug('\n%s', format_unmapped_section(unmapped_items)) + + # Gateway deployment + gw_token = '' + if project_result: + gw_token = project_result.get("gateway", {}).get("gateway_token", "") + if gw_token: + lines.append(' GATEWAY DEPLOYMENT') + lines.append(' ' + '-' * 40) + lines.append(f' Access Token: {gw_token}') + lines.append('') + lines.append(f' docker run -d --name keeper-gateway \\') + lines.append(f' -e GATEWAY_CONFIG="{gw_token}" \\') + lines.append(f' -e ACCEPT_EULA=Y --shm-size=2g \\') + lines.append(f' --restart unless-stopped keeper/gateway:latest') + lines.append('') + + # Next steps + lines.append(' NEXT STEPS') + lines.append(' ' + '-' * 40) + step = 1 + if unmapped_items: + lines.append(f' {step}. Review unmapped items in debug log (--debug)') + step += 1 + lines.append(f' {step}. Verify: pam gateway list') + step += 1 + lines.append(f' {step}. Cleanup: pam project cyberark-cleanup --name "{project_name}"') + lines.append('') + + # Command (redacted) + lines.append(' COMMAND (redacted)') + lines.append(' ' + '-' * 40) + cmd = f'pam project cyberark-import {server}' + if project_name: + cmd += f' --name "{project_name}"' + lines.append(f' {cmd}') + lines.append('') + lines.append('=' * 60) + + return "\n".join(lines) diff --git a/keepercommander/importer/cyberark/pam/linked_accounts.py b/keepercommander/importer/cyberark/pam/linked_accounts.py new file mode 100644 index 000000000..93e4d3720 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/linked_accounts.py @@ -0,0 +1,147 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' List[dict]: + """Resolve linked accounts (logon, reconcile, enable) for an account. + + Fetches the full account details to get linkedAccounts, then fetches + each linked account and maps it as a pamUser record. + + Returns list of pamUser dicts with role annotations. + """ + account_id = account.get("id", "") + details = client.fetch_account_details(account_id) + if not details: + return [] + + linked = details.get("linkedAccounts") or {} + if not isinstance(linked, dict): + logging.warning('Unexpected linkedAccounts type: %s for account %s', + type(linked).__name__, account_id) + return [] + result = [] + + for role, link_data in linked.items(): + if not isinstance(link_data, dict) or not link_data.get("id"): + continue + role_name = role.replace("Account", "").lower() # logonAccount → logon + + # Fetch the linked account's password + linked_id = link_data["id"] + # Validate linked account ID format (same check as fetch_account_details) + if not re.match(r'^[a-zA-Z0-9_]+$', str(linked_id)): + logging.warning('Invalid linked account ID: %s — skipping', + re.sub(r'[^a-zA-Z0-9_]', '?', str(linked_id))) + continue + linked_name = link_data.get("name", "") + linked_safe = link_data.get("safeName", account.get("safeName", "")) + # Note: skip_all dict not passed here — linked account password + # failures don't share the "Skip All" state with the main loop. + # This is acceptable since linked accounts are typically few per resource. + password = client.retrieve_password(linked_id, linked_name, linked_safe) + + # Build pamUser record for the linked account + user_title = f"{linked_name} ({role_name} account)" + linked_user = { + "type": "pamUser", + "title": user_title, + "login": link_data.get("userName", linked_name), + "password": password or "", + "notes": f"CyberArk role: {role_name} account\n" + f"Linked to: {account.get('name', account_id)}\n" + f"Source safe: {linked_safe}", + "_ca_role": role_name, # Internal: logon, reconcile, or enable + "_ca_id": str(linked_id), # Internal: used by idempotency layer + "_ca_safe": linked_safe, # Internal: used by idempotency layer + } + # Embed CyberArk identity marker so re-imports can match this + # linked account to the existing Keeper record. The linked + # account has its own CyberArk id distinct from the master + # account it decorates, so we tag with ``linked_id`` (not the + # outer ``account_id``). + try: + from .idempotency import annotate_record_with_marker + annotate_record_with_marker(linked_user, str(linked_id), linked_safe) + except Exception: # noqa: BLE001 — never block linked-account resolution on annotation failure + logging.debug("Failed to annotate linked account %s with CyberArk-ID marker", linked_id) + result.append(linked_user) + logging.info('Resolved linked %s account: %s', role_name, user_title) + + return result + + +def pick_launch_credentials(linked_users: List[dict]) -> Optional[str]: + """Pick which linked account populates Keeper's launch_credentials slot. + + CyberArk PSM uses the logonAccount to establish the initial connection + (e.g. SSH as a less-privileged service account), then switches (sudo/su) + to the target account. Keeper's launch_credentials is the connection + credential, so the logon account is its natural fit when present. + + Returns the logon account's title, or None if no logon is linked. + """ + for lu in linked_users: + if lu.get("_ca_role") == "logon": + return lu.get("title") + return None + + +def pick_admin_credentials(linked_users: List[dict]) -> Tuple[Optional[str], Optional[str]]: + """Pick which linked account populates Keeper's administrative_credentials slot. + + CyberArk has three linked-account roles (logon, reconcile, enable) but + Keeper resources have only one administrative_credentials slot. Reconcile + is preferred because it is the account CyberArk CPM uses for password + rotation recovery — the most common privileged-management path. Enable + is used as a fallback when no reconcile account is linked. + + Returns (title, role) of the chosen account, or (None, None) if none present. + """ + for lu in linked_users: + if lu.get("_ca_role") == "reconcile": + return lu.get("title"), "reconcile" + for lu in linked_users: + if lu.get("_ca_role") == "enable": + return lu.get("title"), "enable" + return None, None + + +def detect_dual_account(account: dict) -> Optional[Dict[str, str]]: + """Detect if an account is part of a dual-account/rotational group. + + CyberArk dual accounts have VirtualUserName and/or GroupPlatformID + in their platformAccountProperties. + + Returns dict of custom fields to add, or None if not a dual account. + """ + if not isinstance(account, dict): + return None + props = account.get("platformAccountProperties") or {} + virtual_user = props.get("VirtualUserName", "") + group_platform = props.get("GroupPlatformID", "") + index = props.get("Index", "") + + if not virtual_user and not group_platform: + return None + + fields = {} + if virtual_user: + fields["ca_virtual_username"] = virtual_user + if group_platform: + fields["ca_dual_account_group"] = group_platform + if index: + fields["ca_dual_account_index"] = index + + return fields diff --git a/keepercommander/importer/cyberark/pam/master_policy_mapper.py b/keepercommander/importer/cyberark/pam/master_policy_mapper.py new file mode 100644 index 000000000..5957fde77 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/master_policy_mapper.py @@ -0,0 +1,481 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[dict]: + """Translate a CyberArk change-interval (days) → Keeper schedule dict. + + Mirrors the Master Policy's own conversion: any positive interval + becomes a CRON schedule via ``days_to_cron``; ``0``/unset means + "no periodic change" → ``on-demand``. CyberArk's + ``allowedPeriodic``/``performPeriodicChange`` bookkeeping flag is + intentionally not consulted here — an explicit change-interval + exception (e.g. Win Local Admins every 30 days vs. the master's 90) + should be honored as a real CRON cadence in Keeper, the same way + the Master Policy default itself always is. + """ + cron = MasterPolicyMapper.days_to_cron(days) + return {"type": "CRON", "cron": cron} if cron else {"type": SCHEDULE_ON_DEMAND} + + @staticmethod + def _exception_platform_id(entry: dict) -> Optional[str]: + for key in MasterPolicyMapper._PLATFORM_ID_KEYS: + val = entry.get(key) + if val not in (None, ""): + return str(val).strip() + return None + + @staticmethod + def _exception_change_block(entry: dict) -> dict: + change = entry.get("change") + if isinstance(change, dict): + return change + return entry + + @staticmethod + def _exception_days(change: dict) -> int: + for key in MasterPolicyMapper._CHANGE_DAYS_RULES + ("interval", "Interval"): + if key not in change: + continue + try: + return int(change.get(key) or 0) + except (TypeError, ValueError): + return 0 + return 0 + + @staticmethod + def parse_rotation_exceptions(data: Any) -> Dict[str, dict]: + """Normalize ``GET .../master-rotation-policy/exceptions/`` payloads. + + CyberArk ISPSS returns:: + + { + "exceptions": [ + {"platformId": "WinDesktopLocal", + "verifyInterval": 7, "changeInterval": 30} + ], + "totalCount": 1 + } + + Older/alternate shapes (bare list, ``{"value": [...]}``, rule-grouped + dicts) are also accepted. + + Returns ``{platformId: schedule_dict}`` for every change-interval + exception we can translate. Verification-only exceptions are ignored + here — Keeper has no separate verification schedule. + """ + if not data: + return {} + + items: List[dict] = [] + if isinstance(data, list): + items = [x for x in data if isinstance(x, dict)] + elif isinstance(data, dict): + value = data.get("value") + if isinstance(value, list): + items.extend(x for x in value if isinstance(x, dict)) + for key in ("changeInterval", "ChangeInterval", "change", "Change", + "exceptions", "Exceptions"): + grouped = data.get(key) + if isinstance(grouped, list): + items.extend(x for x in grouped if isinstance(x, dict)) + if not items and MasterPolicyMapper._exception_platform_id(data): + items = [data] + + schedules: Dict[str, dict] = {} + for entry in items: + pid = MasterPolicyMapper._exception_platform_id(entry) + if not pid: + continue + change = MasterPolicyMapper._exception_change_block(entry) + days = MasterPolicyMapper._exception_days(change) + if days <= 0: + # No change-interval given for this exception entry (e.g. a + # verification-only exception) — nothing to translate. + continue + schedules[pid] = MasterPolicyMapper.change_settings_to_schedule(days) + return schedules + + @staticmethod + def normalize(policy_data: dict) -> Dict[str, Any]: + """Flatten any of the three Master Policy response shapes. + + - **Self-Hosted ``/Policies/1``** → ``{"DualControl": {"Value": true}}`` + becomes ``{"DualControl": True}``. + - **Privilege Cloud ``/Policy/MasterPolicy``** → + ``{"Policy": {"Rules": [{"RuleName": "X", "Active": true, + "Value": 7}]}}`` becomes ``{"X": True}`` for boolean rules, or + ``{"X": 7}`` when only ``Value`` is present (SafeAuditRetention). + - **ISPSS ``/api/platforms/master-rotation-policy/``** → flat + camelCase JSON. Booleans/ints are kept as-is. If the server wraps + related settings in groups (e.g. ``{"credentialsManagement": { + "requirePasswordChangeEveryXDays": 90, ... }}`` — the same shape + ``Get-Platforms`` uses), nested dicts are flattened one level down + so the same rule lookup works for every endpoint. + """ + rules: Dict[str, Any] = {} + + # ── Format B: Privilege Cloud "Rules" array ────────────── + policy_obj = policy_data.get("Policy", None) + if isinstance(policy_obj, dict): + for rule in (policy_obj.get("Rules") or []): + if not isinstance(rule, dict): + continue + name = rule.get("RuleName", "") + if not name: + continue + if "Active" in rule: + rules[name] = bool(rule.get("Active")) + elif "Value" in rule: + rules[name] = rule.get("Value") + + # ── Formats A & C: flat dicts (Self-Hosted wrapped, ISPSS bare) ── + # We iterate the top-level dict (skipping the "Policy" key we + # already consumed) so a server returning more than one shape still + # parses. ISPSS responses occasionally nest related rules inside a + # group object — we flatten nested dicts one level so the lookup + # tables in map_policy() find them under their leaf name. + for name, val in policy_data.items(): + if name == "Policy": + continue + if isinstance(val, dict): + if "Value" in val and len(val) == 1: + rules[name] = val["Value"] + continue + # Nested ISPSS group (e.g. credentialsManagement, + # sessionManagement, privilegedAccessWorkflows). Also + # surface the parent key so callers can introspect groups + # if needed, but rules lookup uses leaf names. + for subname, subval in val.items(): + if isinstance(subval, dict) and "Value" in subval: + rules.setdefault(subname, subval["Value"]) + elif isinstance(subval, (bool, int, float, str)): + rules.setdefault(subname, subval) + continue + if isinstance(val, (bool, int, float, str)): + rules.setdefault(name, val) + return rules + + @staticmethod + def first_rule(rules: Dict[str, Any], names: Tuple[str, ...]) -> Tuple[bool, Any]: + """Return ``(found, value)`` for the first alias present in ``rules``.""" + for n in names: + if n in rules: + return True, rules[n] + return False, None + + @staticmethod + def days_to_cron(days: int) -> Optional[str]: + """Convert CyberArk PasswordChangeDays → Quartz 6-field CRON string. + + Keeper's rotation engine validates expressions with + ``validate_cron_expression(..., for_rotation=True)`` which requires + SIX fields (``sec min hour dom month dow``) and a ``?`` placeholder + in either day-of-month or day-of-week. CyberArk only tells us the + cadence (e.g. "every 90 days") — not the specific time/day — so we + bucket the cadence into the closest "round" Quartz schedule that + Keeper accepts. The chosen anchor (midnight on the 1st) matches + CyberArk's own default rotation behavior of running CPM jobs after + midnight on the next eligible day. + + Returns ``None`` when ``days <= 0`` so callers fall back to the + on-demand default. + """ + try: + d = int(days) + except (TypeError, ValueError): + return None + if d <= 0: + return None + # Cap absurd intervals (e.g. 10000 days) at annual so callers cannot + # accidentally produce surprising multi-year bucket math. + if d > 365: + logging.debug( + "PasswordChangeDays=%s exceeds 365 — capping to annual CRON", d, + ) + return "0 0 0 1 1 ?" + # Each branch produces a 6-field Quartz expression (sec min hour dom + # month dow) with ``?`` in either dom or dow as Keeper's + # validate_cron_expression(for_rotation=True) requires. Only the + # ``*/N`` step form is accepted by the validator regex, so we avoid + # ``X/N``-style anchors. + if d == 1: + return "0 0 0 * * ?" + if d <= 6: + # Every N days at midnight (step on day-of-month) + return f"0 0 0 */{d} * ?" + if d <= 13: + # Weekly — Sunday (Quartz dow 1 = Sunday) + return "0 0 0 ? * 1" + if d <= 27: + # Bi-weekly — the 1st and 15th of each month at midnight + return "0 0 0 1,15 * ?" + if d <= 59: + # Monthly — first day of every month + return "0 0 0 1 * ?" + if d <= 89: + # Bi-monthly — first day of every other month + return "0 0 0 1 */2 ?" + if d <= 364: + months = (d + 15) // 30 + if months >= 12: + return "0 0 0 1 1 ?" + return f"0 0 0 1 */{months} ?" + # Exactly 365 days — annual + return "0 0 0 1 1 ?" + + @staticmethod + def map_policy(policy_data: Optional[dict]) -> Tuple[dict, List[dict]]: + """Map Master Policy to PAM config settings. + + Returns ``(pam_config_updates, unmapped_items)``. + """ + if not policy_data or not isinstance(policy_data, dict): + return dict(MasterPolicyMapper.DEFAULTS), [] + + # Backward compat: tests pass {"Policy": }; treat anything + # that isn't a dict as a missing policy section but still try the + # flat top-level format above it. + policy_section = policy_data.get("Policy", None) + if "Policy" in policy_data and not isinstance(policy_section, dict): + # Caller explicitly sent a malformed Policy block — keep the + # historical behavior of returning the bare defaults so existing + # crash-case tests stay green. + return dict(MasterPolicyMapper.DEFAULTS), [] + + rules = MasterPolicyMapper.normalize(policy_data) + config = dict(MasterPolicyMapper.DEFAULTS) + unmapped: List[dict] = [] + + # ── Session recording (Active → on) ───────────────────── + found, val = MasterPolicyMapper.first_rule(rules, MasterPolicyMapper._RECORDING_RULES) + if found: + on = bool(val) + config["graphical_session_recording"] = "on" if on else "off" + config["text_session_recording"] = "on" if on else "off" + + # ── Transparent connections ───────────────────────────── + found, val = MasterPolicyMapper.first_rule(rules, MasterPolicyMapper._CONNECTION_RULES) + if found: + config["connections"] = "on" if bool(val) else "off" + + # ── PSM monitoring (no direct Keeper toggle) ──────────── + # If CyberArk requires monitoring + isolation we leave Keeper's + # connections=on but flag the granular PSM behavior as unmapped so + # the admin reviews it after migration. + found, val = MasterPolicyMapper.first_rule(rules, MasterPolicyMapper._MONITORING_RULES) + if found and bool(val): + config["connections"] = "on" + unmapped.append({ + "category": "Master Policy", + "item": "Privileged session monitoring & isolation = Active", + "action": "Enable session recording + connection auditing on the " + "Keeper PAM Configuration; PSM-style isolation is " + "delivered by the Keeper Gateway and Connection records.", + }) + + # ── Rotation cadence (Require password change every X days) ── + # This is the part of the Master Rotation Policy described at + # docs.cyberark.com/.../privcloud_get_masterpolicy.htm. The cadence + # is exposed under different field names depending on the API: + # - PVWA self-hosted /Policies/1 → ``PasswordChangeDays`` + # - ISPSS /api/platforms/master-rotation-policy/ → + # ``passwordChangeDays`` / ``requirePasswordChangeEveryXDays`` + # We translate the integer into Keeper's ``default_rotation_schedule`` + # so newly-imported PAM users inherit the same rotation frequency. + found, raw_days = MasterPolicyMapper.first_rule( + rules, MasterPolicyMapper._CHANGE_DAYS_RULES) + if found: + try: + days = int(raw_days or 0) + except (TypeError, ValueError): + days = 0 + config["password_change_days"] = days + cron = MasterPolicyMapper.days_to_cron(days) + if cron: + config["default_rotation_schedule"] = {"type": "CRON", "cron": cron} + elif days == 0: + # 0 in CyberArk = "do not auto-change"; leave default on-demand + # but warn the admin so they don't think rotation is enforced. + unmapped.append({ + "category": "Master Policy", + "item": "Password change interval = 0 (auto-change disabled)", + "action": "Auto-rotation was disabled in CyberArk. Imported " + "records default to on-demand rotation in Keeper — " + "enable a CRON schedule per record/PAM Config when " + "ready to rotate.", + }) + + # ── Verification cadence (no direct Keeper equivalent) ── + found, raw_vdays = MasterPolicyMapper.first_rule( + rules, MasterPolicyMapper._VERIFY_DAYS_RULES) + if found: + try: + vdays = int(raw_vdays or 0) + except (TypeError, ValueError): + vdays = 0 + if vdays > 0: + unmapped.append({ + "category": "Master Policy", + "item": f"Password verification interval = {vdays} days", + "action": "Keeper does not run separate password-verification " + "jobs; rotation itself validates credentials. Set a " + "tighter rotation schedule if periodic verification " + "is required.", + }) + + # ── Audit retention (RetentionPeriod or SafeAuditRetention) ── + found, val = MasterPolicyMapper.first_rule(rules, MasterPolicyMapper._AUDIT_RETENTION_RULES) + if found and val not in (None, 0, False): + unmapped.append({ + "category": "Master Policy", + "item": f"Audit retention = {val} days", + "action": "Configure audit retention at the vault level in the " + "Keeper Admin Console (Reporting & Alerts).", + }) + + # ── Other policies with no Keeper equivalent ──────────── + unmapped_aliases = ( + (MasterPolicyMapper._DUAL_CONTROL_RULES, "Dual control approval", + "Use ticketing integration (ServiceNow/Jira) for approval workflows"), + (MasterPolicyMapper._EXCLUSIVE_RULES, "Exclusive checkout", + "Use time-limited record sharing in KeeperPAM"), + (MasterPolicyMapper._ONE_TIME_RULES, "One-time password access", + "Enable post-use rotation in KeeperPAM rotation settings"), + (("MultiLevelApproval", "multiLevelApproval"), "Multi-level approval", + "Configure approval chains via Keeper SSO/Compliance Reporting"), + (("OnlyManagersApproval", "onlyManagersApproval"), "Manager-only approval", + "Enforce manager approval through Keeper team membership policies"), + (("RequireReason", "requireReason"), "Require reason on access", + "Reason-on-access is per-record in Keeper; enable on shared folders " + "as needed"), + (("AllowFreeText", "allowFreeText"), "Allow free-text reason", + "Keeper accepts free-text reasons by default for audit comments"), + (("AllowViewPassword", "allowViewPassword"), "Allow view password", + "Password visibility is governed by Keeper sharing/role policies"), + ) + for aliases, label, action in unmapped_aliases: + found, val = MasterPolicyMapper.first_rule(rules, aliases) + if found and bool(val): + unmapped.append({ + "category": "Master Policy", + "item": f"{label} = Active", + "action": action, + }) + + # Confirmers count — only meaningful when > 0 + confirmers_raw = rules.get("ConfirmersNumber") or rules.get("confirmersNumber") + if confirmers_raw: + try: + n = int(confirmers_raw) + except (TypeError, ValueError): + n = 0 + if n > 0: + unmapped.append({ + "category": "Master Policy", + "item": f"ConfirmersNumber = {n}", + "action": "Configure approval workflow with the matching " + "number of approvers in Keeper Admin Console.", + }) + + return config, unmapped diff --git a/keepercommander/importer/cyberark/pam/permission_mapper.py b/keepercommander/importer/cyberark/pam/permission_mapper.py new file mode 100644 index 000000000..cff65a7bf --- /dev/null +++ b/keepercommander/importer/cyberark/pam/permission_mapper.py @@ -0,0 +1,127 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' dict: + """Map CyberArk permission booleans to Keeper shared folder permissions. + + Returns dict with keys: manage_users, manage_records, can_edit, can_share. + """ + if not isinstance(perms, dict): + return {"manage_users": False, "manage_records": False, + "can_edit": False, "can_share": False} + + # Tier 1: View — can list and use accounts + has_view = (perms.get("useAccounts", False) + and perms.get("listAccounts", False)) + + # Tier 2: Edit — can modify account content + has_edit = (has_view + and perms.get("addAccounts", False) + and (perms.get("updateAccountContent", False) + or perms.get("updateAccountProperties", False))) + + # Tier 3: Manage — full safe administration + has_manage = (has_edit + and perms.get("manageSafe", False) + and perms.get("manageSafeMembers", False)) + + return { + "manage_users": has_manage, + "manage_records": has_edit or has_manage, + "can_edit": has_edit or has_manage, + "can_share": has_manage, + } + + @staticmethod + def get_unmapped_permissions(perms: dict) -> List[str]: + """Return list of CyberArk permissions that have no Keeper equivalent.""" + if not isinstance(perms, dict): + return [] + return [ + p for p in PermissionMapper.UNMAPPED_PERMISSIONS + if perms.get(p, False) + ] + + @staticmethod + def map_member(member: dict) -> dict: + """Map a CyberArk safe member to a Keeper shared folder permission entry. + + Returns dict with: name, member_type ('user'|'team'), permissions dict, + unmapped list, and the raw memberName for matching. + """ + name = member.get("memberName", "") + member_type = member.get("memberType", "User") + perms = member.get("permissions", {}) + + # Warn on unexpected member types (CyberArk currently uses "User" and "Group") + if member_type not in ("User", "Group"): + logging.warning('Unexpected member type "%s" for member "%s" — treating as user', + member_type, name) + + keeper_perms = PermissionMapper.map_permissions(perms) + unmapped = PermissionMapper.get_unmapped_permissions(perms) + + return { + "name": name, + "member_type": "team" if member_type == "Group" else "user", + "permissions": keeper_perms, + "unmapped_permissions": unmapped, + } + diff --git a/keepercommander/importer/cyberark/pam/platform_mapping.py b/keepercommander/importer/cyberark/pam/platform_mapping.py new file mode 100644 index 000000000..0920c16b7 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/platform_mapping.py @@ -0,0 +1,205 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[dict]: + """Map unknown/custom platformIds to a record type via keyword scan. + + Returns a mapping dict on match, or None (caller uses FALLBACK_PLATFORM_MAP). + """ + haystack = f"{platform_id or ''}\n{raw_name or ''}".lower() + if not haystack.strip(): + return None + for keyword, mapping in _PLATFORM_KEYWORD_MAP: + if keyword in haystack: + return dict(mapping) + return None + + +# CyberArk auto-generated account names typically start with one of these +# system category prefixes (e.g. "Operating System-UnixSSH-10.0.0.1-root"). +# Stripping them before the platformId strip yields cleaner record titles. +_CATEGORY_PREFIX_RE = re.compile( + r"^(Operating System|Database|Network Device|Cloud Service|Website|" + r"Application|Security Appliance|Generic)-", + re.IGNORECASE, +) + +# Port-related keys in platformAccountProperties (case-insensitive lookup). +_PORT_PROPERTY_KEYS = ( + "Port", "port", "PORT", + "PSMSSHPort", "PSMSshPort", "PSMServerPort", "PSMRDPPort", + "SSHPort", "RDPPort", "TelnetPort", + "SQLPort", "DBPort", "DatabasePort", + "ConnectionPort", "ServicePort", "TargetPort", +) + + +def _split_host_port(address: str) -> Tuple[str, str]: + """Split a host[:port] string. Returns (host, port) where port is "" when absent. + + Handles bracketed IPv6 ("[::1]:22"), bare IPv6 (no port), and trims + accidental whitespace. Non-numeric trailing values are ignored so that + addresses like "host:22 (primary)" don't pollute the port. + """ + if not isinstance(address, str): + return "", "" + s = address.strip() + if not s: + return "", "" + # Bracketed IPv6 — "[::1]" or "[::1]:22" + if s.startswith("["): + end = s.find("]") + if end > 0: + host = s[1:end] + tail = s[end + 1:] + if tail.startswith(":") and tail[1:].split()[0].isdigit(): + return host, tail[1:].split()[0] + return host, "" + # Bare IPv6 (2+ colons) — leave intact, no port + if s.count(":") > 1: + return s, "" + # host:port + if ":" in s: + host, _, port = s.partition(":") + port = port.split()[0] + if port.isdigit(): + return host, port + return s, "" + + +def _extract_port(props: dict, address: str, mapping_default: str) -> Tuple[str, str]: + """Resolve the port for a CyberArk account, with provenance for debugging. + + Resolution order: + 1. Known port-related keys in ``platformAccountProperties`` (case-insensitive, + specific keys first — see _PORT_PROPERTY_KEYS). + 2. Port embedded in the account ``address`` (e.g. "host:2222"). + 3. Platform-map default (e.g. SSH→22, RDP→3389). + 4. Empty string. + + Returns ``(port, source)`` where source identifies which path matched so + callers can log it once per account. + """ + if isinstance(props, dict) and props: + # Direct hit on the canonical keys (fast path, preserves casing in source). + for key in _PORT_PROPERTY_KEYS: + val = props.get(key) + if val not in (None, ""): + return str(val).strip(), f"props.{key}" + # Case-insensitive fallback for tenants with bespoke casing. + lowered = {str(k).lower(): k for k in props.keys()} + for key in _PORT_PROPERTY_KEYS: + actual = lowered.get(key.lower()) + if actual and props.get(actual) not in (None, ""): + return str(props[actual]).strip(), f"props.{actual}" + _, embedded = _split_host_port(address) + if embedded: + return embedded, "address" + if mapping_default: + return str(mapping_default).strip(), "platform-map" + return "", "none" + + +# SystemType (from /Platforms list) → mapping. CyberArk classifies every +# platform by its SystemType field, regardless of how the platformId was +# renamed; using it bypasses keyword guessing entirely for tenants whose +# platform list we've cached. +_SYSTEM_TYPE_MAP: Dict[str, dict] = { + "windows": {"record_type": "pamMachine", "rotation": "general", "protocol": "rdp", "port": "3389"}, + "unixdistro": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "unix": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "linux": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "appliance": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "network": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "oracledb": {"record_type": "pamDatabase", "rotation": "general", "protocol": None, "port": "1521", "database_type": "oracle"}, + "mssqlserver": {"record_type": "pamDatabase", "rotation": "general", "protocol": "sql-server", "port": "1433", "database_type": "mssql"}, + "mysql": {"record_type": "pamDatabase", "rotation": "general", "protocol": "mysql", "port": "3306", "database_type": "mysql"}, + "mariadb": {"record_type": "pamDatabase", "rotation": "general", "protocol": "mysql", "port": "3306", "database_type": "mariadb"}, + "postgresql": {"record_type": "pamDatabase", "rotation": "general", "protocol": "postgresql", "port": "5432", "database_type": "postgresql"}, + "mongodb": {"record_type": "pamDatabase", "rotation": "general", "protocol": None, "port": "27017", "database_type": "mongodb"}, + # Generic "database" SystemType: assume MSSQL since it's the most common in + # CyberArk Windows-centric tenants. Tenants with other DBs should use + # --platform-map or rely on platformId keyword guess to override. + "database": {"record_type": "pamDatabase", "rotation": "general", "protocol": "sql-server", "port": "1433", "database_type": "mssql"}, + "website": {"record_type": "login", "rotation": None, "protocol": None, "port": None}, + "webapp": {"record_type": "login", "rotation": None, "protocol": None, "port": None}, +} + + +def _port_from_platform_details(details: dict) -> str: + """Extract a port from the Platforms detail endpoint payload. + + The platform's default port lives under various keys depending on the + CyberArk version: ``Details.Properties.Required[].Name=="Port"`` with + DefaultValue, ``Details.UI.PSMServer.Port``, or simply + ``Details.Properties.Port``. We probe in that order. + """ + if not isinstance(details, dict): + return "" + d = details.get("Details") if isinstance(details.get("Details"), dict) else details + if not isinstance(d, dict): + return "" + props = d.get("Properties") if isinstance(d.get("Properties"), dict) else {} + # Newer Privilege Cloud: Required/Optional lists of {Name, DefaultValue} + for bucket in ("Required", "Optional"): + entries = props.get(bucket) if isinstance(props, dict) else None + if isinstance(entries, list): + for entry in entries: + if not isinstance(entry, dict): + continue + if str(entry.get("Name", "")).lower() in ("port", "sshport", "rdpport", "sqlport", "dbport"): + val = entry.get("DefaultValue") or entry.get("Value") + if val not in (None, ""): + return str(val).strip() + # Older self-hosted PVWA flattens it + for key in ("Port", "port", "PSMServerPort", "RDPPort", "SSHPort"): + if isinstance(props, dict) and props.get(key) not in (None, ""): + return str(props[key]).strip() + ui = d.get("UI") if isinstance(d.get("UI"), dict) else {} + for sub in (ui.get("PSMServer"), ui.get("ConnectionDetails")): + if isinstance(sub, dict) and sub.get("Port") not in (None, ""): + return str(sub["Port"]).strip() + return "" diff --git a/keepercommander/importer/cyberark/pam/policy_rules.py b/keepercommander/importer/cyberark/pam/policy_rules.py new file mode 100644 index 000000000..dff7cb1fc --- /dev/null +++ b/keepercommander/importer/cyberark/pam/policy_rules.py @@ -0,0 +1,77 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + """Coerce CyberArk boolean-ish values (true/false/Yes/No/1/0) to bool.""" + if isinstance(val, bool): + return val + if isinstance(val, (int, float)): + return bool(val) + if isinstance(val, str): + return val.strip().lower() in ("true", "yes", "y", "1", "on", "active") + return False + + +def flatten_asmx_grid(data: dict) -> Dict[str, Any]: + """Flatten PoliciesMgt.asmx ExtJS-grid ``{data: [{name, value}, ...]}`` shape.""" + out: Dict[str, Any] = {} + if not isinstance(data, dict): + return out + envelope = data.get("d", data) + if not isinstance(envelope, dict): + envelope = data + rows = (envelope.get("data") or envelope.get("Data") + or envelope.get("rows") or envelope.get("rules") + or envelope.get("Rules") or []) + if isinstance(rows, list): + for row in rows: + if not isinstance(row, dict): + continue + name = (row.get("name") or row.get("Name") + or row.get("RuleName") or row.get("ruleName") or "") + if not name: + continue + if "value" in row: + out[name] = row.get("value") + elif "Value" in row: + out[name] = row.get("Value") + elif "Active" in row: + out[name] = row.get("Active") + else: + out[name] = True + return out diff --git a/keepercommander/importer/cyberark/pam/record_kind.py b/keepercommander/importer/cyberark/pam/record_kind.py new file mode 100644 index 000000000..304327108 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/record_kind.py @@ -0,0 +1,26 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """Return the RecordKind for a PVWA payload.""" + if not isinstance(payload, dict): + return RecordKind.ACCOUNT + if "AppID" in payload: + return RecordKind.APPLICATION + if payload.get("platformType") == "Application": + return RecordKind.API_TOKEN + return RecordKind.ACCOUNT diff --git a/keepercommander/importer/cyberark/pam/safe_folder_mapper.py b/keepercommander/importer/cyberark/pam/safe_folder_mapper.py new file mode 100644 index 000000000..39c28e104 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/safe_folder_mapper.py @@ -0,0 +1,83 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """Returns folder_path for use in pam_data extend JSON. + + Deduplicates colliding names with #N suffix (e.g. 'Safe #2'). + """ + if self.mode == "flat": + return "" + # Return cached result if already mapped (same safe = same folder) + if safe_name in self._cache: + return self._cache[safe_name] + + if self.mode == "exact": + sanitized = safe_name + elif self.mode in ("ksm", "safe"): + # ``safe`` reuses the same sanitization rules as ``ksm`` so the + # resulting shared-folder name is well-formed for the Keeper + # vault (no URL-escapes, single internal whitespace). + sanitized = re.sub(r"[^\w\s\-]", "", safe_name) + sanitized = re.sub(r"\s+", " ", sanitized).strip() + else: + sanitized = safe_name + + # Deduplicate: if two safes sanitize to the same name, add #N suffix + if sanitized in self._seen: + self._seen[sanitized] += 1 + suffix = f" #{self._seen[sanitized]}" + if self.mode in ("ksm", "safe"): + max_base = MAX_SAFE_NAME_LENGTH - len(suffix) + result = f"{sanitized[:max_base]}{suffix}" + else: + result = f"{sanitized}{suffix}" + else: + self._seen[sanitized] = 1 + if self.mode in ("ksm", "safe"): + result = sanitized[:MAX_SAFE_NAME_LENGTH] + else: + result = sanitized + + self._cache[safe_name] = result + return result + + def iter_mapped(self): + """Yields ``(raw_safe_name, resolved_folder_name)`` pairs for every + safe that has been mapped so far. Useful for emitting the + ``safe_folders`` block of the import JSON without round-tripping + through the records list (which would miss safes whose accounts + were all skipped). + """ + for raw, resolved in self._cache.items(): + yield raw, resolved + diff --git a/keepercommander/importer/cyberark/pam/safe_utils.py b/keepercommander/importer/cyberark/pam/safe_utils.py new file mode 100644 index 000000000..796f8b261 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/safe_utils.py @@ -0,0 +1,124 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' None: + """Merge extra system-safe names from ``KEEPER_CYBERARK_SYSTEM_SAFES``.""" + raw = os.environ.get("KEEPER_CYBERARK_SYSTEM_SAFES", "") + if not raw: + return + register_system_safes(*(p.strip() for p in raw.split(",") if p.strip())) + + +def _safe_marked_system(safe: dict) -> bool: + """Return True when the PVWA Safe payload indicates a system/internal safe.""" + if not isinstance(safe, dict): + return False + for key in ("isSystem", "IsSystem", "isPredefined", "IsPredefined"): + if safe.get(key) is True: + return True + safe_type = str(safe.get("safeType") or safe.get("SafeType") or "").strip().lower() + return safe_type in ("system", "internal", "predefined") + + +def exclude_system_safes(safes: List[dict], include_system: bool = False) -> List[dict]: + """Remove CyberArk system/internal safes from the list. + + System safes (VaultInternal, PVWAConfig, etc.) don't contain + user-managed accounts and should be excluded from migration. + Override with include_system=True (--include-system-safes flag). + + Also registers any safes the PVWA API flags as system/predefined so + newly introduced CyberArk system safes are excluded without a code + update, and merges names from ``KEEPER_CYBERARK_SYSTEM_SAFES``. + """ + _load_system_safes_from_env() + # Dynamically learn system safes from API metadata when present. + for s in safes: + if _safe_marked_system(s): + name = s.get("safeName") or s.get("SafeName") or "" + if name: + register_system_safes(str(name)) + if include_system: + return safes + before = len(safes) + system_lower = {s.lower() for s in SYSTEM_SAFES} + filtered = [s for s in safes if s.get("safeName", "").lower() not in system_lower] + excluded = before - len(filtered) + if excluded > 0: + logging.info('Excluded %d system safe(s) from migration', excluded) + return filtered + + +def apply_safe_filter(safes: List[dict], include: Optional[str] = None, + exclude: Optional[str] = None) -> List[dict]: + """Filter safes by --safes (include) and --exclude-safes patterns. + + Patterns are comma-separated and support glob matching. + """ + if include: + patterns = [p.strip() for p in include.split(",") if p.strip()] + safes = [s for s in safes if any(fnmatch.fnmatch(s["safeName"], p) for p in patterns)] + if exclude: + patterns = [p.strip() for p in exclude.split(",") if p.strip()] + safes = [s for s in safes if not any(fnmatch.fnmatch(s["safeName"], p) for p in patterns)] + return safes + + +def sanitize_safe_name(name: str) -> str: + """Sanitize a CyberArk safe name for use as a Keeper folder name. + + - Strip/replace characters not allowed in folder names + - Truncate to MAX_SAFE_NAME_LENGTH + - Handle dedup by appending suffix if needed + """ + # Strip control characters (null bytes, newlines, etc.) + safe = re.sub(r'[\x00-\x1f\x7f]', '', name) + # Strip path separators + safe = safe.replace('/', '_').replace('\\', '_').replace('..', '_') + # Remove leading/trailing whitespace + safe = safe.strip() + # Truncate to max length + if len(safe) > MAX_SAFE_NAME_LENGTH: + safe = safe[:MAX_SAFE_NAME_LENGTH].rstrip() + return safe or 'Unnamed-Safe' + + +def deduplicate_safe_names(safes: List[dict]) -> Dict[str, str]: + """Build a mapping of safeUrlId → sanitized folder name, deduplicating collisions. + + Returns dict: { safeUrlId: "FolderName" } + """ + name_map = {} # safeUrlId → sanitized name + seen = {} # sanitized name → count + + for safe in safes: + url_id = safe.get("safeUrlId", safe.get("safeName", "")) + raw_name = safe.get("safeName", url_id) + sanitized = sanitize_safe_name(raw_name) + + if sanitized in seen: + seen[sanitized] += 1 + suffix = f" #{seen[sanitized]}" + # Trim base name to fit suffix within MAX_SAFE_NAME_LENGTH + max_base = MAX_SAFE_NAME_LENGTH - len(suffix) + sanitized = f"{sanitized[:max_base]}{suffix}" + else: + seen[sanitized] = 1 + + name_map[url_id] = sanitized + + return name_map diff --git a/keepercommander/importer/cyberark/pam/session_recording.py b/keepercommander/importer/cyberark/pam/session_recording.py new file mode 100644 index 000000000..ac9796d16 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/session_recording.py @@ -0,0 +1,52 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[bool]: + """Return True/False when recording can be determined, else None.""" + if not session_data: + return None + rules: Dict[str, Any] = {} + rules.update(MasterPolicyMapper.normalize(session_data)) + rules.update(flatten_asmx_grid(session_data)) + for key in PLATFORM_RECORD_KEYS: + if key in rules: + return truthy(rules[key]) + for key in PLATFORM_MONITOR_KEYS: + if key in rules and truthy(rules[key]): + return True + return None + + @classmethod + def apply_to_master_config(cls, master_config: dict, + session_data: Optional[dict]) -> dict: + """Merge session-recording flags into a master policy config dict.""" + config = dict(master_config) + flag = cls.resolve_from_monitoring_data(session_data) + if flag is True: + config["graphical_session_recording"] = "on" + config["text_session_recording"] = "on" + elif flag is False: + config.setdefault("graphical_session_recording", "off") + config.setdefault("text_session_recording", "off") + return config diff --git a/keepercommander/importer/cyberark/pam/throttler.py b/keepercommander/importer/cyberark/pam/throttler.py new file mode 100644 index 000000000..2be977c06 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/throttler.py @@ -0,0 +1,34 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' self.base_delay: + self.current_delay = max(self.base_delay, self.current_delay * 0.8) + else: + self._recent_errors += 1 + self.current_delay = min(self.max_delay, self.current_delay * 1.5) + + def wait(self): + if self.current_delay > 0: + time.sleep(self.current_delay) diff --git a/keepercommander/importer/cyberark/pam/ui.py b/keepercommander/importer/cyberark/pam/ui.py new file mode 100644 index 000000000..4a2a5d988 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/ui.py @@ -0,0 +1,16 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """Escape HTML-significant characters and strip control/ANSI sequences + for safe use in prompt_toolkit HTML().""" + s = re.sub(r'[\x00-\x1f\x7f]', '', str(text)) # strip control chars + ANSI + return _html_escape(s, quote=False) diff --git a/keepercommander/importer/cyberark/pam/user_team_matcher.py b/keepercommander/importer/cyberark/pam/user_team_matcher.py new file mode 100644 index 000000000..c2ab6e6b7 --- /dev/null +++ b/keepercommander/importer/cyberark/pam/user_team_matcher.py @@ -0,0 +1,135 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[str]: + """Try to match a CyberArk user to a Keeper user. + + Returns the matched Keeper email or None if not found. + """ + # Check manual override first + override = self._overrides.get(cyberark_username.lower()) + if override and override in self._user_emails: + return override + + # Match by email + if cyberark_email and cyberark_email.lower() in self._user_emails: + return cyberark_email.lower() + + # Match by username (might be an email) + if '@' in cyberark_username and cyberark_username.lower() in self._user_emails: + return cyberark_username.lower() + + # Not found + self.unmatched.append({ + 'cyberark_username': cyberark_username, + 'cyberark_email': cyberark_email, + 'cyberark_groups': cyberark_groups, + 'keeper_match_found': 'no', + 'keeper_email': '', + 'suggested_action': 'provision_user', + }) + return None + + def match_team(self, cyberark_group_name: str) -> Optional[str]: + """Try to match a CyberArk group to a Keeper team. + + Returns the matched Keeper team name or None if not found. + """ + if cyberark_group_name.lower() in self._team_names: + return cyberark_group_name + # Not found + self.unmatched.append({ + 'cyberark_username': cyberark_group_name, + 'cyberark_email': '', + 'cyberark_groups': '(group)', + 'keeper_match_found': 'no', + 'keeper_email': '', + 'suggested_action': 'create_team', + }) + return None + + def generate_csv(self) -> str: + """Generate ca_users_to_provision.csv content from unmatched identities. + + Returns CSV as a string (no file I/O — caller writes to file/attachment). + Uses csv.writer for proper quoting and escaping (prevents formula injection). + """ + if not self.unmatched: + return '' + output = io.StringIO() + writer = csv.writer(output, quoting=csv.QUOTE_ALL) + writer.writerow(['cyberark_username', 'cyberark_email', 'cyberark_groups', + 'keeper_match_found', 'keeper_email', 'suggested_action']) + for row in self.unmatched: + # Sanitize formula-triggering prefixes (=, +, -, @, \t, \r). + # Strip leading whitespace first — spreadsheet apps often ignore + # it when parsing formulas, so " =cmd()" would bypass a naive + # first-char check. + def _sanitize(val): + s = str(val).lstrip() + if s and s[0] in ('=', '+', '-', '@', '\t', '\r'): + s = "'" + s # prefix with single quote to neutralize + return s + writer.writerow([ + _sanitize(row.get('cyberark_username', '')), + _sanitize(row.get('cyberark_email', '')), + _sanitize(row.get('cyberark_groups', '')), + row.get('keeper_match_found', 'no'), + row.get('keeper_email', ''), + row.get('suggested_action', ''), + ]) + return output.getvalue().strip() diff --git a/tests/test_cyberark_pam_import.py b/tests/test_cyberark_pam_import.py new file mode 100644 index 000000000..619e7e646 --- /dev/null +++ b/tests/test_cyberark_pam_import.py @@ -0,0 +1,3729 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' --'.""" + mapper = AccountMapper() + account = { + "id": "1", "name": "Operating System-UnixSSH-10.0.1.30-simon", + "platformId": "UnixSSH", "address": "10.0.1.30", "userName": "simon", + "platformAccountProperties": {}, + } + result = mapper.map_account(account, "pw") + assert result["title"] == "10.0.1.30-simon" + + def test_title_falls_back_to_address_user_when_verbose(self): + """When the name embeds a CPM policy name (not the platformId), stripping + leaves it verbose — fall back to {address}-{user} for readability.""" + mapper = AccountMapper() + account = { + "id": "2", + "name": "Operating System-WindowsDesktopLocalAccountsRotationalPolicy-10.0.1.20-x_accountB", + "platformId": "WinDesktopLocal", "address": "10.0.1.20", "userName": "x_accountB", + "platformAccountProperties": {}, + } + result = mapper.map_account(account, "pw") + assert result["title"] == "10.0.1.20-x_accountB" + assert len(result["title"]) < 40 + + def test_title_preserves_short_custom_names(self): + """Short admin-set names (Linux2, db1, windows1) stay untouched.""" + mapper = AccountMapper() + for name in ("Linux2", "db1", "windows1"): + account = { + "id": "3", "name": name, "platformId": "UnixSSH", + "address": "10.0.0.1", "userName": "root", + "platformAccountProperties": {}, + } + result = mapper.map_account(account, "pw") + assert result["title"] == name + + +# ── SafeFolderMapper Tests ─────────────────────────────────── + + +class TestSafeFolderMapper: + + def test_flat_mode_returns_empty(self): + mapper = SafeFolderMapper(mode="flat") + assert mapper.map_safe("Production Servers", "MyProject") == "" + + def test_exact_mode_preserves_name(self): + mapper = SafeFolderMapper(mode="exact") + assert mapper.map_safe("Production & Servers (2024)", "MyProject") == "Production & Servers (2024)" + + def test_ksm_mode_sanitizes(self): + mapper = SafeFolderMapper(mode="ksm") + assert mapper.map_safe("Production & Servers (2024)", "MyProject") == "Production Servers 2024" + + def test_ksm_mode_collapses_spaces(self): + mapper = SafeFolderMapper(mode="ksm") + result = mapper.map_safe("My Safe Name", "Project") + assert " " not in result + assert result == "My Safe Name" + + def test_ksm_mode_handles_special_chars(self): + mapper = SafeFolderMapper(mode="ksm") + result = mapper.map_safe("Safe/With\\Special", "Project") + assert "/" not in result + assert "\\" not in result + assert "<" not in result + + def test_exact_mode_long_name(self): + mapper = SafeFolderMapper(mode="exact") + long_name = "A" * 200 + assert mapper.map_safe(long_name, "Project") == long_name + + def test_duplicate_safe_names_preserved(self): + """exact mode should preserve names even if they look similar.""" + mapper = SafeFolderMapper(mode="exact") + assert mapper.map_safe("Servers", "P") == "Servers" + assert mapper.map_safe("servers", "P") == "servers" + + +# ── apply_safe_filter Tests ────────────────────────────────── + + +class TestApplySafeFilter: + + def _safes(self, names): + return [{"safeName": n} for n in names] + + def test_no_filter_returns_all(self): + safes = self._safes(["A", "B", "C"]) + result = apply_safe_filter(safes) + assert len(result) == 3 + + def test_include_filter(self): + safes = self._safes(["Prod-Servers", "Dev-Servers", "Prod-DBs"]) + result = apply_safe_filter(safes, include="Prod-*") + assert len(result) == 2 + assert all("Prod" in s["safeName"] for s in result) + + def test_exclude_filter(self): + safes = self._safes(["Prod-Servers", "Dev-Servers", "Test-Servers"]) + result = apply_safe_filter(safes, exclude="Dev-*,Test-*") + assert len(result) == 1 + assert result[0]["safeName"] == "Prod-Servers" + + def test_include_and_exclude(self): + safes = self._safes(["Prod-Servers", "Prod-Test", "Dev-Servers"]) + result = apply_safe_filter(safes, include="Prod-*", exclude="*-Test") + assert len(result) == 1 + assert result[0]["safeName"] == "Prod-Servers" + + def test_glob_pattern_matching(self): + safes = self._safes(["Safe_001", "Safe_002", "Other_001"]) + result = apply_safe_filter(safes, include="Safe_*") + assert len(result) == 2 + + +# ── AdaptiveThrottler Tests ────────────────────────────────── + + +class TestAdaptiveThrottler: + + def test_initial_delay(self): + t = AdaptiveThrottler(base_delay=1.0) + assert t.current_delay == 1.0 + + def test_success_decreases_delay(self): + t = AdaptiveThrottler(base_delay=0.5, max_delay=5.0) + t.current_delay = 2.0 # simulate elevated delay + t.record_response(500, True) # fast success + assert t.current_delay < 2.0 + + def test_failure_increases_delay(self): + t = AdaptiveThrottler(base_delay=0.5, max_delay=5.0) + initial = t.current_delay + t.record_response(5000, False) + assert t.current_delay > initial + + def test_delay_never_below_base(self): + t = AdaptiveThrottler(base_delay=0.5) + for _ in range(20): + t.record_response(100, True) + assert t.current_delay >= t.base_delay + + def test_delay_never_above_max(self): + t = AdaptiveThrottler(base_delay=0.5, max_delay=5.0) + for _ in range(20): + t.record_response(10000, False) + assert t.current_delay <= t.max_delay + + def test_delay_increases_after_errors(self): + t = AdaptiveThrottler() + initial_delay = t.current_delay + for _ in range(5): + t.record_response(1000, False) + assert t.current_delay > initial_delay + + def test_delay_stable_on_success(self): + t = AdaptiveThrottler() + initial_delay = t.current_delay + for _ in range(5): + t.record_response(200, True) + assert t.current_delay <= initial_delay + + +# ── build_import_json Tests ────────────────────────────────── + + +class TestBuildImportJson: + + def test_basic_structure(self): + result = build_import_json("Test Project", "my-gateway", [], []) + assert result["project"] == "Test Project" + assert result["pam_configuration"]["gateway_name"] == "my-gateway" + assert result["pam_configuration"]["environment"] == "local" + assert result["pam_configuration"]["rotation"] == "on" + assert result["pam_data"]["resources"] == [] + assert result["pam_data"]["users"] == [] + + def test_no_gateway(self): + result = build_import_json("Test", None, [], []) + assert "gateway_name" not in result["pam_configuration"] + + def test_with_resources_and_users(self): + resources = [{"type": "pamMachine", "title": "srv1", "host": "10.0.0.1"}] + users = [{"type": "login", "title": "web", "login": "user"}] + result = build_import_json("Test", None, resources, users) + assert len(result["pam_data"]["resources"]) == 1 + assert len(result["pam_data"]["users"]) == 1 + + +class TestBuildExtendJson: + + def test_extend_only_has_pam_data(self): + result = build_extend_json( + [{"type": "pamMachine", "title": "srv"}], + [{"type": "login", "title": "web"}], + ) + assert "project" not in result + assert "pam_configuration" not in result + assert len(result["pam_data"]["resources"]) == 1 + assert len(result["pam_data"]["users"]) == 1 + + +# ── strip_credentials Tests ───────────────────────────────── + + +class TestStripCredentials: + + def test_strips_user_passwords(self): + data = {"pam_data": {"users": [{"password": "secret"}], "resources": []}} + strip_credentials(data) + assert data["pam_data"]["users"][0]["password"] == "***" + + def test_strips_nested_resource_user_passwords(self): + data = { + "pam_data": { + "users": [], + "resources": [ + {"type": "pamMachine", "users": [{"password": "secret"}]} + ], + } + } + strip_credentials(data) + assert data["pam_data"]["resources"][0]["users"][0]["password"] == "***" + + def test_handles_empty_data(self): + data = {"pam_data": {"users": [], "resources": []}} + strip_credentials(data) # should not raise + + +# ── format_duration Tests ──────────────────────────────────── + + +class TestFormatDuration: + + def test_seconds_only(self): + assert format_duration(45) == "45s" + + def test_minutes_and_seconds(self): + assert format_duration(125) == "2m 5s" + + def test_zero(self): + assert format_duration(0) == "0s" + + +# ── build_report Tests ─────────────────────────────────────── + + +class TestBuildReport: + + def test_report_contains_project_name(self): + report = build_report( + project_name="Test Migration", + safes_processed=5, + total_accounts=50, + resource_counts={"pamMachine": {"ok": 20, "skip": 0, "err": 0}}, + platform_counts={"UnixSSH": {"rotation": "general", "count": 20}}, + skipped=[], + incomplete_count=0, + duration=120.0, + ) + assert "Test Migration" in report + assert "Safes processed: 5" in report + assert "Accounts found: 50" in report + + def test_report_shows_unmapped_platforms(self): + report = build_report( + project_name="Test", + safes_processed=1, + total_accounts=10, + resource_counts={}, + platform_counts={"CustomPlatform": {"rotation": "UNMAPPED", "count": 5}}, + skipped=[], + incomplete_count=0, + duration=10.0, + unmapped_platforms={"CustomPlatform": 5}, + ) + assert "UNMAPPED" in report + assert "--platform-map" in report + + def test_report_shows_skipped(self): + report = build_report( + project_name="Test", + safes_processed=1, + total_accounts=10, + resource_counts={}, + platform_counts={}, + skipped=[ + {"reason": "password retrieval failed"}, + {"reason": "password retrieval failed"}, + {"reason": "CPM disabled"}, + ], + incomplete_count=2, + duration=10.0, + ) + assert "Password retrieval failed: 2" in report + assert "Manual mgmt (CPM disabled): 1" in report + assert "Incomplete (missing fields): 2" in report + + def test_report_shows_duration(self): + report = build_report( + project_name="Test", + safes_processed=1, + total_accounts=10, + resource_counts={}, + platform_counts={}, + skipped=[], + incomplete_count=0, + duration=272.0, + ) + assert "4m 32s" in report + + +# ── CyberArkPVWAClient Tests ──────────────────────────────── + + +class TestCyberArkPVWAClientNormalize: + + def test_normalize_plain_host(self): + host, params = CyberArkPVWAClient._normalize_host("pvwa.example.com") + assert host == "pvwa.example.com" + assert params == {} + + def test_normalize_strips_https(self): + host, params = CyberArkPVWAClient._normalize_host("https://pvwa.example.com") + assert host == "pvwa.example.com" + + def test_normalize_privilege_cloud(self): + host, params = CyberArkPVWAClient._normalize_host("mycompany.cyberark.cloud") + assert host == "mycompany.privilegecloud.cyberark.cloud" + + def test_normalize_with_query_params(self): + host, params = CyberArkPVWAClient._normalize_host("pvwa.example.com?WinDomain") + assert host == "pvwa.example.com" + assert params == {"search": "WinDomain"} + + def test_normalize_with_kv_query_params(self): + host, params = CyberArkPVWAClient._normalize_host("pvwa.example.com?limit=10&offset=20") + assert host == "pvwa.example.com" + assert params == {"limit": "10", "offset": "20"} + + +# ── SSRF Protection Tests ──────────────────────────────────── + + +class TestSSRFProtection: + + def test_rejects_localhost(self): + with pytest.raises(ValueError, match="local address"): + CyberArkPVWAClient._validate_host("localhost") + + def test_rejects_127_0_0_1(self): + with pytest.raises(ValueError, match="local address"): + CyberArkPVWAClient._validate_host("127.0.0.1") + + def test_rejects_private_ip(self): + with pytest.raises(ValueError, match="private/reserved"): + CyberArkPVWAClient._validate_host("10.0.0.1") + + def test_rejects_link_local(self): + with pytest.raises(ValueError, match="private/reserved"): + CyberArkPVWAClient._validate_host("169.254.169.254") + + def test_rejects_empty_host(self): + with pytest.raises(ValueError, match="local address"): + CyberArkPVWAClient._validate_host("") + + def test_rejects_invalid_hostname_chars(self): + with pytest.raises(ValueError, match="invalid characters"): + CyberArkPVWAClient._validate_host("pvwa.example.com/../../admin") + + def test_accepts_valid_hostname(self): + CyberArkPVWAClient._validate_host("pvwa.example.com") # should not raise + + def test_accepts_cyberark_cloud(self): + CyberArkPVWAClient._validate_host("mycompany.privilegecloud.cyberark.cloud") + + +# ── Login Type Validation Tests ────────────────────────────── + + +class TestLoginTypeValidation: + + def test_valid_login_types(self): + from keepercommander.importer.cyberark.cyberark_pam import VALID_LOGON_TYPES + for lt in ("cyberark", "ldap", "radius", "windows"): + assert lt in VALID_LOGON_TYPES + for lt in ("Cyberark", "CyberArk", "LDAP", "RADIUS", "Windows"): + assert lt.lower() in VALID_LOGON_TYPES + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.prompt") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_invalid_login_type_rejected_by_authenticate(self, mock_dns, mock_prompt, mock_requests): + """authenticate() should return False for invalid login types.""" + mock_prompt.side_effect = ["../../admin", "user", "pass"] + client = CyberArkPVWAClient("pvwa.example.com") + with patch("keepercommander.importer.cyberark.cyberark_pam.print_formatted_text"): + result = client.authenticate() + assert result is False + + +# ── SSL Verification Tests ─────────────────────────────────── + + +class TestSSLVerification: + + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_cloud_always_verify(self, mock_dns): + """Privilege Cloud must always verify SSL, even if verify_ssl=False.""" + client = CyberArkPVWAClient("mycompany.cyberark.cloud", verify_ssl=False) + assert client.verify_ssl is True + + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_selfhosted_respects_verify_flag(self, mock_dns): + """Self-hosted PVWA should respect verify_ssl=False.""" + client = CyberArkPVWAClient("pvwa.example.com", verify_ssl=False) + assert client.verify_ssl is False + + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_selfhosted_default_verify_true(self, mock_dns): + """Self-hosted defaults to verify_ssl=True.""" + client = CyberArkPVWAClient("pvwa.example.com") + assert client.verify_ssl is True + + +# ── CyberArkPAMImportCommand Tests ────────────────────────── + + +class TestCyberArkPAMImportCommandParser: + + def test_all_flags_parse(self): + cmd = CyberArkPAMImportCommand() + args = cmd.parser.parse_args([ + "pvwa.example.com", + "--name", "My Project", + "--config", "config-uid-123", + "--gateway", "gw-uid-456", + "--folder-mode", "exact", + "--safes", "Prod-*", + "--exclude-safes", "Test-*", + "--list-safes", + "--dry-run", + "--output", "output.json", + "--include-credentials", + "--estimate", + "--yes", + "--skip-users", + "--skip-linked-accounts", + "--skip-members", + "--include-system-safes", + "--user-map", "users.json", + "--batch-size", "50", + "--batch-delay", "1.0", + "--platform-map", "map.json", + "--state-filter", "active,inactive", + "--no-verify-ssl", + ]) + assert args.server == "pvwa.example.com" + assert args.project_name == "My Project" + assert args.config == "config-uid-123" + assert args.gateway == "gw-uid-456" + assert args.folder_mode == "exact" + assert args.safes == "Prod-*" + assert args.exclude_safes == "Test-*" + assert args.list_safes is True + assert args.dry_run is True + assert args.output == "output.json" + assert args.include_credentials is True + assert args.estimate is True + assert args.yes is True + assert args.skip_users is True + assert args.skip_linked_accounts is True + assert args.skip_members is True + assert args.include_system_safes is True + assert args.user_map == "users.json" + assert args.batch_size == 50 + assert args.batch_delay == 1.0 + assert args.platform_map == "map.json" + assert args.state_filter == "active,inactive" + assert args.no_verify_ssl is True + + def test_minimal_args(self): + cmd = CyberArkPAMImportCommand() + args = cmd.parser.parse_args(["pvwa.example.com"]) + assert args.server == "pvwa.example.com" + assert args.project_name == "" + assert args.dry_run is False + + def test_folder_mode_choices(self): + cmd = CyberArkPAMImportCommand() + # Valid choices + for mode in ("ksm", "exact", "flat"): + args = cmd.parser.parse_args(["server", "--folder-mode", mode]) + assert args.folder_mode == mode + + def test_short_flags(self): + cmd = CyberArkPAMImportCommand() + args = cmd.parser.parse_args(["server", "-n", "Proj", "-d", "-y", "-o", "out.json"]) + assert args.project_name == "Proj" + assert args.dry_run is True + assert args.yes is True + assert args.output == "out.json" + + +# ── Dry Run Integration Test (mocked PVWA) ────────────────── + + + +# ── Default Platform Map Completeness ──────────────────────── + + +class TestDefaultPlatformMap: + + def test_all_common_platforms_mapped(self): + expected = [ + "UnixSSH", "UnixSSHKey", "WinDomain", "WinLocalAccount", + "Oracle", "MySQL", "MSSql", "PostgreSQL", "BusinessWebsite", + ] + for platform in expected: + assert platform in DEFAULT_PLATFORM_MAP, f"Missing platform: {platform}" + + def test_all_mappings_have_required_fields(self): + for platform, mapping in DEFAULT_PLATFORM_MAP.items(): + assert "record_type" in mapping, f"{platform} missing record_type" + assert "rotation" in mapping, f"{platform} missing rotation" + assert "protocol" in mapping, f"{platform} missing protocol" + assert "port" in mapping, f"{platform} missing port" + + def test_business_website_is_login_type(self): + m = DEFAULT_PLATFORM_MAP["BusinessWebsite"] + assert m["record_type"] == "login" + assert m["rotation"] is None + + def test_unix_platforms_use_ssh(self): + for p in ("UnixSSH", "UnixSSHKey"): + assert DEFAULT_PLATFORM_MAP[p]["protocol"] == "ssh" + assert DEFAULT_PLATFORM_MAP[p]["port"] == "22" + + def test_windows_platforms_use_rdp(self): + for p in ("WinDomain", "WinLocalAccount", "WinServerLocal", "WinDesktopLocal"): + assert DEFAULT_PLATFORM_MAP[p]["protocol"] == "rdp" + assert DEFAULT_PLATFORM_MAP[p]["port"] == "3389" + + def test_database_platforms_have_correct_ports(self): + assert DEFAULT_PLATFORM_MAP["Oracle"]["port"] == "1521" + assert DEFAULT_PLATFORM_MAP["MySQL"]["port"] == "3306" + assert DEFAULT_PLATFORM_MAP["MSSql"]["port"] == "1433" + assert DEFAULT_PLATFORM_MAP["PostgreSQL"]["port"] == "5432" + + +# ── Secure Temp File Tests ─────────────────────────────────── + + +class TestSecureTempFiles: + + def test_write_secure_temp_creates_valid_json(self): + from keepercommander.commands.pam_import.cyberark_import import _write_secure_temp_json, _remove_secure_temp + data = {"test": "value", "nested": {"key": 123}} + tmp_path = _write_secure_temp_json(data) + try: + assert os.path.exists(tmp_path) + with open(tmp_path, 'r') as f: + loaded = json.load(f) + assert loaded == data + # Check permissions (skip on Windows) + if os.name != 'nt': + import stat + mode = os.stat(tmp_path).st_mode & 0o777 + assert mode == 0o600, f"Expected 0o600, got {oct(mode)}" + finally: + _remove_secure_temp(tmp_path) + + def test_remove_secure_temp_deletes_file(self): + from keepercommander.commands.pam_import.cyberark_import import _write_secure_temp_json, _remove_secure_temp + data = {"secret": "password123"} + tmp_path = _write_secure_temp_json(data) + assert os.path.exists(tmp_path) + _remove_secure_temp(tmp_path) + assert not os.path.exists(tmp_path) + + def test_remove_secure_temp_handles_missing_file(self): + from keepercommander.commands.pam_import.cyberark_import import _remove_secure_temp + _remove_secure_temp("/nonexistent/path/file.json") # should not raise + + +# ── Config UID Lookup Tests ────────────────────────────────── + + +class TestFindConfigUid: + """Tests calling the real _find_config_uid method.""" + + def _make_mock_record(self, title, uid): + record = MagicMock() + record.title = title + record.record_uid = uid + return record + + @patch('keepercommander.vault_extensions') + @patch('keepercommander.api.sync_down') + def test_exact_match(self, mock_sync, mock_ve): + mock_ve.find_records.return_value = [ + self._make_mock_record("MyProject Configuration", "uid-001"), + ] + cmd = CyberArkPAMImportCommand() + result = cmd._find_config_uid(MagicMock(), "MyProject") + assert result == "uid-001" + + @patch('keepercommander.vault_extensions') + @patch('keepercommander.api.sync_down') + def test_suffix_picks_highest_numerically(self, mock_sync, mock_ve): + """#10 should sort after #9 (numeric, not lexicographic).""" + mock_ve.find_records.return_value = [ + self._make_mock_record("MyProject Configuration", "uid-001"), + self._make_mock_record("MyProject Configuration #2", "uid-002"), + self._make_mock_record("MyProject Configuration #10", "uid-010"), + ] + cmd = CyberArkPAMImportCommand() + result = cmd._find_config_uid(MagicMock(), "MyProject") + assert result == "uid-010" + + @patch('keepercommander.vault_extensions') + @patch('keepercommander.api.sync_down') + def test_no_match_returns_empty(self, mock_sync, mock_ve): + mock_ve.find_records.return_value = [ + self._make_mock_record("OtherProject Configuration", "uid-999"), + ] + cmd = CyberArkPAMImportCommand() + result = cmd._find_config_uid(MagicMock(), "MyProject") + assert result == "" + + @patch('keepercommander.vault_extensions') + @patch('keepercommander.api.sync_down') + def test_rejects_partial_match(self, mock_sync, mock_ve): + mock_ve.find_records.return_value = [ + self._make_mock_record("MyProject Configuration Extra", "uid-bad"), + ] + cmd = CyberArkPAMImportCommand() + result = cmd._find_config_uid(MagicMock(), "MyProject") + assert result == "" + + +# ── Platform Map Validation Tests ──────────────────────────── + + +class TestPlatformMapValidation: + + def test_map_account_missing_record_type_defaults(self): + """AccountMapper should default to pamMachine if record_type missing from override.""" + override = {"CustomPlatform": {"rotation": "general", "protocol": "ssh", "port": "22"}} + mapper = AccountMapper(platform_map_override=override) + account = { + "id": "1", "name": "Custom-server", "platformId": "CustomPlatform", + "address": "10.0.0.1", "userName": "root", + "platformAccountProperties": {}, + } + result = mapper.map_account(account, "pass") + assert result["type"] == "pamMachine" # defaulted + + def test_platform_map_invalid_json_file(self, tmp_path): + """Invalid JSON should raise CommandError.""" + from keepercommander.error import CommandError + bad_file = tmp_path / "bad.json" + bad_file.write_text("not valid json {{{") + cmd = CyberArkPAMImportCommand() + with pytest.raises(CommandError, match="Invalid JSON"): + cmd.execute(MagicMock(), server="pvwa.example.com", + platform_map=str(bad_file), dry_run=True, + project_name="Test", config="", gateway="", + folder_mode="flat", safes="", exclude_safes="", + list_safes=False, output="", include_credentials=False, + estimate=False, yes=False, skip_users=False, + auto_throttle=True, batch_size=100, batch_delay=0.5, + state_filter="", no_verify_ssl=True) + + def test_platform_map_missing_record_type_file(self, tmp_path): + """Entry without record_type should raise CommandError.""" + from keepercommander.error import CommandError + bad_file = tmp_path / "bad_map.json" + bad_file.write_text('{"CustomPlatform": {"rotation": "general"}}') + cmd = CyberArkPAMImportCommand() + with pytest.raises(CommandError, match="record_type"): + cmd.execute(MagicMock(), server="pvwa.example.com", + platform_map=str(bad_file), dry_run=True, + project_name="Test", config="", gateway="", + folder_mode="flat", safes="", exclude_safes="", + list_safes=False, output="", include_credentials=False, + estimate=False, yes=False, skip_users=False, + auto_throttle=True, batch_size=100, batch_delay=0.5, + state_filter="", no_verify_ssl=True) + + def test_platform_map_not_dict_file(self, tmp_path): + """Non-dict JSON should raise CommandError.""" + from keepercommander.error import CommandError + bad_file = tmp_path / "list.json" + bad_file.write_text('[1, 2, 3]') + cmd = CyberArkPAMImportCommand() + with pytest.raises(CommandError, match="JSON object"): + cmd.execute(MagicMock(), server="pvwa.example.com", + platform_map=str(bad_file), dry_run=True, + project_name="Test", config="", gateway="", + folder_mode="flat", safes="", exclude_safes="", + list_safes=False, output="", include_credentials=False, + estimate=False, yes=False, skip_users=False, + auto_throttle=True, batch_size=100, batch_delay=0.5, + state_filter="", no_verify_ssl=True) + + +# ── Critical Fix Validation Tests ──────────────────────────── + + +class TestRotationTypesValid: + """C1: Verify all rotation types in DEFAULT_PLATFORM_MAP are accepted by base.py.""" + + def test_all_platform_rotations_are_valid(self): + from keepercommander.commands.pam_import.base import PamRotationSettingsObject + valid_types = ("general", "iam_user", "scripts_only") + for platform_id, mapping in DEFAULT_PLATFORM_MAP.items(): + rotation = mapping.get("rotation") + if rotation is None: + continue # login records have no rotation + assert rotation in valid_types, ( + f"Platform '{platform_id}' has invalid rotation '{rotation}'. " + f"Valid types: {valid_types}" + ) + + def test_rotation_settings_load_accepts_general(self): + from keepercommander.commands.pam_import.base import PamRotationSettingsObject + r = PamRotationSettingsObject.load({"rotation": "general", "enabled": "on"}) + assert r.rotation == "general" + + def test_rotation_settings_load_rejects_ad_user(self): + from keepercommander.commands.pam_import.base import PamRotationSettingsObject + r = PamRotationSettingsObject.load({"rotation": "ad_user", "enabled": "on"}) + assert r.rotation == "" # rejected — empty + + def test_rotation_settings_load_rejects_database(self): + from keepercommander.commands.pam_import.base import PamRotationSettingsObject + r = PamRotationSettingsObject.load({"rotation": "database", "enabled": "on"}) + assert r.rotation == "" # rejected — empty + + +class TestCPMRotationMapping: + """C2b: Verify CyberArk CPM state maps to rotation_settings and pam_settings.""" + + def test_cpm_enabled_sets_rotation_on(self): + mapper = AccountMapper() + account = { + "id": "1", "name": "linux-root", "platformId": "UnixSSH", + "address": "10.0.0.1", "userName": "root", + "secretManagement": {"automaticManagementEnabled": True, "status": "active"}, + } + result = mapper.map_account(account, "pass") + user = result["users"][0] + assert user["rotation_settings"]["enabled"] == "on" + assert result["pam_settings"]["options"]["rotation"] == "on" + + def test_cpm_disabled_sets_rotation_off(self): + mapper = AccountMapper() + account = { + "id": "2", "name": "manual-acct", "platformId": "UnixSSH", + "address": "10.0.0.2", "userName": "svc", + "secretManagement": { + "automaticManagementEnabled": False, + "manualManagementReason": "Service account — no auto-rotation", + }, + } + result = mapper.map_account(account, "pass") + user = result["users"][0] + assert user["rotation_settings"]["enabled"] == "off" + assert "CPM disabled" in user.get("notes", "") + assert result["pam_settings"]["options"]["rotation"] == "off" + + def test_missing_secret_management_defaults_to_on(self): + mapper = AccountMapper() + account = { + "id": "3", "name": "no-mgmt", "platformId": "WinDomain", + "address": "dc1.local", "userName": "admin", + } + result = mapper.map_account(account, "pass") + user = result["users"][0] + assert user["rotation_settings"]["enabled"] == "on" + + def test_pam_settings_options_structure(self): + mapper = AccountMapper() + account = { + "id": "4", "name": "ssh-box", "platformId": "UnixSSH", + "address": "10.0.0.4", "userName": "deploy", + } + result = mapper.map_account(account, "pass") + ps = result["pam_settings"] + assert "options" in ps + assert ps["options"]["connections"] == "on" + assert ps["options"]["tunneling"] == "off" + assert ps["options"]["graphical_session_recording"] == "off" + + def test_pam_settings_launch_credentials(self): + mapper = AccountMapper() + account = { + "id": "5", "name": "db-admin", "platformId": "MSSql", + "address": "sqlserver.local", "userName": "sa", + } + result = mapper.map_account(account, "pass") + user = result["users"][0] + assert result["pam_settings"]["connection"]["launch_credentials"] == user["title"] + + def test_schedule_always_on_demand(self): + mapper = AccountMapper() + account = { + "id": "6", "name": "scheduled", "platformId": "UnixSSH", + "address": "10.0.0.6", "userName": "root", + "secretManagement": {"automaticManagementEnabled": True}, + } + result = mapper.map_account(account, "pass") + user = result["users"][0] + assert user["rotation_settings"]["schedule"] == {"type": "on-demand"} + + +class TestConnectDatabaseMapping: + """C3: Verify Database property flows to connect_database on pamUser.""" + + def test_mssql_database_mapped(self): + mapper = AccountMapper() + account = { + "id": "100", "name": "MSSql-hr", "platformId": "MSSql", + "address": "dbserver1.cyberark.local", "userName": "sa", + "platformAccountProperties": {"Port": "15345", "Database": "hr"}, + } + result = mapper.map_account(account, "pass") + assert result["type"] == "pamDatabase" + assert result["port"] == "15345" + user = result["users"][0] + assert user.get("connect_database") == "hr" + + def test_mysql_database_mapped(self): + mapper = AccountMapper() + account = { + "id": "101", "name": "MySQL-app", "platformId": "MySQL", + "address": "mysql.internal", "userName": "root", + "platformAccountProperties": {"Database": "appdb"}, + } + result = mapper.map_account(account, "pass") + user = result["users"][0] + assert user.get("connect_database") == "appdb" + + def test_no_database_property_no_field(self): + mapper = AccountMapper() + account = { + "id": "102", "name": "UnixSSH-srv", "platformId": "UnixSSH", + "address": "10.0.0.1", "userName": "root", + "platformAccountProperties": {}, + } + result = mapper.map_account(account, "pass") + user = result["users"][0] + assert "connect_database" not in user + + def test_database_only_on_pam_database_type(self): + """Database property should not appear on pamMachine records.""" + mapper = AccountMapper() + account = { + "id": "103", "name": "UnixSSH-srv", "platformId": "UnixSSH", + "address": "10.0.0.1", "userName": "root", + "platformAccountProperties": {"Database": "should_not_appear"}, + } + result = mapper.map_account(account, "pass") + user = result["users"][0] + assert "connect_database" not in user + + +class TestOracleProtocol: + """C2: Verify Oracle uses a valid protocol.""" + + def test_oracle_protocol_is_registered(self): + mapping = DEFAULT_PLATFORM_MAP["Oracle"] + # sql-server is a registered protocol in base.py + assert mapping["protocol"] == "sql-server" + + +class TestReconcileAdminCredentials: + """H1: Verify reconcile account maps to administrative_credentials.""" + + def test_reconcile_role_tagged(self): + """resolve_linked_accounts should tag reconcile users with _ca_role.""" + from keepercommander.importer.cyberark.cyberark_pam import resolve_linked_accounts + client = MagicMock() + client.fetch_account_details.return_value = { + "linkedAccounts": { + "reconcileAccount": { + "id": "99_1", "name": "recon-admin", + "safeName": "AdminSafe", "userName": "recon_user" + } + } + } + client.retrieve_password.return_value = "recon_pass" + account = {"id": "1", "name": "target-account", "safeName": "TestSafe"} + result = resolve_linked_accounts(client, account) + assert len(result) == 1 + assert result[0]["_ca_role"] == "reconcile" + assert result[0]["login"] == "recon_user" + + def test_logon_role_tagged(self): + from keepercommander.importer.cyberark.cyberark_pam import resolve_linked_accounts + client = MagicMock() + client.fetch_account_details.return_value = { + "linkedAccounts": { + "logonAccount": { + "id": "99_2", "name": "logon-svc", + "safeName": "SvcSafe", "userName": "svc_user" + } + } + } + client.retrieve_password.return_value = "logon_pass" + account = {"id": "2", "name": "target", "safeName": "TestSafe"} + result = resolve_linked_accounts(client, account) + assert len(result) == 1 + assert result[0]["_ca_role"] == "logon" + + +class TestPickAdminCredentials: + """Keeper has one administrative_credentials slot; CyberArk may have both + reconcile and enable linked accounts. Reconcile wins; enable is fallback.""" + + def test_reconcile_only(self): + from keepercommander.importer.cyberark.cyberark_pam import pick_admin_credentials + linked = [{"title": "recon-svc (reconcile account)", "_ca_role": "reconcile"}] + title, role = pick_admin_credentials(linked) + assert title == "recon-svc (reconcile account)" + assert role == "reconcile" + + def test_enable_only_used_as_fallback(self): + from keepercommander.importer.cyberark.cyberark_pam import pick_admin_credentials + linked = [{"title": "enable-svc (enable account)", "_ca_role": "enable"}] + title, role = pick_admin_credentials(linked) + assert title == "enable-svc (enable account)" + assert role == "enable" + + def test_reconcile_preferred_over_enable_when_both_present(self): + from keepercommander.importer.cyberark.cyberark_pam import pick_admin_credentials + linked = [ + {"title": "enable-svc (enable account)", "_ca_role": "enable"}, + {"title": "recon-svc (reconcile account)", "_ca_role": "reconcile"}, + ] + title, role = pick_admin_credentials(linked) + assert role == "reconcile" + assert title == "recon-svc (reconcile account)" + + def test_logon_only_returns_none(self): + from keepercommander.importer.cyberark.cyberark_pam import pick_admin_credentials + linked = [{"title": "logon-svc (logon account)", "_ca_role": "logon"}] + title, role = pick_admin_credentials(linked) + assert title is None + assert role is None + + def test_empty_list_returns_none(self): + from keepercommander.importer.cyberark.cyberark_pam import pick_admin_credentials + title, role = pick_admin_credentials([]) + assert title is None and role is None + + +class TestPickLaunchCredentials: + """CyberArk's logonAccount is the connection credential (PSM logs in as the + logon account, then switches to the target). That maps to Keeper's + launch_credentials slot.""" + + def test_logon_returns_title(self): + from keepercommander.importer.cyberark.cyberark_pam import pick_launch_credentials + linked = [{"title": "svc-logon (logon account)", "_ca_role": "logon"}] + assert pick_launch_credentials(linked) == "svc-logon (logon account)" + + def test_no_logon_returns_none(self): + from keepercommander.importer.cyberark.cyberark_pam import pick_launch_credentials + linked = [ + {"title": "recon-svc (reconcile account)", "_ca_role": "reconcile"}, + {"title": "enable-svc (enable account)", "_ca_role": "enable"}, + ] + assert pick_launch_credentials(linked) is None + + def test_empty_list_returns_none(self): + from keepercommander.importer.cyberark.cyberark_pam import pick_launch_credentials + assert pick_launch_credentials([]) is None + + +# ── Existing CyberArk Importer Unchanged ──────────────────── + + +class TestExistingImporterUnchanged: + + def test_original_importer_still_importable(self): + from keepercommander.importer.cyberark import Importer + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + assert Importer is CyberArkImporter + + def test_original_importer_has_do_import(self): + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + assert hasattr(CyberArkImporter, "do_import") + + def test_original_endpoints_unchanged(self): + from keepercommander.importer.cyberark.cyberark import CyberArkImporter + expected_endpoints = { + "accounts": "Accounts", + "account_password": "Accounts/{account_id}/Password/Retrieve", + "logon": "Auth/{type}/Logon", + "safes": "Safes", + } + assert expected_endpoints.items() <= CyberArkImporter.ENDPOINTS.items() + + +# ── Phase 2 Tests: System Safe Exclusion + Safe Filtering ───── + +class TestSystemSafeExclusion: + """Tests for exclude_system_safes().""" + + def test_excludes_system_safes(self): + from keepercommander.importer.cyberark.cyberark_pam import exclude_system_safes + safes = [ + {"safeName": "Windows-Admins"}, + {"safeName": "System"}, + {"safeName": "VaultInternal"}, + {"safeName": "PVWAConfig"}, + {"safeName": "Unix-Servers"}, + ] + result = exclude_system_safes(safes) + names = [s["safeName"] for s in result] + assert "Windows-Admins" in names + assert "Unix-Servers" in names + assert "System" not in names + assert "VaultInternal" not in names + assert "PVWAConfig" not in names + + def test_include_system_override(self): + from keepercommander.importer.cyberark.cyberark_pam import exclude_system_safes + safes = [ + {"safeName": "Windows-Admins"}, + {"safeName": "System"}, + {"safeName": "VaultInternal"}, + ] + result = exclude_system_safes(safes, include_system=True) + assert len(result) == 3 + + def test_empty_list(self): + from keepercommander.importer.cyberark.cyberark_pam import exclude_system_safes + assert exclude_system_safes([]) == [] + + def test_all_system_safes(self): + from keepercommander.importer.cyberark.cyberark_pam import exclude_system_safes, SYSTEM_SAFES + safes = [{"safeName": name} for name in list(SYSTEM_SAFES)[:5]] + result = exclude_system_safes(safes) + assert len(result) == 0 + + +class TestSafeNameSanitization: + """Tests for sanitize_safe_name() and deduplicate_safe_names().""" + + def test_basic_name(self): + from keepercommander.importer.cyberark.cyberark_pam import sanitize_safe_name + assert sanitize_safe_name("Windows-Admins") == "Windows-Admins" + + def test_path_traversal_stripped(self): + from keepercommander.importer.cyberark.cyberark_pam import sanitize_safe_name + result = sanitize_safe_name("../../../etc/passwd") + assert ".." not in result + assert "/" not in result + + def test_slashes_replaced(self): + from keepercommander.importer.cyberark.cyberark_pam import sanitize_safe_name + result = sanitize_safe_name("Corp/IT\\Servers") + assert "/" not in result + assert "\\" not in result + + def test_max_length_truncated(self): + from keepercommander.importer.cyberark.cyberark_pam import sanitize_safe_name, MAX_SAFE_NAME_LENGTH + long_name = "A" * 50 + result = sanitize_safe_name(long_name) + assert len(result) <= MAX_SAFE_NAME_LENGTH + + def test_empty_name(self): + from keepercommander.importer.cyberark.cyberark_pam import sanitize_safe_name + assert sanitize_safe_name("") == "Unnamed-Safe" + + def test_whitespace_only(self): + from keepercommander.importer.cyberark.cyberark_pam import sanitize_safe_name + assert sanitize_safe_name(" ") == "Unnamed-Safe" + + def test_dedup_collisions(self): + from keepercommander.importer.cyberark.cyberark_pam import deduplicate_safe_names + safes = [ + {"safeUrlId": "safe1", "safeName": "TestSafe"}, + {"safeUrlId": "safe2", "safeName": "TestSafe"}, + {"safeUrlId": "safe3", "safeName": "TestSafe"}, + ] + result = deduplicate_safe_names(safes) + assert result["safe1"] == "TestSafe" + assert result["safe2"] == "TestSafe #2" + assert result["safe3"] == "TestSafe #3" + + def test_dedup_no_collision(self): + from keepercommander.importer.cyberark.cyberark_pam import deduplicate_safe_names + safes = [ + {"safeUrlId": "a", "safeName": "Alpha"}, + {"safeUrlId": "b", "safeName": "Beta"}, + ] + result = deduplicate_safe_names(safes) + assert result["a"] == "Alpha" + assert result["b"] == "Beta" + + +# ── Phase 2 Audit Fix Tests ────────────────────────────────── + +class TestSystemSafeExclusionCaseInsensitive: + """Verify case-insensitive system safe exclusion.""" + + def test_lowercase_system_safe_excluded(self): + from keepercommander.importer.cyberark.cyberark_pam import exclude_system_safes + safes = [{"safeName": "system"}, {"safeName": "UserSafe"}] + result = exclude_system_safes(safes) + assert len(result) == 1 + assert result[0]["safeName"] == "UserSafe" + + def test_uppercase_system_safe_excluded(self): + from keepercommander.importer.cyberark.cyberark_pam import exclude_system_safes + safes = [{"safeName": "VAULTINTERNAL"}, {"safeName": "RealSafe"}] + result = exclude_system_safes(safes) + assert len(result) == 1 + assert result[0]["safeName"] == "RealSafe" + + def test_mixed_case_system_safe_excluded(self): + from keepercommander.importer.cyberark.cyberark_pam import exclude_system_safes + safes = [{"safeName": "pvwaConfig"}, {"safeName": "Production"}] + result = exclude_system_safes(safes) + assert len(result) == 1 + + +class TestSafeNameSanitizationExtended: + """Additional edge cases for sanitize_safe_name.""" + + def test_unicode_name_preserved(self): + from keepercommander.importer.cyberark.cyberark_pam import sanitize_safe_name + result = sanitize_safe_name("Café-Serveurs") + assert "Café" in result + + def test_control_chars_stripped(self): + from keepercommander.importer.cyberark.cyberark_pam import sanitize_safe_name + result = sanitize_safe_name("Safe\x00Name\nWith\tCtrl") + assert "\x00" not in result + assert "\n" not in result + assert "\t" not in result + assert "SafeName" in result + + def test_exact_max_length(self): + from keepercommander.importer.cyberark.cyberark_pam import sanitize_safe_name, MAX_SAFE_NAME_LENGTH + name = "A" * MAX_SAFE_NAME_LENGTH + result = sanitize_safe_name(name) + assert len(result) == MAX_SAFE_NAME_LENGTH + + def test_one_over_max_length(self): + from keepercommander.importer.cyberark.cyberark_pam import sanitize_safe_name, MAX_SAFE_NAME_LENGTH + name = "A" * (MAX_SAFE_NAME_LENGTH + 1) + result = sanitize_safe_name(name) + assert len(result) == MAX_SAFE_NAME_LENGTH + + def test_dedup_respects_max_length(self): + from keepercommander.importer.cyberark.cyberark_pam import deduplicate_safe_names, MAX_SAFE_NAME_LENGTH + long_name = "A" * 30 # exceeds 28 + safes = [ + {"safeUrlId": "a", "safeName": long_name}, + {"safeUrlId": "b", "safeName": long_name}, + ] + result = deduplicate_safe_names(safes) + for name in result.values(): + assert len(name) <= MAX_SAFE_NAME_LENGTH + + +class TestInteractiveSafePicker: + """Tests for _interactive_safe_picker.""" + + def test_select_all(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": "Safe1"}, {"safeName": "Safe2"}] + with patch("builtins.input", return_value="A"): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result is None # None = import all + + def test_select_empty(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": "Safe1"}, {"safeName": "Safe2"}] + with patch("builtins.input", return_value=""): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result is None + + def test_select_specific(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": "Alpha"}, {"safeName": "Beta"}, {"safeName": "Gamma"}] + with patch("builtins.input", return_value="1,3"): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result == "Alpha,Gamma" + + def test_select_invalid_input(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": "Safe1"}] + with patch("builtins.input", return_value="abc"): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result is None # invalid → all + + def test_eof_returns_none(self): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + from unittest.mock import patch + safes = [{"safeName": "Safe1"}] + with patch("builtins.input", side_effect=EOFError): + result = CyberArkPAMImportCommand._interactive_safe_picker(safes) + assert result is None + + +class TestListSafesDetailed: + """Tests for _list_safes_detailed.""" + + def test_output_contains_safe_names(self, capsys): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + safes = [ + {"safeName": "Windows-Admins", "managingCPM": "PasswordManager"}, + {"safeName": "Unix-Servers", "managingCPM": ""}, + ] + CyberArkPAMImportCommand._list_safes_detailed(safes, 3) + out = capsys.readouterr().out + assert "Windows-Admins" in out + assert "Unix-Servers" in out + assert "3 system safes excluded" in out + assert "Total: 2 safes" in out + + def test_output_no_system_excluded(self, capsys): + from keepercommander.commands.pam_import.cyberark_import import CyberArkPAMImportCommand + safes = [{"safeName": "MySafe", "managingCPM": "CPM1"}] + CyberArkPAMImportCommand._list_safes_detailed(safes, 0) + out = capsys.readouterr().out + assert "system safes excluded" not in out + + +# ── Phase 3 Tests: Linked Accounts + Dual Accounts ─────────── + +class TestFetchAccountDetails: + """Tests for CyberArkPVWAClient.fetch_account_details.""" + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_returns_details_with_linked_accounts(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "id": "25_4", + "name": "WinDomain-admin", + "linkedAccounts": { + "logonAccount": {"id": "25_8", "name": "logon-svc", "safeName": "Admins"}, + "reconcileAccount": {"id": "25_9", "name": "recon-svc", "safeName": "Admins"}, + } + } + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test-token" + result = client.fetch_account_details("25_4") + assert result is not None + assert "linkedAccounts" in result + assert "logonAccount" in result["linkedAccounts"] + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_returns_none_on_failure(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test-token" + result = client.fetch_account_details("bad_id") + assert result is None + + def test_rejects_invalid_account_id(self): + from keepercommander.importer.cyberark.cyberark_pam import CyberArkPVWAClient + client = CyberArkPVWAClient.__new__(CyberArkPVWAClient) + client.auth_token = "test" + client.verify_ssl = True + result = client.fetch_account_details("../../admin") + assert result is None + + +class TestResolveLinkedAccounts: + """Tests for resolve_linked_accounts.""" + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_resolves_logon_and_reconcile(self, mock_dns, mock_requests): + from keepercommander.importer.cyberark.cyberark_pam import resolve_linked_accounts + # Mock detail response with linked accounts + detail_resp = MagicMock() + detail_resp.status_code = 200 + detail_resp.json.return_value = { + "id": "25_4", + "linkedAccounts": { + "logonAccount": {"id": "25_8", "name": "logon-svc", "userName": "logon-user", "safeName": "Admins"}, + "reconcileAccount": {"id": "25_9", "name": "recon-svc", "userName": "recon-user", "safeName": "Admins"}, + } + } + # Mock password responses + pw_resp = MagicMock() + pw_resp.status_code = 200 + pw_resp.text = '"LinkedPwd123"' + + mock_requests.get.return_value = detail_resp + mock_requests.post.return_value = pw_resp + + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test-token" + + account = {"id": "25_4", "name": "admin-account", "safeName": "Admins"} + result = resolve_linked_accounts(client, account) + + assert len(result) == 2 + types = {r["title"] for r in result} + assert any("logon" in t for t in types) + assert any("reconcile" in t for t in types) + assert all(r["type"] == "pamUser" for r in result) + + def test_returns_empty_when_no_linked(self): + from keepercommander.importer.cyberark.cyberark_pam import resolve_linked_accounts + client = MagicMock() + client.fetch_account_details.return_value = {"id": "1", "linkedAccounts": {}} + result = resolve_linked_accounts(client, {"id": "1"}) + assert result == [] + + def test_returns_empty_when_details_fail(self): + from keepercommander.importer.cyberark.cyberark_pam import resolve_linked_accounts + client = MagicMock() + client.fetch_account_details.return_value = None + result = resolve_linked_accounts(client, {"id": "1"}) + assert result == [] + + +class TestDetectDualAccount: + """Tests for detect_dual_account.""" + + def test_detects_dual_account(self): + from keepercommander.importer.cyberark.cyberark_pam import detect_dual_account + account = { + "platformAccountProperties": { + "VirtualUserName": "svc_rotation", + "GroupPlatformID": "WinDualAccount", + "Index": "1", + } + } + result = detect_dual_account(account) + assert result is not None + assert result["ca_virtual_username"] == "svc_rotation" + assert result["ca_dual_account_group"] == "WinDualAccount" + assert result["ca_dual_account_index"] == "1" + + def test_returns_none_for_normal_account(self): + from keepercommander.importer.cyberark.cyberark_pam import detect_dual_account + account = { + "platformAccountProperties": {"LogonDomain": "mydomain"} + } + result = detect_dual_account(account) + assert result is None + + def test_handles_missing_properties(self): + from keepercommander.importer.cyberark.cyberark_pam import detect_dual_account + account = {} + result = detect_dual_account(account) + assert result is None + + def test_partial_dual_fields(self): + from keepercommander.importer.cyberark.cyberark_pam import detect_dual_account + account = { + "platformAccountProperties": {"VirtualUserName": "svc_user"} + } + result = detect_dual_account(account) + assert result is not None + assert "ca_virtual_username" in result + assert "ca_dual_account_group" not in result + + +# ── Phase 4 Tests: Permission Mapping + Safe Members ───────── + +class TestPermissionMapper: + """Tests for PermissionMapper.map_permissions and map_member.""" + + def test_view_only(self): + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + perms = {"listAccounts": True, "useAccounts": True, + "retrieveAccounts": True, "addAccounts": False, + "updateAccountContent": False, "manageSafe": False, + "manageSafeMembers": False} + result = PermissionMapper.map_permissions(perms) + assert result["can_edit"] is False + assert result["can_share"] is False + assert result["manage_users"] is False + assert result["manage_records"] is False + + def test_edit_tier(self): + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + perms = {"listAccounts": True, "useAccounts": True, + "addAccounts": True, "updateAccountContent": True, + "manageSafe": False, "manageSafeMembers": False} + result = PermissionMapper.map_permissions(perms) + assert result["can_edit"] is True + assert result["can_share"] is False + assert result["manage_users"] is False + + def test_manage_tier(self): + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + perms = {"listAccounts": True, "useAccounts": True, + "addAccounts": True, "updateAccountContent": True, + "manageSafe": True, "manageSafeMembers": True} + result = PermissionMapper.map_permissions(perms) + assert result["can_edit"] is True + assert result["can_share"] is True + assert result["manage_users"] is True + assert result["manage_records"] is True + + def test_edit_via_update_properties(self): + """updateAccountProperties alone (without updateAccountContent) triggers edit tier.""" + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + perms = {"listAccounts": True, "useAccounts": True, + "addAccounts": True, "updateAccountProperties": True, + "updateAccountContent": False, + "manageSafe": False, "manageSafeMembers": False} + result = PermissionMapper.map_permissions(perms) + assert result["can_edit"] is True + assert result["manage_records"] is True + assert result["manage_users"] is False + + def test_edit_tier_grants_manage_records(self): + """Edit tier should set manage_records=True (can modify records in shared folder).""" + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + perms = {"listAccounts": True, "useAccounts": True, + "addAccounts": True, "updateAccountContent": True} + result = PermissionMapper.map_permissions(perms) + assert result["manage_records"] is True + assert result["manage_users"] is False + + def test_no_permissions(self): + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + result = PermissionMapper.map_permissions({}) + assert result["can_edit"] is False + assert result["can_share"] is False + + def test_none_input(self): + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + result = PermissionMapper.map_permissions(None) + assert result["can_edit"] is False + + def test_unmapped_permissions_detected(self): + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + perms = {"accessWithoutConfirmation": True, + "requestsAuthorizationLevel1": True, + "requestsAuthorizationLevel2": False} + result = PermissionMapper.get_unmapped_permissions(perms) + assert "accessWithoutConfirmation" in result + assert "requestsAuthorizationLevel1" in result + assert "requestsAuthorizationLevel2" not in result + + def test_map_member_user(self): + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + member = { + "memberName": "john.doe@company.com", + "memberType": "User", + "permissions": {"listAccounts": True, "useAccounts": True, + "addAccounts": False, "updateAccountContent": False, + "manageSafe": False, "manageSafeMembers": False}, + } + result = PermissionMapper.map_member(member) + assert result["name"] == "john.doe@company.com" + assert result["member_type"] == "user" + assert result["permissions"]["can_edit"] is False + + def test_map_member_group(self): + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + member = { + "memberName": "Windows-Admins", + "memberType": "Group", + "permissions": {"listAccounts": True, "useAccounts": True, + "addAccounts": True, "updateAccountContent": True, + "manageSafe": True, "manageSafeMembers": True}, + } + result = PermissionMapper.map_member(member) + assert result["name"] == "Windows-Admins" + assert result["member_type"] == "team" + assert result["permissions"]["can_share"] is True + assert result["permissions"]["manage_users"] is True + + def test_manage_requires_edit(self): + """manage without edit should not grant manage.""" + from keepercommander.importer.cyberark.cyberark_pam import PermissionMapper + perms = {"listAccounts": True, "useAccounts": True, + "addAccounts": False, "updateAccountContent": False, + "manageSafe": True, "manageSafeMembers": True} + result = PermissionMapper.map_permissions(perms) + assert result["manage_users"] is False + assert result["can_share"] is False + + +class TestFetchSafeMembers: + """Tests for CyberArkPVWAClient.fetch_safe_members.""" + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_fetches_and_filters_predefined(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "value": [ + {"memberName": "Master", "memberType": "User", "isPredefinedUser": True, + "permissions": {}}, + {"memberName": "john.doe", "memberType": "User", "isPredefinedUser": False, + "permissions": {"listAccounts": True}}, + {"memberName": "Admins", "memberType": "Group", "isPredefinedUser": False, + "permissions": {"manageSafe": True}}, + ], + "count": 3, + } + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test-token" + result = client.fetch_safe_members("TestSafe") + assert len(result) == 2 # Master filtered out + names = [m["memberName"] for m in result] + assert "Master" not in names + assert "john.doe" in names + assert "Admins" in names + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_handles_api_failure(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test-token" + result = client.fetch_safe_members("ForbiddenSafe") + assert result == [] + + +class TestSkipMembersFlag: + """Verify --skip-members flag parses correctly.""" + + def test_skip_members_flag_parses(self): + cmd = CyberArkPAMImportCommand() + args = cmd.parser.parse_args(["pvwa.example.com", "--skip-members"]) + assert args.skip_members is True + + def test_skip_members_default_false(self): + cmd = CyberArkPAMImportCommand() + args = cmd.parser.parse_args(["pvwa.example.com"]) + assert args.skip_members is False + + +class TestFetchSafeMembersPagination: + """Test pagination loop in fetch_safe_members.""" + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_pagination_fetches_multiple_pages(self, mock_dns, mock_requests): + page1 = [{"memberName": f"user{i}", "memberType": "User", + "isPredefinedUser": False, "permissions": {}} + for i in range(100)] + page2 = [{"memberName": "last_user", "memberType": "User", + "isPredefinedUser": False, "permissions": {}}] + + resp1 = MagicMock() + resp1.status_code = 200 + resp1.json.return_value = {"value": page1, "count": 101} + + resp2 = MagicMock() + resp2.status_code = 200 + resp2.json.return_value = {"value": page2, "count": 101} + + mock_requests.get.side_effect = [resp1, resp2] + + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test" + result = client.fetch_safe_members("TestSafe") + assert len(result) == 101 + assert result[-1]["memberName"] == "last_user" + + +# ── Phase 5 Tests: User/Team Matching + CSV ────────────────── + +class TestUserTeamMatcher: + """Tests for UserTeamMatcher.""" + + def test_match_user_by_email(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher( + keeper_users=[{"email": "john@company.com"}]) + result = matcher.match_user("john.doe", cyberark_email="john@company.com") + assert result == "john@company.com" + assert len(matcher.unmatched) == 0 + + def test_match_user_by_username_email(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher( + keeper_users=[{"email": "john@company.com"}]) + result = matcher.match_user("john@company.com") + assert result == "john@company.com" + + def test_match_user_by_override(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher( + keeper_users=[{"email": "john@company.com"}], + user_map_override={"ca_admin": "john@company.com"}) + result = matcher.match_user("ca_admin") + assert result == "john@company.com" + + def test_user_not_found(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher(keeper_users=[{"email": "john@company.com"}]) + result = matcher.match_user("unknown_user", cyberark_email="unknown@other.com") + assert result is None + assert len(matcher.unmatched) == 1 + assert matcher.unmatched[0]["cyberark_username"] == "unknown_user" + + def test_match_team_by_name(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher( + keeper_teams=[{"name": "Windows Admins"}]) + result = matcher.match_team("Windows Admins") + assert result == "Windows Admins" + + def test_match_team_case_insensitive(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher( + keeper_teams=[{"name": "Windows Admins"}]) + result = matcher.match_team("windows admins") + assert result == "windows admins" + + def test_team_not_found(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher(keeper_teams=[{"name": "Existing Team"}]) + result = matcher.match_team("New Team") + assert result is None + assert len(matcher.unmatched) == 1 + assert matcher.unmatched[0]["suggested_action"] == "create_team" + + def test_empty_matcher(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher() + result = matcher.match_user("anyone") + assert result is None + result = matcher.match_team("any_team") + assert result is None + assert len(matcher.unmatched) == 2 + + +class TestCSVGeneration: + """Tests for UserTeamMatcher.generate_csv.""" + + def test_generates_csv_with_unmatched(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher() + matcher.match_user("admin", cyberark_email="admin@old.com", + cyberark_groups="Admins") + matcher.match_team("Missing Team") + csv = matcher.generate_csv() + lines = csv.strip().split('\n') + assert len(lines) == 3 # header + 2 rows + assert 'cyberark_username' in lines[0] + assert 'admin' in lines[1] + assert 'Missing Team' in lines[2] + + def test_empty_csv_when_all_matched(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher( + keeper_users=[{"email": "admin@company.com"}]) + matcher.match_user("admin", cyberark_email="admin@company.com") + csv = matcher.generate_csv() + assert csv == '' + + def test_csv_escapes_commas(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher() + matcher.match_user("user,with,commas", cyberark_email="test@x.com") + csv_output = matcher.generate_csv() + # csv.writer with QUOTE_ALL properly quotes fields with commas + assert '"user,with,commas"' in csv_output + + def test_no_credentials_in_csv(self): + from keepercommander.importer.cyberark.cyberark_pam import UserTeamMatcher + matcher = UserTeamMatcher() + matcher.match_user("admin", cyberark_email="admin@x.com") + csv = matcher.generate_csv() + assert 'password' not in csv.lower() + assert 'secret' not in csv.lower() + assert 'token' not in csv.lower() + + +class TestFetchUsersAndGroups: + """Tests for fetch_users and fetch_user_groups.""" + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_fetch_users_excludes_component(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "Users": [ + {"id": 1, "username": "john.doe", "componentUser": False, + "personalDetails": {"email": "john@x.com"}}, + ], + "Total": 1, + } + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test" + result = client.fetch_users() + assert len(result) == 1 + assert result[0]["username"] == "john.doe" + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_fetch_user_groups(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "value": [ + {"id": 10, "groupName": "Windows Admins", "groupType": "Vault"}, + ], + "count": 1, + } + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test" + result = client.fetch_user_groups() + assert len(result) == 1 + assert result[0]["groupName"] == "Windows Admins" + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_fetch_users_handles_failure(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test" + result = client.fetch_users() + assert result == [] + + +# ── Phase 6 Tests: Master Policy → PAM Config ──────────────── + +class TestMasterPolicyMapper: + """Tests for MasterPolicyMapper.map_policy.""" + + def test_session_recording_active(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + policy = {"Policy": {"Rules": [ + {"RuleName": "RecordAndSaveSessionActivity", "Active": True}, + ]}} + config, unmapped = MasterPolicyMapper.map_policy(policy) + assert config["graphical_session_recording"] == "on" + assert config["text_session_recording"] == "on" + + def test_session_recording_inactive(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + policy = {"Policy": {"Rules": [ + {"RuleName": "RecordAndSaveSessionActivity", "Active": False}, + ]}} + config, unmapped = MasterPolicyMapper.map_policy(policy) + assert config["graphical_session_recording"] == "off" + assert config["text_session_recording"] == "off" + + def test_connections_active(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + policy = {"Policy": {"Rules": [ + {"RuleName": "AllowEPVTransparentConnections", "Active": True}, + ]}} + config, unmapped = MasterPolicyMapper.map_policy(policy) + assert config["connections"] == "on" + + def test_connections_inactive(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + policy = {"Policy": {"Rules": [ + {"RuleName": "AllowEPVTransparentConnections", "Active": False}, + ]}} + config, unmapped = MasterPolicyMapper.map_policy(policy) + assert config["connections"] == "off" + + def test_unmapped_dual_control(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + policy = {"Policy": {"Rules": [ + {"RuleName": "RequireDualControlPasswordAccessApproval", "Active": True}, + {"RuleName": "EnforceCheckinCheckoutExclusiveAccess", "Active": True}, + {"RuleName": "EnforceOnetimePasswordAccess", "Active": False}, + ]}} + config, unmapped = MasterPolicyMapper.map_policy(policy) + categories = [u["item"] for u in unmapped] + assert any("Dual control" in c for c in categories) + assert any("Exclusive checkout" in c for c in categories) + assert not any("One-time" in c for c in categories) # False = not active + + def test_audit_retention_unmapped(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + policy = {"Policy": {"Rules": [ + {"RuleName": "SafeAuditRetention", "Value": 365}, + ]}} + config, unmapped = MasterPolicyMapper.map_policy(policy) + assert any("365" in u["item"] for u in unmapped) + assert any("Admin Console" in u["action"] for u in unmapped) + + def test_none_policy_returns_defaults(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + config, unmapped = MasterPolicyMapper.map_policy(None) + assert config["connections"] == "on" + assert config["graphical_session_recording"] == "off" + assert unmapped == [] + + def test_empty_dict_returns_defaults(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + config, unmapped = MasterPolicyMapper.map_policy({}) + assert config["connections"] == "on" + assert unmapped == [] + + def test_malformed_rules_handled(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + policy = {"Policy": {"Rules": ["not_a_dict", 42, None]}} + config, unmapped = MasterPolicyMapper.map_policy(policy) + assert config["connections"] == "on" # defaults + assert unmapped == [] + + +class TestMasterPolicyRotationExceptions: + """Tests for master-rotation-policy/exceptions parsing.""" + + def test_parse_list_shape(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + data = [ + {"platformId": "WinDesktopLocal", "changeInterval": 30}, + {"platformId": "WinDomain", "interval": 60, "allowedPeriodic": True}, + ] + schedules = MasterPolicyMapper.parse_rotation_exceptions(data) + assert "WinDesktopLocal" in schedules + assert schedules["WinDesktopLocal"]["type"] == "CRON" + assert schedules["WinDomain"]["type"] == "CRON" + + def test_parse_cyberark_exceptions_envelope(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + data = { + "exceptions": [ + {"platformId": "WinDesktopLocal", "verifyInterval": 7, + "changeInterval": 30}, + {"platformId": "WinDomain", "verifyInterval": 7, + "changeInterval": 0}, + ], + "totalCount": 2, + } + schedules = MasterPolicyMapper.parse_rotation_exceptions(data) + assert schedules["WinDesktopLocal"]["type"] == "CRON" + assert schedules["WinDesktopLocal"]["cron"] == "0 0 0 1 * ?" + assert "WinDomain" not in schedules # changeInterval=0 → no schedule + + def test_parse_grouped_shape(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + data = { + "changeInterval": [ + {"platformId": "WinLocalAccount", "interval": 30}, + ], + } + schedules = MasterPolicyMapper.parse_rotation_exceptions(data) + assert schedules["WinLocalAccount"]["cron"] == "0 0 0 1 * ?" + + def test_allowed_periodic_false_still_uses_interval(self): + """``allowedPeriodic`` is informational only — an explicit exception + interval is always honored as a CRON cadence, same as the Master + Policy default itself (which has no such flag to check).""" + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + data = [{"platformId": "WinDesktopLocal", "interval": 90, + "allowedPeriodic": False}] + schedules = MasterPolicyMapper.parse_rotation_exceptions(data) + assert schedules["WinDesktopLocal"] == {"type": "CRON", "cron": "0 0 0 1 */3 ?"} + + def test_empty_payload(self): + from keepercommander.importer.cyberark.cyberark_pam import MasterPolicyMapper + assert MasterPolicyMapper.parse_rotation_exceptions(None) == {} + assert MasterPolicyMapper.parse_rotation_exceptions([]) == {} + + +class TestPlatformScheduleWithExceptions: + """AccountMapper honors master-policy rotation exceptions.""" + + def test_master_exception_overrides_inherit_master(self): + client = MagicMock() + client.fetch_platform_rotation_policy.return_value = { + "change": { + "interval": 90, + "allowedPeriodic": True, + "overridesMasterPolicy": False, + }, + } + exc = {"WinDesktopLocal": {"type": "CRON", "cron": "0 0 0 */30 * ?"}} + mapper = AccountMapper( + client=client, + master_rotation_exceptions=exc, + ) + sched = mapper._resolve_platform_schedule("WinDesktopLocal") + assert sched == exc["WinDesktopLocal"] + assert mapper.platform_schedule_overrides.get("WinDesktopLocal", 0) == 0 + + def test_platform_override_beats_master_exception(self): + client = MagicMock() + client.fetch_platform_rotation_policy.return_value = { + "change": { + "interval": 30, + "allowedPeriodic": True, + "overridesMasterPolicy": True, + }, + } + exc = {"WinDesktopLocal": {"type": "CRON", "cron": "0 0 0 1 */3 ?"}} + mapper = AccountMapper(client=client, master_rotation_exceptions=exc) + sched = mapper._resolve_platform_schedule("WinDesktopLocal") + assert sched == {"type": "CRON", "cron": "0 0 0 1 * ?"} + + def test_allowed_periodic_false_without_exception_inherits_master(self): + """``overridesMasterPolicy=False`` means no exception exists — the + platform inherits the Master Policy's own CRON schedule. The + informational ``allowedPeriodic=False`` flag must NOT force + on-demand here (CyberArk's own Master Policy default has no such + flag and is always applied as CRON).""" + client = MagicMock() + client.fetch_platform_rotation_policy.return_value = { + "change": { + "interval": 90, + "allowedPeriodic": False, + "overridesMasterPolicy": False, + }, + } + mapper = AccountMapper(client=client) + sched = mapper._resolve_platform_schedule("WinDesktopLocal") + assert sched is None # caller applies default_rotation_schedule (master CRON) + + def test_interval_mismatch_treated_as_exception_without_flag(self): + """No recognized override flag present, but the platform's own + interval differs from the Master Policy value — CyberArk is + clearly applying a platform-specific cadence, so honor it.""" + client = MagicMock() + client.fetch_platform_rotation_policy.return_value = { + "change": { + "interval": 30, + "allowedPeriodic": True, + # No overridesMasterPolicy / isException key at all. + }, + } + mapper = AccountMapper(client=client, master_change_days=90) + sched = mapper._resolve_platform_schedule("WinDesktopLocal") + assert sched == {"type": "CRON", "cron": "0 0 0 1 * ?"} # 30 days -> monthly + + def test_matching_interval_without_flag_inherits_master(self): + """No flag, and the interval matches master — no exception exists.""" + client = MagicMock() + client.fetch_platform_rotation_policy.return_value = { + "change": {"interval": 90, "allowedPeriodic": True}, + } + mapper = AccountMapper(client=client, master_change_days=90) + sched = mapper._resolve_platform_schedule("WinDesktopLocal") + assert sched is None + + def test_legacy_is_exception_flag_name_honored(self): + """Alternate flag name (``isException``) observed on some tenants.""" + client = MagicMock() + client.fetch_platform_rotation_policy.return_value = { + "change": { + "interval": 14, + "allowedPeriodic": True, + "isException": True, + }, + } + mapper = AccountMapper(client=client, master_change_days=90) + sched = mapper._resolve_platform_schedule("WinLocalAccount") + assert sched == {"type": "CRON", "cron": "0 0 0 1,15 * ?"} # 14 days -> bi-weekly + + +class TestMasterRotationExceptionsBogusShapeDetection: + """CyberArkPVWAClient must not mistake the base master-policy object + (re-served by some tenants at .../exceptions/) for real exception data.""" + + def test_looks_like_base_master_policy_detected(self): + data = { + "changeInterval": 90, + "changeIntervalExceptionsCount": 1, + "verifyInterval": 7, + "verifyIntervalExceptionsCount": 0, + } + assert CyberArkPVWAClient._looks_like_base_master_policy(data) is True + + def test_real_exceptions_envelope_not_flagged(self): + data = { + "exceptions": [{"platformId": "WinDesktopLocal", + "changeInterval": 30, "verifyInterval": 7}], + "totalCount": 1, + } + assert CyberArkPVWAClient._looks_like_base_master_policy(data) is False + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_fetch_returns_none_for_bogus_shape(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "changeInterval": 90, + "changeIntervalExceptionsCount": 1, + "verifyInterval": 7, + "verifyIntervalExceptionsCount": 0, + } + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test" + result = client.fetch_master_rotation_policy_exceptions() + assert result is None + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_fetch_returns_data_for_real_envelope(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "exceptions": [{"platformId": "WinDesktopLocal", + "changeInterval": 30, "verifyInterval": 7}], + "totalCount": 1, + } + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test" + result = client.fetch_master_rotation_policy_exceptions() + assert result is not None + assert result["totalCount"] == 1 + + +class TestFetchMasterPolicy: + """Tests for CyberArkPVWAClient.fetch_master_policy.""" + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_returns_policy_on_success(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "Policy": {"Rules": [ + {"RuleName": "RecordAndSaveSessionActivity", "Active": True}, + ]} + } + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test" + result = client.fetch_master_policy() + assert result is not None + assert "Policy" in result + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_returns_none_on_403(self, mock_dns, mock_requests): + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_requests.get.return_value = mock_resp + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test" + result = client.fetch_master_policy() + assert result is None + + @patch("keepercommander.importer.cyberark.cyberark_pam.requests") + @patch("keepercommander.importer.cyberark.cyberark_pam.socket.getaddrinfo", + return_value=[(2, 1, 6, '', ('93.184.216.34', 0))]) + def test_returns_none_on_network_error(self, mock_dns, mock_requests): + import requests as _req + mock_requests.get.side_effect = _req.ConnectionError("network down") + mock_requests.RequestException = _req.RequestException + client = CyberArkPVWAClient("pvwa.example.com") + client.auth_token = "test" + result = client.fetch_master_policy() + assert result is None + + +# ── Red Team Coverage Tests ────────────────────────────────── + +class TestEscFunction: + """Tests for _esc() HTML + control char sanitizer.""" + + def test_html_chars_escaped(self): + from keepercommander.importer.cyberark.cyberark_pam import _esc + assert '<' in _esc('