diff --git a/keepercommander/importer/commands.py b/keepercommander/importer/commands.py index dfc6ce790..e3ef67884 100644 --- a/keepercommander/importer/commands.py +++ b/keepercommander/importer/commands.py @@ -50,8 +50,8 @@ def register_command_info(aliases, command_info): import_parser.add_argument('--display-json', '-dj', dest='display_json', action='store_true', help='display Keeper JSON import instructions') import_parser.add_argument( - '--format', choices=['json', 'csv', 'cyberark', 'keepass', 'lastpass', 'myki', 'nordpass', 'manageengine', - '1password', 'bitwarden', 'thycotic', 'proton', 'dashlane'], + '--format', choices=['json', 'csv', 'cyberark', 'cyberark_portal', 'keepass', 'lastpass', 'myki', 'nordpass', + 'manageengine', '1password', 'bitwarden', 'thycotic', 'proton', 'dashlane'], required=True, help='file format') import_parser.add_argument('--folder', dest='import_into', action='store', help='import into a separate folder.') diff --git a/keepercommander/importer/cyberark_portal/__init__.py b/keepercommander/importer/cyberark_portal/__init__.py new file mode 100644 index 000000000..d6deb2cc4 --- /dev/null +++ b/keepercommander/importer/cyberark_portal/__init__.py @@ -0,0 +1,3 @@ +from .cyberark_portal import CyberArkPortalImporter as Importer + +__all__ = ["Importer"] diff --git a/keepercommander/importer/cyberark_portal/cyberark_portal.py b/keepercommander/importer/cyberark_portal/cyberark_portal.py new file mode 100644 index 000000000..09c4f0539 --- /dev/null +++ b/keepercommander/importer/cyberark_portal/cyberark_portal.py @@ -0,0 +1,213 @@ +import re +from http import HTTPStatus +from os import environ +from time import sleep +from urllib.parse import urlencode +import logging +import requests +from prompt_toolkit import HTML, print_formatted_text, prompt + + +from ..importer import BaseImporter, Record, RecordField + + +class CyberArkPortalImporter(BaseImporter): + """CyberArk Portal Importer + This importer allows users to import records from the CyberArk User Portal. + Users authenticate using their (personal) CyberArk credentials then the importer + retrieves applications and secured items data and yields them as Keeper records. + Authentication uses the CyberArk Identity Portal API but may be redirected to an SSO provider. + CyberArk Identity Portal API authentication is a multi-step process that may require + entering a one-time password (OTP) or other out-of-band (OOB) authentication + mechanisms depending on the user's security settings. + + The API documentation can be found at + https://api-docs.cyberark.com/docs/identity-api-reference/ + + Yields: + Record: Login type Keeper Records for applications and Secure Notes for secured items data. + """ + + # Delay between requests to avoid hitting the API rate limits + LOOP_DELAY = 0.025 + # Request timeout in seconds + TIMEOUT = 10 + + @staticmethod + def get_url(id_tenant, endpoint): + return f'https://{id_tenant}.id.cyberark.cloud/{endpoint.rstrip("/")}' + + def do_import(self, filename, **kwargs): + id_tenant = re.search(r"^([A-Za-z0-9-]+)(\.id\.cyberark\.cloud)?$", filename.removeprefix("https://"))[0] + username = environ.get("KEEPER_CYBERARK_USERNAME") or prompt("CyberArk User Portal username: ") + response = requests.post( + self.get_url(id_tenant, "/Security/StartAuthentication"), + json={ + "TenantId": id_tenant, + "Version": "1.0", + "User": username, + }, + timeout=self.TIMEOUT, + ) + if response.status_code != HTTPStatus.OK: + logging.error(f"Error starting authentication: {response.text}") + return + + start_auth_result = response.json().get("Result") + logging.debug(f"Authentication Result: {start_auth_result}") + redirect = start_auth_result.get("PodFqdn") + if redirect: + print_formatted_text( + HTML( + f"Use {redirect.removesuffix('.id.cyberark.cloud')} instead of {id_tenant} for user {username}." + ) + ) + return + idp_redirect_url = start_auth_result.get("IdpRedirectUrl") + if idp_redirect_url: + print_formatted_text(HTML("Federated login is not supported.")) + return + + password_operation = { + "MechanismId": start_auth_result["Challenges"][0]["Mechanisms"][0]["MechanismId"], + "Action": "Answer", + "Answer": environ.get("KEEPER_CYBERARK_PASSWORD") + or prompt("CyberArk Identity Portal password: ", is_password=True), + } + session_id = start_auth_result.get("SessionId") + advance_auth_request = { + "TenantId": id_tenant, + "SessionId": session_id, + } + if len(start_auth_result["Challenges"]) > 1: + challenge_mechs = list(start_auth_result["Challenges"][1]["Mechanisms"]) + # Use a batch-operation to send the password and start OOB authentication in one request + advance_auth_request["MultipleOperations"] = [ + password_operation, + { + "MechanismId": challenge_mechs[0]["MechanismId"], + "Action": "StartOOB", + }, + ] + else: + challenge_mechs = [] + advance_auth_request |= password_operation + + logging.debug(f"Advance Authentication Request: {advance_auth_request}") + response = requests.post( + self.get_url(id_tenant, "/Security/AdvanceAuthentication"), + json=advance_auth_request, + timeout=self.TIMEOUT, + ) + if response.status_code != HTTPStatus.OK: + logging.error(f"Error advancing authentication: {response.text}") + return + advance_auth_result = response.json().get("Result") + if not advance_auth_result.get("Summary") in ["OobPending", "LoginSuccess"]: + logging.error( + f"Unexpected authentication summary: {advance_auth_result.get('Summary')}, response: {response.text}" + ) + return + logging.debug(f"Advance Authentication Result: {advance_auth_result}") + # Iterate through the challenge mechanisms until we get a successful login or run out of mechanisms + while challenge_mechs and advance_auth_result.get("Summary") == "OobPending": + response = requests.post( + self.get_url(id_tenant, "/Security/AdvanceAuthentication"), + json={ + "TenantId": id_tenant, + "SessionId": session_id, + "MechanismId": challenge_mechs[0]["MechanismId"], + "Action": "Answer", + "Answer": prompt(f"Authentication code from {challenge_mechs[0]['PromptSelectMech']}: "), + }, + timeout=self.TIMEOUT, + ) + if response.status_code != HTTPStatus.OK: + logging.error(f"Error advancing authentication: {response.text}") + return + advance_auth_result = response.json().get("Result") + if advance_auth_result.get("Summary") != "OobPending": + challenge_mechs = challenge_mechs[1:] + elif advance_auth_result.get("Summary") == "LoginSuccess": + break + + if advance_auth_result.get("Summary") != "LoginSuccess": + logging.error( + f"Unexpected authentication summary: {advance_auth_result.get('Summary')}, response: {response.text}" + ) + return + print_formatted_text(HTML("Authentication successful")) + authentication_token = advance_auth_result.get("Token") + # Get all the application data (except the password, of course) in one API call + response = requests.post( + self.get_url(id_tenant, "/UPRest/GetUPData"), + headers={"Authorization": f"Bearer {authentication_token}"}, + json={}, + timeout=self.TIMEOUT, + ) + if response.status_code != HTTPStatus.OK: + logging.error(f"Error getting UP data: {response.text}") + return + apps = response.json()["Result"]["Apps"] + + if len(apps) > 0: + print_formatted_text(HTML(f"Importing {len(apps)} Applications")) + for app in apps: + app_key = app["AppKey"] + response = requests.post( + self.get_url(id_tenant, f"/UPRest/GetMCFA?appkey={app_key}"), + headers={ + "Authorization": f"Bearer {authentication_token}", + }, + json={}, + timeout=self.TIMEOUT, + ) + if response.status_code != HTTPStatus.OK: + logging.error(f"Error getting password for app {app_key}: {response.text}") + continue + + record = Record() + record.type = "login" + record.title = app["Name"] + record.login_url = app["Url"] + record.username = app["Username"] + record.password = response.json()["Result"]["p"] + if app["IsTotpSet"]: + record.notes = "The original CyberArk Application included a TOTP that was not imported." + yield record + sleep(self.LOOP_DELAY) + + response = requests.post( + self.get_url(id_tenant, "/UPRest/GetSecuredItemsData"), + headers={"Authorization": f"Bearer {authentication_token}"}, + json={}, + timeout=self.TIMEOUT, + ) + if response.status_code != HTTPStatus.OK: + logging.error(f"Error getting secured items data: {response.text}") + return + secured_items = response.json()["Result"]["SecuredItems"] + + if len(secured_items) > 0: + print_formatted_text(HTML(f"Importing {len(secured_items)} Secured Items")) + for item in secured_items: + item_key = item["ItemKey"] + response = requests.post( + self.get_url(id_tenant, f"/UPRest/GetCredsForSecuredItem?sItemKey={item_key}"), + headers={"Authorization": f"Bearer {authentication_token}"}, + json={}, + timeout=self.TIMEOUT, + ) + if response.status_code != HTTPStatus.OK: + print_formatted_text( + HTML(f"Error getting notes for item {item_key}: {response.text}") + ) + continue + record = Record() + record.type = "encryptedNotes" + record.title = item["Name"] + record.fields.append(RecordField(type="note", value=response.json()["Result"]["n"])) + yield record + sleep(self.LOOP_DELAY) + + print_formatted_text(HTML("Import complete"))