From 9b1b4b6bd67951e0cc355db48e84bc714fa034fe Mon Sep 17 00:00:00 2001 From: pvagare-ks Date: Fri, 5 Jun 2026 16:48:27 +0530 Subject: [PATCH 1/9] initial commit --- docs/cyberark-pam-import.md | 288 ++ .../commands/pam_import/commands.py | 3 + .../commands/pam_import/cyberark_import.py | 1080 +++++ .../importer/cyberark/cyberark_pam.py | 1833 +++++++++ tests/test_cyberark_pam_import.py | 3533 +++++++++++++++++ 5 files changed, 6737 insertions(+) create mode 100644 docs/cyberark-pam-import.md create mode 100644 keepercommander/commands/pam_import/cyberark_import.py create mode 100644 keepercommander/importer/cyberark/cyberark_pam.py create mode 100644 tests/test_cyberark_pam_import.py diff --git a/docs/cyberark-pam-import.md b/docs/cyberark-pam-import.md new file mode 100644 index 000000000..60f2e3e36 --- /dev/null +++ b/docs/cyberark-pam-import.md @@ -0,0 +1,288 @@ +# 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. + +**Branch**: `feature/cyberark-pam-import` on `jlima8900/Commander` +**Commands**: `pam project cyberark-import` (alias: `ca`) and `pam project cyberark-cleanup` (alias: `CC`) +**Status**: 287 tests passing, 29 commits, rebased on Release v17.2.13 + +--- + +## 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/pam_import/commands.py b/keepercommander/commands/pam_import/commands.py index bec4017e0..d60dbf2ec 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", "ca") + 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..7883d44e4 --- /dev/null +++ b/keepercommander/commands/pam_import/cyberark_import.py @@ -0,0 +1,1080 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """Write data to a temp file with restrictive permissions (owner-only read/write). + Returns the temp file path. Caller MUST call _remove_secure_temp() after use.""" + # Create temp file with restrictive permissions atomically (no race window) + tmp_dir = tempfile.gettempdir() + fd = tempfile.mkstemp(suffix='.json', prefix='keeper_ca_import_', dir=tmp_dir) + tmp_fd, tmp_path = fd + try: + with os.fdopen(tmp_fd, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + # mkstemp creates with 0o600 by default on POSIX + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + _pending_temp_files.add(tmp_path) + return tmp_path + + +def _remove_secure_temp(tmp_path: str): + """Securely remove a temp file — overwrite content before unlinking.""" + try: + if os.path.exists(tmp_path): + size = os.path.getsize(tmp_path) + with open(tmp_path, 'wb') as f: + f.write(b'\x00' * size) + os.unlink(tmp_path) + except OSError: + pass + _pending_temp_files.discard(tmp_path) + + +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=["ksm", "exact", "flat"], default="flat", + help="Safe → folder mapping mode (default: flat)") + 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("--user-map", required=False, dest="user_map", action="store", + default="", help="JSON file mapping CyberArk users to Keeper emails") + + 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) + + # All placeholder flags now implemented (Phases 3-5 complete) + + 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: + self._run_import(params, client, kwargs, dry_run, output_file, + include_creds, estimate_only, skip_confirm, skip_users, + skip_linked, safe_include, safe_exclude, list_safes, + batch_size, batch_delay, folder_mode, project_name, + config_uid, gateway_name, platform_map_override, + state_filter) + finally: + client.logoff() + + def _run_import(self, params, client, kwargs, dry_run, output_file, + include_creds, estimate_only, skip_confirm, skip_users, + skip_linked, safe_include, safe_exclude, list_safes, + batch_size, batch_delay, folder_mode, project_name, + config_uid, gateway_name, platform_map_override, + state_filter): + """Core import logic — separated from execute() for logoff guarantee.""" + + unmapped_items = [] # items requiring manual action post-import + + # ── Phase 0e: Fetch Master Policy (Layer 5) ───────── + master_policy_config = dict(MasterPolicyMapper.DEFAULTS) + if not dry_run: + print_formatted_text(HTML("Fetching Master Policy...")) + policy_data = client.fetch_master_policy() + if policy_data: + master_policy_config, mp_unmapped = \ + MasterPolicyMapper.map_policy(policy_data) + unmapped_items.extend(mp_unmapped) + 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')}")) + else: + logging.warning(f'{bcolors.WARNING}Master Policy not accessible — ' + f'using defaults. Review PAM config settings manually.{bcolors.ENDC}') + + # ── Phase 0b: Fetch Safes ──────────────────────────── + all_safes = client.fetch_safes() + if not all_safes: + return + + # Exclude system safes (VaultInternal, PVWAConfig, etc.) + include_system = kwargs.get("include_system_safes", False) + safes = exclude_system_safes(all_safes, include_system=include_system) + system_excluded = len(all_safes) - len(safes) + + # Apply --safes / --exclude-safes filters + safes = apply_safe_filter(safes, safe_include or None, safe_exclude or None) + if not safes: + print_formatted_text(HTML("No safes match the filter criteria")) + return + + # ── Early exit: --list-safes ───────────────────────── + if list_safes: + self._list_safes_detailed(safes, system_excluded) + return + + # Interactive safe picker (when no --safes flag in interactive mode) + if (not safe_include and not safe_exclude + and not skip_confirm + and not dry_run and not output_file + and not getattr(params, 'batch_mode', False)): + selected = self._interactive_safe_picker(safes) + if selected is not None: + # Use exact matching (not glob) for interactive selection + # to handle safe names containing *, ?, [, ] characters + selected_set = {n.strip() for n in selected.split(',')} + safes = [s for s in safes if s.get("safeName", "") in selected_set] + if not safes: + print_formatted_text(HTML("No safes selected")) + return + + safe_names = [s.get("safeUrlId", s["safeName"]) for s in safes] + + # ── Phase 1b: Fetch safe members (Layer 2) ────────── + skip_members = kwargs.get("skip_members", False) + safe_member_map = {} # safeUrlId → [mapped_member_dicts] + member_unmapped = [] # members not matched to Keeper users/teams + if not skip_members and not dry_run: + print_formatted_text(HTML( + f"\nFetching safe members from {len(safe_names)} safes...")) + for safe_url_id in safe_names: + raw_members = client.fetch_safe_members(safe_url_id) + mapped = [] + for m in raw_members: + mapped_member = PermissionMapper.map_member(m) + mapped.append(mapped_member) + # Track unmapped permissions for report + if mapped_member["unmapped_permissions"]: + for up in mapped_member["unmapped_permissions"]: + unmapped_items.append({ + "category": "Safe member permission", + "item": f"{mapped_member['name']} in {safe_url_id}", + "action": f"CyberArk permission '{up}' has no " + f"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")) + + # ── Phase 0b2: Fetch vault users/groups (Layer 6) ──── + user_team_matcher = None + if not skip_users and not skip_members and not dry_run: + print_formatted_text(HTML("\nFetching CyberArk vault users and groups...")) + ca_users = client.fetch_users() + ca_groups = client.fetch_user_groups() + + # Load --user-map override if provided + user_map_file = kwargs.get("user_map", "") + user_map_override = None + if user_map_file: + try: + with open(user_map_file, encoding="utf-8") as f: + user_map_override = json.load(f) + if not isinstance(user_map_override, dict): + raise CommandError("pam project cyberark-import", + f"--user-map file must contain a JSON object, " + f"got {type(user_map_override).__name__}") + except (FileNotFoundError, ValueError) as e: + raise CommandError("pam project cyberark-import", + f"Failed to load --user-map file: {e}") + + # Build matcher (Keeper users/teams loaded from params if available) + keeper_users = [] + keeper_teams = [] + if hasattr(params, 'enterprise') and params.enterprise: + for u in params.enterprise.get('users', []): + keeper_users.append({ + 'email': u.get('username', ''), + 'username': u.get('username', ''), + }) + else: + logging.warning(f'{bcolors.WARNING}Enterprise data not available — ' + f'all safe members will appear as unmatched. ' + f'Ensure you are logged in as an enterprise admin.{bcolors.ENDC}') + if hasattr(params, 'available_team_cache') and params.available_team_cache: + for t in params.available_team_cache: + keeper_teams.append({ + 'name': t.get('team_name', ''), + }) + + user_team_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, " + f"{len(ca_groups)} groups")) + + # ── Phase 0c: Fetch accounts ───────────────────────── + print_formatted_text(HTML(f"\nFetching accounts from {len(safe_names)} safes...")) + accounts_by_safe = client.fetch_accounts(safe_names, state_filter=state_filter) + 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 + + # ── Early exit: --estimate ─────────────────────────── + if estimate_only: + est_seconds = total_accounts * 3 # ~2s password retrieval + ~1s vault write + print(f"\nSafes: {len(accounts_by_safe)}") + print(f"Accounts: {total_accounts}") + print(f"Estimated import time: ~{format_duration(est_seconds)}") + return + + # ── Phase 2: Map accounts → PAM records ───────────── + mapper = AccountMapper(platform_map_override) + folder_mapper = SafeFolderMapper(mode=folder_mode) + + pam_resources = [] + pam_users = [] # standalone login records + incomplete = [] + skipped = [] + platform_counts = {} # platformId → {rotation, count} + + skip_all_passwords = {} + + for safe_name, accounts in accounts_by_safe.items(): + for account in accounts: + platform_id = account.get("platformId", "Unknown") + + # Track platform counts for report + if platform_id not in platform_counts: + mapping = mapper.platform_map.get(platform_id, {}) + platform_counts[platform_id] = { + "rotation": mapping.get("rotation", "UNMAPPED"), + "count": 0, + } + platform_counts[platform_id]["count"] += 1 + + # Check completeness + is_incomplete, reason = mapper.is_incomplete(account) + + # Retrieve password + password = None + if not dry_run or include_creds: + password = client.retrieve_password( + account.get("id", ""), + account_name=account.get("name", ""), + safe_name=safe_name, + skip_all=skip_all_passwords, + ) + if password is None: + skipped.append({ + "account": account.get("name", ""), + "safe": safe_name, + "reason": "password retrieval failed", + }) + continue + + # Map to PAM record + record = mapper.map_account(account, password, safe_name) + if record is None: + skipped.append({ + "account": account.get("name", ""), + "safe": safe_name, + "reason": f"unmappable platformId: {platform_id}", + }) + continue + + # Set folder paths — resources and users go under separate + # shared folder roots (matching KCM import pattern that + # edit.py/extend.py expects for folder resolution) + if folder_mode != "flat": + safe_folder = folder_mapper.map_safe(safe_name, project_name) + if safe_folder: + res_root = f"{project_name} - Resources" + usr_root = f"{project_name} - Users" + if record.get("type") == "login": + record["folder_path"] = f"{usr_root}/{safe_folder}" + else: + record["folder_path"] = f"{res_root}/{safe_folder}" + if record.get("users"): + for u in record["users"]: + u["folder_path"] = f"{usr_root}/{safe_folder}" + + if is_incomplete: + record["notes"] = f"INCOMPLETE: {reason}" + incomplete.append(record) + + # Detect dual accounts + dual_fields = detect_dual_account(account) + if dual_fields: + # Add custom fields to the record + if "custom" not in record: + record["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", + }) + + # Resolve linked accounts (logon/reconcile/enable) + if (not skip_linked and not dry_run and not skip_users + and record.get("type") != "login"): + linked_users = resolve_linked_accounts(client, account) + if linked_users: + if "users" not in record: + record["users"] = [] + record["users"].extend(linked_users) + admin_title, admin_role = pick_admin_credentials(linked_users) + if admin_title: + ps = record.setdefault("pam_settings", {}) + conn = ps.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: + ps = record.setdefault("pam_settings", {}) + conn = ps.setdefault("connection", {}) + conn["launch_credentials"] = logon_title + logging.info("Overrode launch_credentials with logon account '%s' on '%s'", + logon_title, record.get("title", "")) + # Apply folder_path to linked users (they were added + # after the initial folder_path assignment) + if folder_mode != "flat": + safe_folder = folder_mapper.map_safe(safe_name, project_name) + if safe_folder: + usr_root = f"{project_name} - Users" + for lu in linked_users: + lu["folder_path"] = f"{usr_root}/{safe_folder}" + + # Sort by record type + if record.get("type") == "login": + pam_users.append(record) + else: + if skip_users: + record.pop("users", None) + pam_resources.append(record) + + # ── Summary ────────────────────────────────────────── + resource_count = len(pam_resources) + user_count = sum(len(r.get("users", [])) for r in pam_resources) + login_count = len(pam_users) + print() + print(f"{bcolors.OKBLUE}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 incomplete: + print(f" {bcolors.WARNING}Incomplete: {len(incomplete)}{bcolors.ENDC}") + if skipped: + print(f" {bcolors.WARNING}Skipped: {len(skipped)}{bcolors.ENDC}") + 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") + print() + + # ── Pre-import validation ──────────────────────────── + validation_warnings = validate_import_data(pam_resources, pam_users) + if validation_warnings: + 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() + + # ── Build import JSON ──────────────────────────────── + if config_uid: + import_data = build_extend_json(pam_resources, pam_users) + else: + import_data = build_import_json( + project_name=project_name, + gateway_name=gateway_name or None, + resources=pam_resources, + users=pam_users, + safe_member_map=safe_member_map if safe_member_map else None, + user_team_matcher=user_team_matcher, + master_policy_config=master_policy_config, + ) + + # ── Early exit: --output ───────────────────────────── + if output_file: + output_data = copy.deepcopy(import_data) # deep copy + if not include_creds: + strip_credentials(output_data) + fd = os.open(output_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(output_data, f, indent=2) + print(f"Import JSON saved to: {output_file}") + return + + # ── Early exit: --dry-run ──────────────────────────── + if dry_run: + print("[DRY RUN] No changes will be made to the vault.") + print() + output_data = copy.deepcopy(import_data) + if not 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.") + return + + # ── Confirmation ───────────────────────────────────── + if not skip_confirm: + print(f"Ready to import {resource_count} resources, {user_count} users, " + f"{login_count} logins into project '{project_name}'.") + if config_uid: + print(f"Extending existing PAM configuration: {config_uid}") + else: + print("A new PAM project will be created.") + confirm = input("Proceed? [y/N] ").strip().lower() + if confirm not in ("y", "yes"): + print("Import cancelled.") + return + + # ── Phase 6-8: Import to vault ─────────────────────── + import_start = time.time() + project_result = None + + try: + project_result = self._execute_import( + params, import_data, project_name, config_uid, + batch_size, batch_delay, pam_resources, pam_users, + ) + except Exception as e: + # Log only the exception type and non-sensitive message to avoid credential leaks + logging.error(f"Import failed: {type(e).__name__}") + logging.debug(f"Import error details: {e}") + print_formatted_text(HTML(f"\nImport failed: {type(e).__name__}")) + return + + import_duration = time.time() - import_start + + # ── Phase 9: Report ────────────────────────────────── + resource_counts = { + "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 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 pam_users: + resource_counts["login"]["ok"] += 1 + + report_text = build_report( + project_name=project_name, + safes_processed=len(accounts_by_safe), + total_accounts=total_accounts, + resource_counts=resource_counts, + platform_counts=platform_counts, + skipped=skipped, + incomplete_count=len(incomplete), + duration=import_duration, + project_result=project_result, + unmapped_platforms=mapper.unmapped_platforms, + unmapped_items=unmapped_items, + server=kwargs.get("server", ""), + ) + + # Strip gateway token from notes (it goes in custom fields instead) + 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 + + # Save report + CSV as vault attachments + report_config_uid = (project_result or {}).get("config_uid", "") or config_uid + if report_config_uid: + tmp_files = [] + try: + from ..record_edit import RecordUploadAttachmentCommand + attachments = [] + + # Report .md file + 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) + + # CSV for unmatched users (if any) + 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( + 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(f"Failed to save report attachment: {type(e).__name__}") + finally: + for f in tmp_files: + try: + os.unlink(f) + except OSError: + pass + + 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 = _write_secure_temp_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: + _remove_secure_temp(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 = _write_secure_temp_json(first_batch_data) + try: + PAMProjectImportCommand().execute( + params, project_name=project_name, file_name=tmp_path, dry_run=False + ) + finally: + _remove_secure_temp(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 = _write_secure_temp_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: + _remove_secure_temp(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): + """Delete all records and folders created by a CyberArk PAM import.""" + + 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 VxANFEPLi8E9gdtlDmfBvw --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, vault, vault_extensions + + 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") + + # Find shared folders + res_name = f"{project_name} - Resources" + usr_name = f"{project_name} - Users" + sf_uids = [] + record_count = 0 + for sf_uid, sf in params.shared_folder_cache.items(): + name = sf.get("name_unencrypted", "") + if name in (res_name, usr_name): + sf_uids.append(sf_uid) + records_in_sf = params.subfolder_record_cache.get(sf_uid, set()) + record_count += len(records_in_sf) + + print(f"\nCyberArk PAM Project Cleanup") + print("=" * 50) + print(f" Project: {project_name}") + print(f" Config: {config_uid}") + print(f" Folders: {len(sf_uids)}") + print(f" Records: ~{record_count}") + + if dry_run: + print(" (dry run — no changes made)") + print("=" * 50) + return + + if not auto_confirm: + answer = input("\n Delete all records and folders? [y/N]: ").strip().lower() + if answer not in ("y", "yes"): + print(" Cancelled.") + return + + # Delete records in shared folders + from ..record_edit import RecordDeleteCommand + deleted = 0 + failed = 0 + for sf_uid in sf_uids: + records = params.subfolder_record_cache.get(sf_uid, set()) + for rec_uid in list(records): + try: + RecordDeleteCommand().execute(params, force=True, record=rec_uid) + deleted += 1 + except Exception as e: + failed += 1 + logging.warning("Failed to delete record %s: %s", + rec_uid, type(e).__name__) + + # Delete PAM config record + try: + RecordDeleteCommand().execute(params, force=True, record=config_uid) + deleted += 1 + except Exception as e: + failed += 1 + logging.warning("Failed to delete PAM config record %s: %s", + config_uid, type(e).__name__) + + api.sync_down(params) + msg = f"\nCleanup complete: {deleted} records deleted" + if failed: + msg += f" ({failed} failed — see warnings above)" + print(msg) + print("=" * 50) diff --git a/keepercommander/importer/cyberark/cyberark_pam.py b/keepercommander/importer/cyberark/cyberark_pam.py new file mode 100644 index 000000000..e4b868dc5 --- /dev/null +++ b/keepercommander/importer/cyberark/cyberark_pam.py @@ -0,0 +1,1833 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 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) + +# Valid CyberArk logon types for self-hosted PVWA (case-insensitive check) +VALID_LOGON_TYPES = {"cyberark", "ldap", "radius", "windows"} + +# System safes excluded from migration by default. +# These are internal CyberArk safes that do not contain user-managed accounts. +# Override with --include-system-safes flag. +SYSTEM_SAFES = { + "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", + # Additional system safes found in real environments + "PasswordManager", "SCIM Config", "PSMSessions", "PSMUnmanagedSessionAccounts", + "PSMLiveSessions", "PSMNotifications", "PSMRecordings", +} + +# 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 + + +# Default CyberArk platformId → KeeperPAM record mapping +DEFAULT_PLATFORM_MAP = { + # NIX + "UnixSSH": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "UnixSSHKey": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "UnixSSHKeys": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + # Windows + "WinDomain": {"record_type": "pamMachine", "rotation": "general", "protocol": "rdp", "port": "3389"}, + "WinLocalAccount": {"record_type": "pamMachine", "rotation": "general", "protocol": "rdp", "port": "3389"}, + "WinServerLocal": {"record_type": "pamMachine", "rotation": "general", "protocol": "rdp", "port": "3389"}, + "WinDesktopLocal": {"record_type": "pamMachine", "rotation": "general", "protocol": "rdp", "port": "3389"}, + # Database + "Oracle": {"record_type": "pamDatabase", "rotation": "general", "protocol": "sql-server", "port": "1521"}, + "MySQL": {"record_type": "pamDatabase", "rotation": "general", "protocol": "mysql", "port": "3306"}, + "MSSql": {"record_type": "pamDatabase", "rotation": "general", "protocol": "mssql", "port": "1433"}, + "PostgreSQL": {"record_type": "pamDatabase", "rotation": "general", "protocol": "postgresql", "port": "5432"}, + # Network devices — SSH-managed + "PaloAltoNetworks": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "CiscoIOS": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "CiscoIOSEnable": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "CiscoASA": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "JuniperJunos": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "F5BigIP": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + "CheckPointGAIA": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + # CyberArk internal — service accounts, import as pamMachine/SSH + "CyberArk": {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"}, + # Web — login record, NOT pamMachine + "BusinessWebsite": {"record_type": "login", "rotation": None, "protocol": None, "port": None}, +} + +# Fallback mapping for accounts with empty or unknown platformId +FALLBACK_PLATFORM_MAP = {"record_type": "pamMachine", "rotation": "general", "protocol": "ssh", "port": "22"} + + +class RecordKind: + """PVWA returns multiple record-bearing entities under different + endpoints. Each maps to a different Keeper record-type and goes + through a different mapper. Discriminated at import time off the + PVWA payload shape rather than the endpoint, so a single + exported-JSON file can carry mixed kinds. + + STATE: only ACCOUNT is implemented. APPLICATION and API_TOKEN are + stubs awaiting real PVWA samples (Prathamesh deliverable #2). + """ + ACCOUNT = "account" # /Accounts (current — pamMachine/pamDatabase/login) + APPLICATION = "application" # /Applications (Application Identity Manager) + API_TOKEN = "api_token" # custom platform / non-account credential + + +def discriminate_record_kind(payload: dict) -> str: + """Return the RecordKind for a PVWA payload. + + Discriminators below are best-guess from spec; will be refined + when real samples land. Order matters: APPLICATION and API_TOKEN + are checked before ACCOUNT so they win when their distinguishing + field is present. + """ + 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 + + +# 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, +) + + +class CyberArkPVWAClient: + """Handles authentication and API calls to CyberArk PVWA.""" + + DELAY = 0.025 + TIMEOUT = 10 + ENDPOINTS = { + "accounts": "Accounts", + "account_password": "Accounts/{account_id}/Password/Retrieve", + "logon": "Auth/{type}/Logon", + "safes": "Safes", + } + + def __init__(self, pvwa_host, verify_ssl=True): + host, query_params = self._normalize_host(pvwa_host) + self.pvwa_host = host + self.query_params = query_params + # SSL verification: always True for Privilege Cloud. + # For self-hosted: default True, caller can disable with verify_ssl=False. + if self.pvwa_host.endswith(".cyberark.cloud"): + self.verify_ssl = True + else: + self.verify_ssl = verify_ssl + if not verify_ssl: + logging.warning("SSL certificate verification is disabled for self-hosted PVWA. " + "This is insecure and vulnerable to man-in-the-middle attacks.") + self.auth_token = None + self._validate_host(self.pvwa_host) + + @staticmethod + def _normalize_host(filename): + """Normalize PVWA host — same logic as existing CyberArkImporter (PR #1423). + Returns (host, query_params) tuple. + + Accepted formats: + - 'pvwa.company.com' → self-hosted, used as-is + - 'tenant.cyberark.cloud' → tenant.privilegecloud.cyberark.cloud + - 'tenant.privilegecloud.cyberark.cloud' → already correct, no rewrite + - 'https://tenant.cyberark.cloud' → strips https, rewrites + - 'https://pvwa.company.com?safe=MySafe' → query param extracted + """ + pvwa_host = filename.removeprefix("https://").removeprefix("http://").rstrip("/") + query_params = {} + if "?" in pvwa_host: + pvwa_host, query_string = pvwa_host.split("?", 1) + if "=" in query_string: + query_params = dict(parse_qsl(query_string)) + else: + query_params["search"] = query_string + # Rewrite *.cyberark.cloud to *.privilegecloud.cyberark.cloud + # but skip if already has .privilegecloud. subdomain + if (pvwa_host.endswith(".cyberark.cloud") + and ".privilegecloud." not in pvwa_host): + pvwa_host = f"{pvwa_host.split('.')[0]}.privilegecloud.cyberark.cloud" + return pvwa_host, query_params + + @staticmethod + def _validate_host(host): + """Validate that the PVWA host is not targeting internal/private addresses (SSRF protection).""" + # Strip port if present + hostname = host.split(":")[0] if ":" in host else host + # Reject obviously dangerous hostnames + if hostname in ("localhost", "127.0.0.1", "0.0.0.0", "::1", ""): + raise ValueError(f"PVWA host '{host}' targets a local address and is not allowed") + # Validate hostname format (alphanumeric, dots, hyphens only) + if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$', hostname): + raise ValueError(f"PVWA host '{hostname}' contains invalid characters") + # Check IP literals against private ranges + try: + addr = ipaddress.ip_address(hostname) + if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved: + raise ValueError(f"PVWA host '{host}' is a private/reserved IP and is not allowed") + return + except ValueError as e: + if "is not allowed" in str(e): + raise + # Not an IP literal — resolve hostname and check all IPs + pass + # DNS resolution check — reject if hostname resolves to private IP + try: + resolved_ips = socket.getaddrinfo(hostname, None) + for family, _, _, _, sockaddr in resolved_ips: + ip_str = sockaddr[0] + addr = ipaddress.ip_address(ip_str) + if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved: + raise ValueError( + f"PVWA host '{hostname}' resolves to private/reserved IP " + f"{ip_str} and is not allowed (SSRF protection)") + except socket.gaierror: + # Cannot resolve — will fail at connection time, not a security issue + pass + + def _get_url(self, endpoint): + return f"https://{self.pvwa_host}/PasswordVault/API/{self.ENDPOINTS[endpoint]}" + + MAX_RETRIES = 3 + + def logoff(self): + """Log off from CyberArk PVWA. Best-effort — failures are silently ignored.""" + if not self.auth_token: + return + try: + base = self._get_url("safes").rsplit("/Safes", 1)[0] + requests.post( + f"{base}/Auth/Logoff", + headers={"Authorization": self.auth_token, "Content-Type": "application/json"}, + timeout=self.TIMEOUT, + verify=self.verify_ssl, + allow_redirects=False, + ) + except Exception: + pass # Best-effort logoff + self.auth_token = None + + def _get(self, url, params=None): + """GET with automatic retry on HTTP 429 (rate limit). Redirects disabled.""" + response = None + for attempt in range(self.MAX_RETRIES): + response = requests.get( + url, + headers={"Authorization": self.auth_token, "Content-Type": "application/json"}, + params=params, + timeout=self.TIMEOUT, + verify=self.verify_ssl, + allow_redirects=False, + ) + if response.status_code != 429: + return response + try: + retry_after = int(response.headers.get("Retry-After", 2 ** (attempt + 1))) + except (ValueError, TypeError): + retry_after = 2 ** (attempt + 1) # fallback on non-numeric header + retry_after = min(retry_after, 60) + logging.warning('Rate limited (429) — retrying in %ds (attempt %d/%d)', + retry_after, attempt + 1, self.MAX_RETRIES) + time.sleep(retry_after) + return response + + @staticmethod + def _discover_identity_endpoint(tenant_subdomain: str) -> 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.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 via OAuth2 service account. + + 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() + # Strip https:// prefix if present + 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("/") + + # Determine the identity host for OAuth2 + if "." in id_tenant_raw: + # Already has dots — could be 'abc1234.id', 'tenant.my.idaptive.app', + # or 'tenant.cyberark.cloud'. Use as-is for the identity host. + 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"): + # Has dots but not a known domain — treat first part as tenant + id_host = id_tenant_raw.split(".")[0] + ".cyberark.cloud" + else: + # Simple name — apply subdomain ID detection + 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 False + + # Platform discovery — resolve tenant to correct identity endpoint + # (ark-sdk-python pattern: platform-discovery.cyberark.cloud) + 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) + + 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.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}" + )) + return False + try: + self.auth_token = f"Bearer {response.json()['access_token']}" + except (KeyError, ValueError) as e: + print_formatted_text(HTML("Failed to parse OAuth2 response")) + return False + print_formatted_text(HTML("Log on successful")) + return True + + 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.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: + # Validate nextLink origin before following. A compromised or + # malicious PVWA could point pagination at an attacker host + # to exfiltrate the auth_token via the Authorization header. + 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_safe_members(self, safe_url_id: str) -> List[dict]: + """Fetch members of a safe. Returns list of member dicts. + + Excludes predefined system members (Master, Batch, etc.) by default. + """ + # Validate safe_url_id to prevent URL path injection + if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_. -]*$', safe_url_id): + logging.warning('Invalid safe URL ID format: %s — skipping member fetch', + re.sub(r'[^a-zA-Z0-9_. -]', '?', safe_url_id)) + return [] + url = f"{self._get_url('safes')}/{safe_url_id}/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) + + def fetch_master_policy(self) -> Optional[dict]: + """Fetch the CyberArk Master Policy settings. + + Returns the policy dict or None if the API is not accessible (403/404). + Graceful failure — Master Policy is optional for migration. + """ + try: + base = self._get_url("safes").rsplit("/Safes", 1)[0] + response = self._get(f"{base}/Policy/MasterPolicy") + if response.status_code == 200: + try: + return response.json() + except (ValueError, KeyError): + logging.debug('Master Policy response not valid JSON') + return None + logging.debug('Master Policy not accessible: status %d', + response.status_code) + except requests.RequestException as e: + logging.debug('Master Policy fetch error: %s', type(e).__name__) + 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_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.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", + # Include ticketing params if configured — some CyberArk policies + # require a ticket ID for credential retrieval (ark-sdk-python pattern) + **({"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.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 + + +class ApplicationMapper: + """Maps PVWA Applications (Application Identity Manager) to Keeper records. + + STUB. The mapping target and field translation are placeholders + pending Prathamesh's /Applications sample. When the sample lands: + 1. Confirm Keeper target record_type (likely 'login' or new + 'pamApplication' — check with platform team). + 2. Fill `_field_map` with PVWA → Keeper field translations + (AppID, AccessPermittedFrom, AccessPermittedTo, ExpirationDate, + per-app authentication-method serialization). + 3. Replace the NotImplementedError in `map_application` with + the real mapping logic. + 4. Drop a real `tests/fixtures/cyberark_application_sample.json` + in place of the placeholder fixture. + + Until then the import driver skips applications with a warning + rather than raising, so existing /Accounts imports stay + unaffected. + """ + + TARGET_RECORD_TYPE = "login" # PLACEHOLDER — confirm with platform team + + _field_map = { + # "PVWA_field": "keeper_field", # filled when sample arrives + } + + def __init__(self, client: 'CyberArkPVWAClient'): + self._client = client + + def map_application(self, payload: dict) -> dict: + raise NotImplementedError( + "ApplicationMapper.map_application awaiting PVWA " + "/Applications sample (Prathamesh blocker #2). Until " + "then, applications are skipped with a warning." + ) + + +class AccountMapper: + """Maps CyberArk accounts to KeeperPAM record dicts matching pam project import JSON schema.""" + + def __init__(self, platform_map_override: Optional[dict] = None): + self.platform_map = copy.deepcopy(DEFAULT_PLATFORM_MAP) + if platform_map_override: + self.platform_map.update(platform_map_override) + self.unmapped_platforms = {} # platformId → count + + 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 + + if mapping is None: + # Empty or unknown platform — use fallback, track for report + label = platform_id if platform_id else "(empty)" + self.unmapped_platforms[label] = self.unmapped_platforms.get(label, 0) + 1 + mapping = dict(FALLBACK_PLATFORM_MAP) + 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.info("Empty platformId for account '%s' — defaulting to pamMachine/SSH.", + account.get("name", "")) + + record_type = mapping.get("record_type", "pamMachine") + props = account.get("platformAccountProperties", {}) or {} + + # Extract fields + address = account.get("address", "") + user_name = account.get("userName", "") + + # 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 = props.get("Port", mapping.get("port", "")) + + if record_type == "login": + # BusinessWebsite → login record (not pamMachine) + record = { + "type": "login", + "title": item_name or title, + "login": login, + "password": password or "", + } + if url: + record["url"] = url + return record + + if record_type in ("pamMachine", "pamDatabase"): + 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": "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 == "pamDatabase": + 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"): + user_record["rotation_settings"] = { + "rotation": mapping["rotation"], + "enabled": "on" if cpm_enabled else "off", + "schedule": {"type": "on-demand"}, + } + 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 == "pamMachine": + resource["domain_name"] = logon_domain + if mapping.get("protocol"): + resource["pam_settings"] = { + "options": { + "rotation": "on" if cpm_enabled else "off", + "connections": "on", + "tunneling": "off", + "graphical_session_recording": "off", + }, + "connection": { + "protocol": mapping["protocol"], + "port": str(port) if port else "", + "launch_credentials": user_record["title"], + } + } + 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, "" + + +class MasterPolicyMapper: + """Maps CyberArk Master Policy rules to Keeper PAM Configuration settings.""" + + DEFAULTS = { + "connections": "on", + "rotation": "on", + "tunneling": "on", + "graphical_session_recording": "off", + "text_session_recording": "off", + # Keeper-specific features that have no CyberArk Master Policy + # equivalent — default off so a CyberArk migration doesn't silently + # enable RBI or AI threat detection on customers who didn't opt in. + # PamConfigEnvironment defaults RBI to "on", so we set explicitly. + "remote_browser_isolation": "off", + "ai_threat_detection": "off", + "ai_terminate_session_on_detection": "off", + } + + @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), [] + + config = dict(MasterPolicyMapper.DEFAULTS) + unmapped = [] + + # Extract rules + policy_obj = policy_data.get("Policy", policy_data) + if not isinstance(policy_obj, dict): + return dict(MasterPolicyMapper.DEFAULTS), [] + rules = {} + for rule in (policy_obj.get("Rules") or []): + if isinstance(rule, dict): + rules[rule.get("RuleName", "")] = rule.get("Active", False) + + # RecordAndSaveSessionActivity → session recording + if rules.get("RecordAndSaveSessionActivity"): + config["graphical_session_recording"] = "on" + config["text_session_recording"] = "on" + + # AllowEPVTransparentConnections → connections + if rules.get("AllowEPVTransparentConnections"): + config["connections"] = "on" + elif "AllowEPVTransparentConnections" in rules: + config["connections"] = "off" + + # SafeAuditRetention → UNMAPPED + for rule in (policy_obj.get("Rules") or []): + if isinstance(rule, dict) and rule.get("RuleName") == "SafeAuditRetention": + val = rule.get("Value") + if val is not None: + unmapped.append({ + "category": "Master Policy", + "item": f"SafeAuditRetention = {val} days", + "action": "Configure audit retention at vault level in Admin Console", + }) + + # UNMAPPED policy features + unmapped_rules = { + "RequireDualControlPasswordAccessApproval": ( + "Dual control approval", + "Use ticketing integration (ServiceNow/Jira) for approval workflows" + ), + "EnforceCheckinCheckoutExclusiveAccess": ( + "Exclusive checkout", + "Use time-limited record sharing in KeeperPAM" + ), + "EnforceOnetimePasswordAccess": ( + "One-time password access", + "Enable post-use rotation in KeeperPAM rotation settings" + ), + } + for rule_name, (label, action) in unmapped_rules.items(): + if rules.get(rule_name): + unmapped.append({ + "category": "Master Policy", + "item": f"{label} = Active", + "action": action, + }) + + return config, unmapped + + +class UserTeamMatcher: + """Matches CyberArk vault users and groups to Keeper users and teams. + + CyberArk users are matched by email (personalDetails.email). + CyberArk groups are matched by name (groupName). + Manual overrides via --user-map JSON file. + Unmatched identities are collected for the ca_users_to_provision.csv report. + """ + + def __init__(self, keeper_users: List[dict] = None, + keeper_teams: List[dict] = None, + user_map_override: Optional[Dict[str, str]] = None): + """Initialize matcher. + + Args: + keeper_users: list of Keeper user dicts with 'username'/'email' keys + keeper_teams: list of Keeper team dicts with 'name' key + user_map_override: dict mapping CyberArk username → Keeper email + """ + # Build lookup tables + self._user_emails = set() # lowercase Keeper user emails + if keeper_users: + for u in keeper_users: + email = u.get('email', '') or u.get('username', '') + if email: + self._user_emails.add(email.lower()) + + self._team_names = set() # lowercase Keeper team names + if keeper_teams: + for t in keeper_teams: + name = t.get('name', '') or t.get('team_name', '') + if name: + self._team_names.add(name.lower()) + + self._overrides = {} + if user_map_override: + self._overrides = { + str(k).lower(): str(v).lower() + for k, v in user_map_override.items() + } + + self.unmatched = [] # list of unmatched member dicts for CSV + + def match_user(self, cyberark_username: str, + cyberark_email: str = '', + cyberark_groups: str = '') -> 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() + + +class PermissionMapper: + """Maps CyberArk safe member permissions to Keeper shared folder permission tiers. + + CyberArk has 24 granular boolean permissions per safe member (ark-sdk-python). + Keeper has 4 permission levels: manage_users, manage_records, can_edit, can_share. + + Mapping tiers (cumulative): + Tier 1 (view): useAccounts + retrieveAccounts + listAccounts + Tier 2 (edit): + addAccounts + updateAccountContent + updateAccountProperties + + renameAccounts + deleteAccounts + Tier 3 (manage): + manageSafe + manageSafeMembers + viewSafeMembers + + Mapped but not tier-affecting (absorbed into higher tiers): + viewAuditLog, backupSafe, unlockAccounts, + initiateCPMAccountManagementOperations, specifyNextAccountContent, + createFolders, deleteFolders, moveAccountsAndFolders + + UNMAPPED permissions (no Keeper equivalent — logged in report): + accessWithoutConfirmation, requestsAuthorizationLevel1/2 + """ + + # All 24 CyberArk permissions from ark-sdk-python ArkPCloudSafeMemberPermissions + ALL_PERMISSIONS = { + # Tier 1 — View + "useAccounts", "retrieveAccounts", "listAccounts", + # Tier 2 — Edit + "addAccounts", "updateAccountContent", "updateAccountProperties", + "renameAccounts", "deleteAccounts", + # Tier 3 — Manage + "manageSafe", "manageSafeMembers", "viewSafeMembers", + # Absorbed into tiers (not tier-determining but tracked) + "viewAuditLog", "backupSafe", "unlockAccounts", + "initiateCPMAccountManagementOperations", "specifyNextAccountContent", + "createFolders", "deleteFolders", "moveAccountsAndFolders", + # Unmapped — no Keeper equivalent + "accessWithoutConfirmation", + "requestsAuthorizationLevel1", "requestsAuthorizationLevel2", + # CyberArk 13+ additions + "isExpiredMembershipEnable", "isReadOnly", + } + + # Permissions that have no Keeper equivalent + UNMAPPED_PERMISSIONS = { + "accessWithoutConfirmation", + "requestsAuthorizationLevel1", + "requestsAuthorizationLevel2", + } + + @staticmethod + def map_permissions(perms: dict) -> 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, + } + + +class SafeFolderMapper: + """Maps CyberArk Safe names to Keeper folder paths with deduplication.""" + + def __init__(self, mode: str = "flat"): + self.mode = mode + self._seen = {} # sanitized name → count (for dedup) + self._cache = {} # raw safe name → resolved folder path + + def map_safe(self, safe_name: str, project_name: str) -> 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 == "ksm": + 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 == "ksm": + max_base = MAX_SAFE_NAME_LENGTH - len(suffix) + result = f"{sanitized[:max_base]}{suffix}" + else: + result = f"{sanitized}{suffix}" + else: + self._seen[sanitized] = 1 + result = sanitized[:MAX_SAFE_NAME_LENGTH] if self.mode == "ksm" else sanitized + + self._cache[safe_name] = result + return result + + +class AdaptiveThrottler: + """Adaptive rate limiter for batched vault writes.""" + + def __init__(self, base_delay: float = 0.5, max_delay: float = 5.0, batch_size: int = 100): + self.base_delay = base_delay + self.max_delay = max_delay + self.batch_size = batch_size + self.current_delay = base_delay + self._recent_errors = 0 + self._recent_successes = 0 + + def record_response(self, duration_ms: float, success: bool): + if success: + self._recent_successes += 1 + self._recent_errors = max(0, self._recent_errors - 1) + if duration_ms < 1000 and self.current_delay > 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) + +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). + """ + 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 + + +def resolve_linked_accounts(client: 'CyberArkPVWAClient', + account: dict) -> 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 + } + 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 + + +def build_shared_folder_permissions(safe_member_map: Dict[str, List[dict]], + user_team_matcher: Optional['UserTeamMatcher'] = None, + ) -> 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 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, + ) -> dict: + """Build the pam project import JSON from mapped records.""" + mp = master_policy_config or {} + 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": {"type": "on-demand"}, + } + if gateway_name: + pam_config["gateway_name"] = gateway_name + + result = { + "project": project_name, + "pam_configuration": pam_config, + "pam_data": { + "resources": resources, + "users": users, + }, + } + + # Wire safe member permissions into shared folder structure + if 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 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: + 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 section + if unmapped_items: + lines.append(' UNMAPPED — REQUIRES MANUAL ACTION') + lines.append(' ' + '-' * 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('') + + # 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) + lines.append(f' 1. Review UNMAPPED section — action each item') + lines.append(f' 2. Verify: pam gateway list') + lines.append(f' 3. 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/tests/test_cyberark_pam_import.py b/tests/test_cyberark_pam_import.py new file mode 100644 index 000000000..8386a4359 --- /dev/null +++ b/tests/test_cyberark_pam_import.py @@ -0,0 +1,3533 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' --'.""" + 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 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('