diff --git a/Packs/KeeperSecurity/.pack-ignore b/Packs/KeeperSecurity/.pack-ignore new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/Packs/KeeperSecurity/.secrets-ignore b/Packs/KeeperSecurity/.secrets-ignore new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/Packs/KeeperSecurity/Author_image.png b/Packs/KeeperSecurity/Author_image.png new file mode 100644 index 000000000000..347214d59d91 Binary files /dev/null and b/Packs/KeeperSecurity/Author_image.png differ diff --git a/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity.py b/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity.py new file mode 100644 index 000000000000..caebae9e52f7 --- /dev/null +++ b/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity.py @@ -0,0 +1,526 @@ +import urllib3 +import demistomock as demisto # noqa: F401 +from CommonServerPython import * # noqa: F401 +from keepercommander.params import KeeperParams +from keepercommander.auth.login_steps import LoginStepDeviceApproval, DeviceApprovalChannel, LoginStepPassword +from keepercommander import utils, crypto, api +from keepercommander.loginv3 import LoginV3Flow, LoginV3API, InvalidDeviceToken +from keepercommander.proto import APIRequest_pb2 +from datetime import datetime + +""" CONSTANTS """ + +# We fetch from the Keeper Security Admin Console, so the Product is not "Security", but we assigned it as such +# so the dataset could have the name keeper_security_raw +VENDOR = "Keeper" +PRODUCT = "Security" +LOG_LINE = f"{VENDOR}_{PRODUCT}:" +DEFAULT_MAX_FETCH = 10000 +API_MAX_FETCH = 1000 +SESSION_TOKEN_TTL = 3600 # In seconds +REGISTRATION_FLOW_MESSAGE = ( + "In order to authorize the instance, first run the command `!keeper-security-register-start`." + " A code will be sent to your email, copy it and paste that value in the command" + " `!keeper-security-register-complete` as an argument to finish the process." +) +DEVICE_ALREADY_REGISTERED = ( + "Device is already registered, try running the 'keeper-security-register-complete'" + " command without supplying a code argument." +) +LAST_RUN = "Last Run" +DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" # ISO8601 format with UTC, default in XSOAR + +# Disable insecure warnings +urllib3.disable_warnings() + + +""" HELPER FUNCTIONS """ + + +def get_current_time_in_seconds() -> int: + """A function to return time as an int number of seconds since the epoch + + Returns: + int: Number of seconds since the epoch + """ + return int(datetime.now().timestamp()) + + +def load_integration_context_into_keeper_params( + username: str, + password: str, + server_url: str, +): + """Load data from the integration context into a KeeperParams instance, which is used + to communicate with the product. We call it inside the init method of the Client that + will be used to communicate with the product. + + Args: + username (str): The username of the account. + password (str): The password of the account. + server_url (str): The server URL. + + Returns: + KeeperParams: An instance that will be used to communicate with the product. + """ + integration_context = get_integration_context() + keeper_params = KeeperParams() + keeper_params.user = username + keeper_params.password = password + keeper_params.server = server_url + + # To allow requests to bypass proxy + # ref -> https://docs.keeper.io/en/v/secrets-manager/commander-cli/troubleshooting-commander-cli#ssl-certificate-errors + keeper_params.rest_context.certificate_check = False + + keeper_params.device_token = integration_context.get("device_token") + keeper_params.device_private_key = integration_context.get("device_private_key") + keeper_params.session_token = integration_context.get("session_token") + keeper_params.clone_code = integration_context.get("clone_code") + return keeper_params + + +def append_to_integration_context(context_to_append: dict[str, Any]): + """Appends data to the integration context DB. + + Args: + context_to_append (dict[str, Any]): Context data to append + """ + integration_context = get_integration_context() + integration_context |= context_to_append + set_integration_context(integration_context) + + +""" CLIENT CLASS """ + + +class Client: + class DeviceApproval(LoginStepDeviceApproval): + """ + In charge of sending and verifying the code sent to the user's email when registering the device for the first time. + LoginStepDeviceApproval is is an abstract class that must be implemented. Some of the abstract methods + are not needed, therefore, we implement them by including a pass segment. + """ + + @property + def username(self): + pass + + def cancel(self): + pass + + def send_push( + self, + params: KeeperParams, + channel: DeviceApprovalChannel, + encryptedDeviceToken: bytes, + encryptedLoginToken: bytes, + ): + LoginV3Flow.verifyDevice( + params, encryptedDeviceToken, encryptedLoginToken, approval_action="push", approval_channel=channel + ) + + def send_code( + self, + params: KeeperParams, + channel: DeviceApprovalChannel, + encryptedDeviceToken: bytes, + encryptedLoginToken: bytes, + code: str, + ): + LoginV3Flow.verifyDevice( + params, + encryptedDeviceToken, + encryptedLoginToken, + approval_action="code", + approval_channel=channel, + approval_code=code, + ) + + def resume(self): + pass + + class PasswordStep(LoginStepPassword): + """ + In charge of verifying the user's password after verifying the device registration. + LoginStepPassword is is an abstract class that must be implemented. Some of the abstract methods + are not needed, therefore, we implement them by including a pass segment. + """ + + def __init__(self, salt_bytes: bytes, salt_iterations: int): + self.salt_bytes = salt_bytes + self.salt_iterations = salt_iterations + + @property + def username(self): + pass + + def forgot_password(self): + pass + + def verify_password(self, params: KeeperParams, encryptedLoginToken: bytes) -> Any: + # This function returns the data type APIRequest_pb2.LoginResponse + params.auth_verifier = crypto.derive_keyhash_v1(params.password, self.salt_bytes, self.salt_iterations) + return LoginV3API.validateAuthHashMessage(params, encryptedLoginToken) + + def verify_biometric_key(self, biometric_key: bytes): + pass + + def cancel(self): + pass + + def __init__( + self, + server_url: str, + username: str, + password: str, + ) -> None: + self.keeper_params: KeeperParams = load_integration_context_into_keeper_params( + username=username, + password=password, + server_url=server_url, + ) + + def refresh_session_token_if_needed( + self, + ) -> None: + """Refresh the session token if needed. + + Raises: + DemistoException: If saved the TTL of the session token, but it is not found in the + integration's context. + """ + integration_context = get_integration_context() + valid_until = integration_context.get("valid_until", 0) + current_time = get_current_time_in_seconds() + if self.keeper_params.session_token and current_time >= valid_until - 10: + demisto.info("Refreshing session token") + encrypted_device_token = LoginV3API.get_device_id(self.keeper_params) + resp = self.save_device_tokens( + encrypted_device_token=encrypted_device_token, + ) + encrypted_login_token: bytes = resp.encryptedLoginToken + + self.validate_device_registration( + encrypted_device_token=encrypted_device_token, + encrypted_login_token=encrypted_login_token, + ) + self.save_session_token() + else: + demisto.info("No need to refresh session token") + + def save_device_tokens(self, encrypted_device_token: bytes) -> Any: + """Save the devices' tokens when starting to verify the device registration. + + Args: + encrypted_device_token (bytes): The encrypted device token. + + Returns: + APIRequest_pb2.LoginResponse: The response that holds data about the API call. + """ + # This function returns the data type APIRequest_pb2.LoginResponse + resp = LoginV3API.startLoginMessage(self.keeper_params, encrypted_device_token, cloneCode=None, loginType="NORMAL") + append_to_integration_context( + { + "device_private_key": self.keeper_params.device_private_key, + "device_token": self.keeper_params.device_token, + "login_token": utils.base64_url_encode(resp.encryptedLoginToken), + } + ) + return resp + + def start_registering_device( + self, + device_approval: DeviceApproval, + new_device: bool = False, + ): + """Start the registration process of a new or old device. + + Args: + device_approval (DeviceApproval): DeviceApproval instance that is in charge of sending the code to the + user's email. + new_device (bool, optional): If we should configure a new device. Defaults to False. + + Raises: + DemistoException: If we try registering an already registered and authenticated device. + DemistoException: If we get a response status that we don't know how to handle. + """ + encryptedDeviceToken = LoginV3API.get_device_id(self.keeper_params, new_device) + resp = self.save_device_tokens( + encrypted_device_token=encryptedDeviceToken, + ) + if resp.loginState == APIRequest_pb2.DEVICE_APPROVAL_REQUIRED: + # client goes to “standard device approval” + device_approval.send_push( + self.keeper_params, + DeviceApprovalChannel.Email, + encryptedDeviceToken, + resp.encryptedLoginToken, + ) + elif resp.loginState == APIRequest_pb2.REQUIRES_AUTH_HASH: + raise DemistoException(DEVICE_ALREADY_REGISTERED) + else: + raise DemistoException(f"Unknown login state {resp.loginState}") + + def validate_device_registration( + self, + encrypted_device_token: bytes, + encrypted_login_token: bytes, + ): + """Verify the registration process of a new or old device. This method is also used as part of the + mechanism to refresh the session token. + + Args: + encrypted_device_token (bytes): The encrypted device token. + encrypted_login_token (bytes): The encrypted login token. + + Raises: + DemistoException: When trying to verify the user's password, and an error occurs. + DemistoException: When trying to verify the device registration, and an error occurs. + """ + resp = LoginV3API.startLoginMessage(self.keeper_params, encrypted_device_token) + if resp.loginState == APIRequest_pb2.REQUIRES_AUTH_HASH: + salt = api.get_correct_salt(resp.salt) + password_step = self.PasswordStep(salt_bytes=salt.salt, salt_iterations=salt.iterations) + verify_password_response = password_step.verify_password(self.keeper_params, encrypted_login_token) + # Disabling pylint due to external class declaration + if verify_password_response.loginState == APIRequest_pb2.LOGGED_IN: # pylint: disable=no-member + LoginV3Flow.post_login_processing(self.keeper_params, verify_password_response) + else: + raise DemistoException( + "Unknown login state after verify" + # Disabling pylint due to external class declaration + f" password {verify_password_response.loginState}" # pylint: disable=no-member + ) + else: + raise DemistoException(f"Unknown login state {resp.loginState}") + + def finish_registering_device( + self, + device_approval: DeviceApproval, + encrypted_login_token: bytes, + code: str = "", + ): + """Finish the registration process of a new or old device. If the code argument is given, then + we are verifying the newly registered device. + + Args: + device_approval (DeviceApproval): DeviceApproval instance that is in charge of verifying the code sent + to user's email. + encrypted_login_token (bytes): The encrypted login token. + code (str, optional): The code sent to the user's email. Defaults to "". + """ + encrypted_device_token = utils.base64_url_decode(self.keeper_params.device_token) + if code: + device_approval.send_code( + self.keeper_params, + DeviceApprovalChannel.Email, + encrypted_device_token, + encrypted_login_token, + code, + ) + self.validate_device_registration( + encrypted_device_token=encrypted_device_token, + encrypted_login_token=encrypted_login_token, + ) + + def start_registration(self): + device_approval = self.DeviceApproval() + try: + self.start_registering_device(device_approval) + except InvalidDeviceToken: + demisto.info("Registering new device") + self.start_registering_device(device_approval, new_device=True) + + def save_session_token( + self, + ): + append_to_integration_context( + { + "session_token": self.keeper_params.session_token, + "clone_code": self.keeper_params.clone_code, + "valid_until": get_current_time_in_seconds() + SESSION_TOKEN_TTL, + } + ) + + def complete_registration(self, code: str): + device_approval = self.DeviceApproval() + integration_context = get_integration_context() + encrypted_login_token = utils.base64_url_decode(integration_context["login_token"]) + self.finish_registering_device(device_approval, encrypted_login_token, code) + self.save_session_token() + if not self.keeper_params.session_token: + raise DemistoException("Could not find session token") + + def query_audit_logs(self, limit: int, start_event_time: int) -> dict[str, Any]: + request_query = { + "command": "get_audit_event_reports", + "report_type": "raw", + "scope": "enterprise", + "limit": limit, + "order": "ascending", + "filter": { + "created": {"min": start_event_time}, + }, + } + return api.communicate(self.keeper_params, request_query) + + def test_registration(self) -> None: + if not self.keeper_params.session_token: + demisto.debug("No session token configured") + raise DemistoException(REGISTRATION_FLOW_MESSAGE) + self.query_audit_logs(limit=1, start_event_time=0) + + +def load_json(path: str): + with open(path, encoding="utf-8") as f: + return json.loads(f.read()) + + +def get_audit_logs( + client: Client, last_latest_event_time: int, max_fetch_limit: int, last_fetched_ids: set[str] +) -> list[dict[str, Any]]: + continue_fetching = True + events_to_return: list[dict[str, Any]] = [] + # last_latest_event_time -> UNIX epoch time in seconds + start_time_to_fetch = last_latest_event_time + fetched_ids = last_fetched_ids + res_count = 0 + while continue_fetching and res_count < max_fetch_limit: + query_response = client.query_audit_logs( + limit=min(API_MAX_FETCH, max_fetch_limit - res_count), start_event_time=start_time_to_fetch + ) + audit_events = query_response["audit_event_overview_report_rows"] + audit_events_count = len(audit_events) + demisto.debug(f"{LOG_LINE} got {audit_events_count} events from API") + if audit_events: + dedupped_audit_events = dedup_events(audit_events, fetched_ids) + dedupped_events_count = len(dedupped_audit_events) + demisto.debug(f"{LOG_LINE} Events count after dedup {dedupped_events_count}") + if dedupped_audit_events: + add_time_to_events(dedupped_audit_events) + res_count += dedupped_events_count + events_to_return.extend(dedupped_audit_events) + # Getting last events's creation date, assuming asc order + start_time_to_fetch = int(dedupped_audit_events[-1]["created"]) + # We get the event IDs that have the same creation time as the latest event in the response + # We use them to dedup in the next run + fetched_ids = { + str(audit_event["id"]) + for audit_event in dedupped_audit_events + if int(audit_event["created"]) == start_time_to_fetch + } + # Last run of pagination, avoiding endless loop if all the page's results have the same time. + # We do not have other eay to handle this case. + if last_latest_event_time == start_time_to_fetch: + demisto.debug("Got equal start and end time, this was the last page.") + continue_fetching = False + else: + continue_fetching = False + else: + continue_fetching = False + demisto.setLastRun({"last_fetch_epoch_time": str(start_time_to_fetch), "last_fetch_ids": list(fetched_ids)}) + return events_to_return + + +def add_time_to_events(audit_events: list[dict[str, Any]]): + for audit_event in audit_events: + audit_event["_time"] = audit_event["created"] + + +def dedup_events(audit_events: list[dict[str, Any]], last_fetched_ids: set[str]) -> list[dict[str, Any]]: + dedupped_audit_events = list( + filter( + lambda audit_event: str(audit_event["id"]) not in last_fetched_ids, + audit_events, + ) + ) + return dedupped_audit_events + + +def fetch_events(client: Client, last_run: dict[str, Any], max_fetch_limit: int) -> list[dict[str, Any]]: + demisto.debug(f"last_run: {last_run}" if last_run else "last_run is empty") + # SDK's query uses Epoch time to filter events + last_fetch_epoch_time = int(last_run.get("last_fetch_epoch_time", "0")) + + # (if 0) returns False + last_fetch_epoch_time = last_fetch_epoch_time if last_fetch_epoch_time else int(datetime.now().timestamp()) + # last_fetch_epoch_time = 0 + last_fetched_ids = set(last_run.get("last_fetch_ids", [])) + audit_log = get_audit_logs( + client=client, + last_latest_event_time=last_fetch_epoch_time, + max_fetch_limit=max_fetch_limit, + last_fetched_ids=last_fetched_ids, + ) + return audit_log + + +def start_registration_command(client: Client): + client.start_registration() + return CommandResults(readable_output="Code was sent successfully to the user's email") + + +def complete_registration_command(client: Client, code: str): + client.complete_registration(code=code) + return CommandResults(readable_output="Login completed") + + +def test_authorization( + client: Client, +) -> CommandResults: + client.test_registration() + return CommandResults(readable_output="Successful connection") + + +def test_module() -> str: + # We are unable to use client.test_registration(), since the method uses the integration context + # and when we are running test-module, we don't have access to it + raise DemistoException(REGISTRATION_FLOW_MESSAGE) + + +def main() -> None: # pragma: no cover + """main function, parses params and runs command functions""" + params = demisto.params() + command = demisto.command() + args = demisto.args() + username = params.get("credentials", {})["identifier"] + password = params.get("credentials", {})["password"] + server_url = params.get("url") or "keepersecurity.com" + client = Client( + server_url=server_url, + username=username, + password=password, + ) + client.refresh_session_token_if_needed() + demisto.debug(f"Command being called is {demisto.command()}") + try: + if command == "test-module": + return_results(test_module()) + elif command == "keeper-security-register-start": + return_results(start_registration_command(client=client)) + elif command == "keeper-security-register-complete": + return_results(complete_registration_command(client=client, code=args.get("code", ""))) + elif command == "keeper-security-register-test": + return_results(test_authorization(client=client)) + elif command == "fetch-events": + last_run = demisto.getLastRun() + fetched_audit_logs = fetch_events( + client=client, + last_run=last_run, + max_fetch_limit=arg_to_number(params.get("alerts_max_fetch")) or DEFAULT_MAX_FETCH, + ) + demisto.debug(f"Events to send to XSIAM {fetched_audit_logs=}") + send_events_to_xsiam(fetched_audit_logs, VENDOR, PRODUCT) + else: + raise NotImplementedError + # Log exceptions and return errors + except Exception as e: + return_error(f"Failed to execute {command} command.\nError:\n{str(e)}") + + +""" ENTRY POINT """ + + +if __name__ in ("__main__", "__builtin__", "builtins"): + main() diff --git a/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity.yml b/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity.yml new file mode 100644 index 000000000000..61a40c8b2f51 --- /dev/null +++ b/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity.yml @@ -0,0 +1,65 @@ +category: Authentication & Identity Management +sectionOrder: +- Connect +- Collect +commonfields: + id: KeeperSecurity + version: -1 +configuration: +- defaultvalue: keepersecurity.com + display: Server URL + name: url + required: true + type: 0 + section: Connect + additionalinfo: The server URL. For more help, checkout the 'Server Regions' section in the description. +- display: Username + name: credentials + defaultvalue: "" + type: 9 + required: true + section: Connect + displaypassword: Password +- defaultvalue: "10000" + display: Maximum number of Alerts to fetch. + name: alerts_max_fetch + type: 0 + section: Collect +- display: Trust any certificate (not secure) + name: insecure + type: 8 + required: false + section: Connect +- display: Use system proxy settings + name: proxy + type: 8 + required: false + section: Connect +description: Use this integration to fetch audit logs from Keeper Security Admin Console as XSIAM events. +display: Keeper Security +name: KeeperSecurity +script: + commands: + - arguments: + - description: The authorization code retrieved from user's email. + name: code + required: false + description: "Use this command to complete the registration process." + name: keeper-security-register-complete + - description: "Use this command to start the registration process." + name: keeper-security-register-start + arguments: [] + - description: Use this command to test the connectivity of the instance. + name: keeper-security-register-test + arguments: [] + runonce: false + isfetchevents: true + script: '-' + type: python + subtype: python3 + dockerimage: demisto/keepercommander:1.0.0.112259 +fromversion: 6.8.0 +marketplaces: +- marketplacev2 +tests: +- No tests (auto formatted) diff --git a/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity_description.md b/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity_description.md new file mode 100644 index 000000000000..b2a1f8246e54 --- /dev/null +++ b/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity_description.md @@ -0,0 +1,32 @@ +# Keeper Security Event Collector + +## Authentication + +Use basic authentication to communicate with the product. Supply your username and password of the account that you want to use. +To create a new user: + +1. Log in in as admin in [Keeper Admin Console](https://keepersecurity.com/console/). +2. Go to the **Admin** panel, found in the left side bar. +3. Press on **Add User**, and complete the registration process. +4. Once the user has been created, press on the **Edit** icon, and in the **User Actions** dropdown, click **Disable 2FA** (2FA is currently not supported). + +### Authentication Process + +In order to authenticate the configured user, the product uses a device registration process. In order to register a new device that will be used to authenticate the user, follow the following procedures: + +1. Run the **!keeper-security-register-start** command. +2. If the account does **not** have a configured device, then an authorization code will be sent to the configured email address. +3. Run the **!keeper-security-register-complete** command with the acquired authorization code. If the account already has a registered device, run the command without supplying any arguments. +4. Run the command **!keeper-security-register-test** to test that everything is working fine. + +## Server Regions + +Use the URLs for the region that hosts your account: +For more information, see the [Server Config File Options](https://docs.keeper.io/en/v/secrets-manager/commander-cli/commander-installation-setup/configuration#config-file-options) + +- US Instance: +- EU Instance: +- AU Instance: +- GOV Instance: +- CA Instance: +- JP Instance: diff --git a/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity_image.png b/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity_image.png new file mode 100644 index 000000000000..8ea39aa3d807 Binary files /dev/null and b/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity_image.png differ diff --git a/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity_test.py b/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity_test.py new file mode 100644 index 000000000000..6fb8faafb6a2 --- /dev/null +++ b/Packs/KeeperSecurity/Integrations/KeeperSecurity/KeeperSecurity_test.py @@ -0,0 +1,1063 @@ +import json +import pytest +from pytest_mock import MockerFixture +from CommonServerPython import DemistoException +from KeeperSecurity import Client, KeeperParams +from freezegun import freeze_time +import demistomock as demisto + + +def util_load_json(path): + with open(path, encoding="utf-8") as f: + return json.loads(f.read()) + + +@pytest.fixture +def client_class(): + return Client( + server_url="dummy_server_url", + username="dummy_user", + password="dummy_password", + ) + + +@pytest.mark.parametrize( + "device_token, device_private_key, session_token, clone_code", + ( + pytest.param("token1", "private_key1", "session1", "clone_code1"), + pytest.param(None, None, None, None), + ), +) +def test_load_integration_context_into_keeper_params( + mocker: MockerFixture, + device_token: str | None, + device_private_key: str | None, + session_token: str | None, + clone_code: str | None, +): + """ + Given + - Data saved in the integration context that is used in the SDK to do requests. + + When + - Loading the integration context into a KeeperParams instance. + + Then + - Check the KeeperParams instance is instantiated correctly. + """ + from KeeperSecurity import load_integration_context_into_keeper_params + + mocker.patch( + "KeeperSecurity.get_integration_context", + return_value={ + "device_token": device_token, + "device_private_key": device_private_key, + "session_token": session_token, + "clone_code": clone_code, + }, + ) + username = "dummy_username" + password = "dummy_password" + server_url = "dummy_server" + keeper_params = load_integration_context_into_keeper_params(username=username, password=password, server_url=server_url) + assert keeper_params.user == username + assert keeper_params.password == password + assert keeper_params.server == server_url + assert keeper_params.rest_context.certificate_check is False + assert keeper_params.device_token == device_token + assert keeper_params.device_private_key == device_private_key + assert keeper_params.session_token == session_token + assert keeper_params.clone_code == clone_code + + +def test_append_to_integration_context(mocker: MockerFixture): + """ + Given: Data to append to the integration context + When: Appending data to the integration context + Then: Check that the newly appended data is saved + """ + from KeeperSecurity import append_to_integration_context + + integration_context = {"key1": "val1", "key2": 12345} + context_to_append = {"key3": "val3", "key1": "newValue1"} # Notice that key1 will hold newValue1 in the end + mocker.patch( + "KeeperSecurity.get_integration_context", + return_value=integration_context, + ) + set_integration_context_mocker = mocker.patch( + "KeeperSecurity.set_integration_context", + return_value=None, + ) + append_to_integration_context(context_to_append) + assert set_integration_context_mocker.call_args[0][0] == integration_context | context_to_append + + +def test_save_device_tokens( + mocker: MockerFixture, + client_class: Client, +): + """ + Given + - Mocked startLoginMessage function to simulate the device token saving process. + - Mocked set_integration_context to verify its arguments. + When + - Running the save_device_tokens method. + Then + - The set_integration_context function should be called with the correct device private key, device token, and login + token. + """ + from KeeperSecurity import APIRequest_pb2, utils + + device_private_key = "private_key1" + device_token = "device_token1" + login_token = "1111111111111111" # Length 16 + + def startLoginMessage_side_effect( + keeper_params: KeeperParams, encrypted_device_token: bytes, cloneCode: bytes | None = None, loginType="NORMAL" + ): + keeper_params.device_private_key = device_private_key # type: ignore + keeper_params.device_token = device_token # type: ignore + login_response = APIRequest_pb2.LoginResponse() + login_response.encryptedLoginToken = utils.base64_url_decode(login_token) # type: ignore + return login_response + + mocker.patch( + "KeeperSecurity.LoginV3API.startLoginMessage", + side_effect=startLoginMessage_side_effect, + ) + set_integration_context_mocker = mocker.patch( + "KeeperSecurity.set_integration_context", + return_value=None, + ) + client_class.save_device_tokens(b"encryptedDeviceToken") + assert set_integration_context_mocker.call_args[0][0] == { + "device_private_key": device_private_key, + "device_token": device_token, + "login_token": login_token, + } + + +def test_start_registering_device_success(mocker: MockerFixture, client_class: Client): + """ + Given + - A mock device approval object. + - Mocked get_device_id to simulate fetching a device ID. + - Mocked save_device_tokens to simulate saving device tokens. + When + - Running the start_registering_device method. + Then + - The device_approval's send_push method should be called once. + """ + from KeeperSecurity import APIRequest_pb2 + + device_approval = client_class.DeviceApproval() + mocker.patch( + "KeeperSecurity.LoginV3API.get_device_id", + return_value=b"encryptedDeviceToken", + ) + login_resp = APIRequest_pb2.LoginResponse() + login_resp.loginState = APIRequest_pb2.DEVICE_APPROVAL_REQUIRED # type: ignore + mocker.patch.object( + client_class, + "save_device_tokens", + return_value=login_resp, + ) + device_approval_mocker = mocker.patch.object(device_approval, "send_push", return_value=None) + client_class.start_registering_device(device_approval=device_approval) + assert device_approval_mocker.call_count == 1 + + +def test_start_registering_device_already_registered(mocker: MockerFixture, client_class: Client): + """ + Given + - Mocked get_device_id to simulate fetching a device ID. + - Mocked save_device_tokens to simulate returning a login state indicating the device is already registered. + When + - Running the start_registering_device method. + Then + - The function should raise a DemistoException with a message indicating the device is already registered. + """ + from KeeperSecurity import APIRequest_pb2, DEVICE_ALREADY_REGISTERED + + mocker.patch( + "KeeperSecurity.LoginV3API.get_device_id", + return_value=b"encryptedDeviceToken", + ) + login_resp = APIRequest_pb2.LoginResponse() + login_resp.loginState = APIRequest_pb2.REQUIRES_AUTH_HASH # type: ignore + mocker.patch.object( + client_class, + "save_device_tokens", + return_value=login_resp, + ) + with pytest.raises(DemistoException, match=DEVICE_ALREADY_REGISTERED): + client_class.start_registering_device(device_approval=client_class.DeviceApproval()) + + +def test_start_registering_device_unknown_state(mocker: MockerFixture, client_class: Client): + """ + Given + - Mocked get_device_id to simulate fetching a device ID. + - Mocked save_device_tokens to simulate returning an unknown login state. + When + - Running the start_registering_device method. + Then + - The function should raise a DemistoException with a message indicating an unknown login state. + """ + from KeeperSecurity import APIRequest_pb2 + + mocker.patch( + "KeeperSecurity.LoginV3API.get_device_id", + return_value=b"encryptedDeviceToken", + ) + login_resp = APIRequest_pb2.LoginResponse() + login_resp.loginState = APIRequest_pb2.INVALID_LOGINMETHOD # type: ignore + mocker.patch.object( + client_class, + "save_device_tokens", + return_value=login_resp, + ) + with pytest.raises(DemistoException, match="Unknown login state 0"): + client_class.start_registering_device(device_approval=client_class.DeviceApproval()) + + +def test_validate_device_registration_requires_auth_hash(mocker: MockerFixture, client_class: Client): + """ + Given + - Mocked startLoginMessage to simulate a login response requiring authentication hash. + - Mocked get_correct_salt to simulate returning a correct salt. + - Mocked verify_password to simulate a successful password verification. + - Mocked post_login_processing to simulate post-login processing. + When + - Running the validate_device_registration method. + Then + - The function should call the appropriate methods to process the login and verify the password. + """ + from KeeperSecurity import APIRequest_pb2 + + # Arrange + encrypted_device_token = b"encrypted_device_token" + encrypted_login_token = b"encrypted_login_token" + + start_login_resp = APIRequest_pb2.LoginResponse() + start_login_resp.loginState = APIRequest_pb2.REQUIRES_AUTH_HASH # type: ignore + + verify_password_response = APIRequest_pb2.LoginResponse() + verify_password_response.loginState = APIRequest_pb2.LOGGED_IN # type: ignore + + correct_salt_resp = mocker.Mock() + correct_salt_resp.salt = [b"correct_salt"] + + mock_start_login = mocker.patch( + "KeeperSecurity.LoginV3API.startLoginMessage", return_value=start_login_resp + ) + mock_get_salt = mocker.patch("KeeperSecurity.api.get_correct_salt", return_value=correct_salt_resp) + mock_verify_password = mocker.patch.object( + client_class.PasswordStep, "verify_password", return_value=verify_password_response + ) + mock_post_login_processing = mocker.patch( + "KeeperSecurity.LoginV3Flow.post_login_processing", return_value=None + ) + + # Act + client_class.validate_device_registration(encrypted_device_token, encrypted_login_token) + + # Assert + mock_start_login.assert_called_once_with(client_class.keeper_params, encrypted_device_token) + mock_get_salt.assert_called_once_with(start_login_resp.salt) # type: ignore + mock_verify_password.assert_called_once_with(client_class.keeper_params, encrypted_login_token) + mock_post_login_processing.assert_called_once_with(client_class.keeper_params, mock_verify_password.return_value) + + +def test_validate_device_registration_unknown_login_state_after_verify_password(mocker: MockerFixture, client_class: Client): + """ + Given + - Mocked startLoginMessage to simulate a login response requiring authentication hash. + - Mocked verify_password to simulate an unknown login state after password verification. + When + - Running the validate_device_registration method. + Then + - The function should raise a DemistoException with a message indicating an unknown login state after + password verification. + """ + from KeeperSecurity import APIRequest_pb2 + + # Arrange + encrypted_device_token = b"encrypted_device_token" + encrypted_login_token = b"encrypted_login_token" + + start_login_resp = APIRequest_pb2.LoginResponse() + start_login_resp.loginState = APIRequest_pb2.REQUIRES_AUTH_HASH # type: ignore + + verify_password_response = APIRequest_pb2.LoginResponse() + verify_password_response.loginState = APIRequest_pb2.INVALID_LOGINMETHOD # type: ignore + + correct_salt_resp = mocker.Mock() + correct_salt_resp.salt = [b"correct_salt"] + + mocker.patch("KeeperSecurity.LoginV3API.startLoginMessage", return_value=start_login_resp) + mocker.patch("KeeperSecurity.api.get_correct_salt", return_value=correct_salt_resp) + mocker.patch.object(client_class.PasswordStep, "verify_password", return_value=verify_password_response) + + # Act & Assert + with pytest.raises(DemistoException, match="Unknown login state after verify password 0"): + client_class.validate_device_registration(encrypted_device_token, encrypted_login_token) + + +def test_validate_device_registration_unknown_login_state(mocker: MockerFixture, client_class: Client): + """ + Given + - Mocked startLoginMessage to simulate a login response with an unknown login state. + When + - Running the validate_device_registration method. + Then + - The function should raise a DemistoException with a message indicating an unknown login state. + """ + from KeeperSecurity import APIRequest_pb2 + + # Arrange + encrypted_device_token = b"encrypted_device_token" + encrypted_login_token = b"encrypted_login_token" + + start_login_resp = APIRequest_pb2.LoginResponse() + start_login_resp.loginState = APIRequest_pb2.INVALID_LOGINMETHOD # type: ignore + + mocker.patch("KeeperSecurity.LoginV3API.startLoginMessage", return_value=start_login_resp) + + # Act & Assert + with pytest.raises(DemistoException, match="Unknown login state 0"): + client_class.validate_device_registration(encrypted_device_token, encrypted_login_token) + + +def test_start_registration_success(mocker: MockerFixture, client_class: Client): + """ + Given + - A mock device approval object. + - Mocked start_registering_device to simulate the registration process. + When + - Running the start_registration method. + Then + - The start_registering_device method should be called with the device approval object. + """ + # Arrange + device_approval_mock = client_class.DeviceApproval() + + mocker.patch.object(client_class, "DeviceApproval", return_value=device_approval_mock) + mock_start_registering_device = mocker.patch.object(client_class, "start_registering_device") + + # Act + client_class.start_registration() + + # Assert + mock_start_registering_device.assert_called_once_with(device_approval_mock) + + +def test_start_registration_with_invalid_device_token(mocker: MockerFixture, client_class: Client): + """ + Given + - A mock device approval object. + - Mocked start_registering_device to simulate an InvalidDeviceToken exception on the first call. + When + - Running the start_registration method. + Then + - The start_registering_device method should be called twice, once normally and once with new_device=True. + """ + from KeeperSecurity import InvalidDeviceToken + + # Arrange + device_approval_mock = client_class.DeviceApproval() + + mocker.patch.object(client_class, "DeviceApproval", return_value=device_approval_mock) + mock_start_registering_device = mocker.patch.object(client_class, "start_registering_device") + + # Simulate raising InvalidDeviceToken on the first call + mock_start_registering_device.side_effect = [InvalidDeviceToken, None] + + # Act + client_class.start_registration() + + # Assert + assert mock_start_registering_device.call_count == 2 + mock_start_registering_device.assert_any_call(device_approval_mock) + mock_start_registering_device.assert_any_call(device_approval_mock, new_device=True) + + +def test_finish_registering_device_with_code(mocker: MockerFixture, client_class: Client): + """ + Given + - A mock device approval object. + - Mocked base64_url_decode to simulate decoding a device token. + - Mocked send_code to simulate sending a code. + - Mocked validate_device_registration to simulate the device registration process. + When + - Running the finish_registering_device method with a code provided. + Then + - The send_code method should be called with the correct arguments. + - The validate_device_registration method should be called with the decoded device token. + """ + from KeeperSecurity import DeviceApprovalChannel + + # Arrange + device_approval = mocker.Mock() + encrypted_login_token = b"encrypted_login_token" + code = "123456" + + mock_base64_url_decode = mocker.patch( + "KeeperSecurity.utils.base64_url_decode", return_value=b"encrypted_device_token" + ) + mock_send_code = mocker.patch.object(device_approval, "send_code") + mock_validate_device_registration = mocker.patch.object(client_class, "validate_device_registration") + + # Act + client_class.finish_registering_device(device_approval, encrypted_login_token, code) + + # Assert + mock_base64_url_decode.assert_called_once_with(client_class.keeper_params.device_token) + mock_send_code.assert_called_once_with( + client_class.keeper_params, + DeviceApprovalChannel.Email, + b"encrypted_device_token", + encrypted_login_token, + code, + ) + mock_validate_device_registration.assert_called_once_with( + encrypted_device_token=b"encrypted_device_token", + encrypted_login_token=encrypted_login_token, + ) + + +def test_finish_registering_device_without_code(mocker: MockerFixture, client_class: Client): + """ + Given + - A mock device approval object. + - Mocked base64_url_decode to simulate decoding a device token. + - Mocked validate_device_registration to simulate the device registration process. + When + - Running the finish_registering_device method without a code. + Then + - The send_code method should not be called. + - The validate_device_registration method should be called with the decoded device token. + """ + # Arrange + device_approval = mocker.Mock() + encrypted_login_token = b"encrypted_login_token" + code = "" + + mock_base64_url_decode = mocker.patch( + "KeeperSecurity.utils.base64_url_decode", return_value=b"encrypted_device_token" + ) + mock_send_code = mocker.patch.object(device_approval, "send_code") + mock_validate_device_registration = mocker.patch.object(client_class, "validate_device_registration") + + # Act + client_class.finish_registering_device(device_approval, encrypted_login_token, code) + + # Assert + mock_base64_url_decode.assert_called_once_with(client_class.keeper_params.device_token) + mock_send_code.assert_not_called() + mock_validate_device_registration.assert_called_once_with( + encrypted_device_token=b"encrypted_device_token", + encrypted_login_token=encrypted_login_token, + ) + + +def test_complete_registration_success(mocker: MockerFixture, client_class: Client): + """ + Given + - A mock device approval object. + - Mocked get_integration_context to simulate fetching the integration context. + - Mocked base64_url_decode to simulate decoding the login token. + - Mocked finish_registering_device to simulate completing the registration process. + - Mocked save_session_token to simulate saving the session token. + When + - Running the complete_registration method with a valid session token. + Then + - The finish_registering_device and save_session_token methods should be called with the correct arguments. + """ + # Arrange + code = "123456" + device_approval_mock = mocker.Mock() + integration_context_mock = {"login_token": "mocked_login_token"} + encrypted_login_token = b"mocked_encrypted_login_token" + + mocker.patch.object(client_class, "DeviceApproval", return_value=device_approval_mock) + mocker.patch("KeeperSecurity.get_integration_context", return_value=integration_context_mock) + mocker.patch("KeeperSecurity.utils.base64_url_decode", return_value=encrypted_login_token) + mock_finish_registering_device = mocker.patch.object(client_class, "finish_registering_device") + mock_save_session_token = mocker.patch.object(client_class, "save_session_token") + + # Mock a valid session token + client_class.keeper_params.session_token = "mocked_session_token" # type: ignore + + # Act + client_class.complete_registration(code) + + # Assert + mock_finish_registering_device.assert_called_once_with(device_approval_mock, encrypted_login_token, code) + mock_save_session_token.assert_called_once() + assert client_class.keeper_params.session_token == "mocked_session_token" + + +def test_complete_registration_no_session_token(mocker: MockerFixture, client_class: Client): + """ + Given + - A mock device approval object. + - Mocked get_integration_context to simulate fetching the integration context. + - Mocked base64_url_decode to simulate decoding the login token. + - Mocked finish_registering_device to simulate completing the registration process. + - Mocked save_session_token to simulate saving the session token. + - Simulating an empty session token. + When + - Running the complete_registration method. + Then + - The function should raise a DemistoException with a message indicating no session token was found. + """ + # Arrange + code = "123456" + device_approval_mock = mocker.Mock() + integration_context_mock = {"login_token": "mocked_login_token"} + encrypted_login_token = b"mocked_encrypted_login_token" + + mocker.patch.object(client_class, "DeviceApproval", return_value=device_approval_mock) + mocker.patch("KeeperSecurity.get_integration_context", return_value=integration_context_mock) + mocker.patch("KeeperSecurity.utils.base64_url_decode", return_value=encrypted_login_token) + mock_finish_registering_device = mocker.patch.object(client_class, "finish_registering_device") + mock_save_session_token = mocker.patch.object(client_class, "save_session_token") + + # Mock an invalid (empty) session token + client_class.keeper_params.session_token = "" # type: ignore + + # Act & Assert + with pytest.raises(DemistoException, match="Could not find session token"): + client_class.complete_registration(code) + + mock_finish_registering_device.assert_called_once_with(device_approval_mock, encrypted_login_token, code) + mock_save_session_token.assert_called_once() + + +@freeze_time("2024-01-01 00:00:00") +def test_save_session_token(mocker: MockerFixture, client_class: Client): + """ + Given + - Mocked append_to_integration_context to verify its arguments. + - Simulated session_token and clone_code in keeper_params. + - Freezing time to simulate the current time. + When + - Running the save_session_token method. + Then + - The append_to_integration_context function should be called with the correct session token, clone code, and calculated + valid_until time. + """ + from KeeperSecurity import get_current_time_in_seconds + + # Arrange + session_token = "mocked_session_token" + clone_code = "mocked_clone_code" + current_time = get_current_time_in_seconds() # This represents the freezed time, e.g., Jan 1, 2024, 00:00:00 UTC + session_token_ttl = 3600 # Example TTL (1 hour) + + mock_append_to_integration_context = mocker.patch("KeeperSecurity.append_to_integration_context") + + client_class.keeper_params.session_token = session_token # type: ignore + client_class.keeper_params.clone_code = clone_code # type: ignore + + # Act + client_class.save_session_token() + + # Assert + mock_append_to_integration_context.assert_called_once_with( + { + "session_token": session_token, + "clone_code": clone_code, + "valid_until": current_time + session_token_ttl, + } + ) + + +def test_test_registration_no_session_token(client_class: Client): + """ + Given + - Simulated an empty session token in keeper_params. + When + - Running the test_registration method. + Then + - The function should raise a DemistoException with the REGISTRATION_FLOW_MESSAGE. + """ + from KeeperSecurity import REGISTRATION_FLOW_MESSAGE + + # Arrange + client_class.keeper_params.session_token = "" # type: ignore + + # Act & Assert + with pytest.raises(DemistoException, match=REGISTRATION_FLOW_MESSAGE): + client_class.test_registration() + + +def test_test_registration_with_session_token(mocker: MockerFixture, client_class: Client): + """ + Given + - Simulated a valid session token in keeper_params. + - Mocked query_audit_logs to track its call. + When + - Running the test_registration method. + Then + - The query_audit_logs method should be called with the correct limit and start_event_time. + """ + # Arrange + client_class.keeper_params.session_token = "mocked_session_token" # type: ignore + + mock_query_audit_logs = mocker.patch.object(client_class, "query_audit_logs") + + # Act + client_class.test_registration() + + # Assert + mock_query_audit_logs.assert_called_once_with(limit=1, start_event_time=0) + + +def test_refresh_session_token_if_needed_refresh_needed(mocker: MockerFixture, client_class: Client): + """ + Given + - Mocked get_integration_context to return a valid_until time close to the current time. + - Mocked get_current_time_in_seconds to simulate the current time. + - Mocked get_device_id to simulate fetching a device ID. + - Mocked save_device_tokens to simulate saving device tokens. + - Mocked validate_device_registration to simulate the registration process. + - Mocked save_session_token to simulate saving the session token. + When + - Running the refresh_session_token_if_needed method. + Then + - The get_device_id, save_device_tokens, validate_device_registration, and save_session_token methods should be called + with the correct arguments. + """ + # Arrange + integration_context_mock = { + "valid_until": 1609459200 # Example timestamp for valid_until + } + current_time = 1609459195 # 5 seconds before valid_until + + mocker.patch("KeeperSecurity.get_integration_context", return_value=integration_context_mock) + mocker.patch("KeeperSecurity.get_current_time_in_seconds", return_value=current_time) + mock_get_device_id = mocker.patch( + "KeeperSecurity.LoginV3API.get_device_id", return_value=b"encrypted_device_token" + ) + mock_save_device_tokens = mocker.patch.object( + client_class, "save_device_tokens", return_value=mocker.Mock(encryptedLoginToken=b"encrypted_login_token") + ) + mock_validate_device_registration = mocker.patch.object(client_class, "validate_device_registration") + mock_save_session_token = mocker.patch.object(client_class, "save_session_token") + + client_class.keeper_params.session_token = "mocked_session_token" # type: ignore + + # Act + client_class.refresh_session_token_if_needed() + + # Assert + mock_get_device_id.assert_called_once_with(client_class.keeper_params) + mock_save_device_tokens.assert_called_once_with(encrypted_device_token=b"encrypted_device_token") + mock_validate_device_registration.assert_called_once_with( + encrypted_device_token=b"encrypted_device_token", + encrypted_login_token=b"encrypted_login_token", + ) + mock_save_session_token.assert_called_once() + + +def test_refresh_session_token_if_needed_no_refresh_needed(mocker: MockerFixture, client_class: Client): + """ + Given + - Mocked get_integration_context to return a valid_until time far from the current time. + - Mocked get_current_time_in_seconds to simulate the current time. + When + - Running the refresh_session_token_if_needed method. + Then + - The get_device_id, save_device_tokens, validate_device_registration, and save_session_token methods + should not be called. + """ + # Arrange + integration_context_mock = { + "valid_until": 1609459200 # Example timestamp for valid_until + } + current_time = 1609459100 # 100 seconds before valid_until + + mocker.patch("KeeperSecurity.get_integration_context", return_value=integration_context_mock) + mocker.patch("KeeperSecurity.get_current_time_in_seconds", return_value=current_time) + mock_get_device_id = mocker.patch("KeeperSecurity.LoginV3API.get_device_id") + mock_save_device_tokens = mocker.patch.object(client_class, "save_device_tokens") + mock_validate_device_registration = mocker.patch.object(client_class, "validate_device_registration") + mock_save_session_token = mocker.patch.object(client_class, "save_session_token") + + client_class.keeper_params.session_token = "mocked_session_token" # type: ignore + + # Act + client_class.refresh_session_token_if_needed() + + # Assert + mock_get_device_id.assert_not_called() + mock_save_device_tokens.assert_not_called() + mock_validate_device_registration.assert_not_called() + mock_save_session_token.assert_not_called() + + +@pytest.mark.parametrize( + "last_run, expected_last_latest_event_time, expected_last_fetched_ids", + [ + ( + {"last_fetch_epoch_time": "1672524000", "last_fetch_ids": ["id1", "id2"]}, + 1672524000, # 2023-01-01 00:00 UTC + {"id1", "id2"}, + ), + ( + {}, # No last_run data + 1704067200, # Expected frozen timestamp for 2024-01-01 00:00:00 UTC + set(), + ), + ], +) +@freeze_time("2024-01-01 00:00:00") +def test_fetch_events(mocker: MockerFixture, last_run, expected_last_latest_event_time, expected_last_fetched_ids): + """ + Given + - A mock client to fetch audit logs. + - A last_run dictionary with different configurations for previous fetch times and fetched IDs. + - A frozen time of 2024-01-01 00:00:00 UTC to ensure consistent timestamp handling. + When + - Running the fetch_events function. + Then + - The function should call get_audit_logs with the expected last_latest_event_time and last_fetched_ids based on the + last_run input. + - The result should match the mocked audit logs returned by get_audit_logs. + """ + from KeeperSecurity import fetch_events + + # Arrange + client_mock = mocker.Mock() + max_fetch_limit = 10 + + audit_log_mock = [{"event": "mocked_event"}] + mock_get_audit_logs = mocker.patch("KeeperSecurity.get_audit_logs", return_value=audit_log_mock) + + # Act + result = fetch_events(client=client_mock, last_run=last_run, max_fetch_limit=max_fetch_limit) + + # Assert + mock_get_audit_logs.assert_called_once_with( + client=client_mock, + last_latest_event_time=expected_last_latest_event_time, + max_fetch_limit=max_fetch_limit, + last_fetched_ids=expected_last_fetched_ids, + ) + assert result == audit_log_mock + + +@pytest.mark.parametrize( + "audit_events, last_fetched_ids, expected_result", + [ + ( + [{"id": "1", "event": "event1"}, {"id": "2", "event": "event2"}, {"id": "3", "event": "event3"}], + {"4", "5"}, + [{"id": "1", "event": "event1"}, {"id": "2", "event": "event2"}, {"id": "3", "event": "event3"}], + ), + ( + [{"id": "1", "event": "event1"}, {"id": "2", "event": "event2"}, {"id": "3", "event": "event3"}], + {"2", "3"}, + [{"id": "1", "event": "event1"}], + ), + ( + [{"id": "1", "event": "event1"}, {"id": "2", "event": "event2"}, {"id": "3", "event": "event3"}], + {"1", "2", "3"}, + [], + ), + ( + [], + {"1", "2", "3"}, + [], + ), + ], +) +def test_dedup_events(audit_events, last_fetched_ids, expected_result): + """ + Given + - A list of audit events and a set of last fetched IDs. + When + - Running the dedup_events function. + Then + - The function should correctly filter out events whose IDs are in last_fetched_ids. + - The result should match the expected filtered list of audit events. + """ + from KeeperSecurity import dedup_events + + # Act + result = dedup_events(audit_events, last_fetched_ids) + + # Assert + assert result == expected_result + + +def test_get_audit_logs_res_count_reaches_max_fetch_limit(mocker: MockerFixture): + """ + Given + - A mock client with two batches of audit events. + - Mocking API_MAX_FETCH to dynamically limit the number of events fetched in each call. + - Mocking demisto.setLastRun to verify its arguments. + When + - Running the get_audit_logs function with a max_fetch_limit smaller than the total number of available events. + Then + - The function should stop fetching after reaching the max_fetch_limit. + - The client.query_audit_logs should be called twice, with the correct limits for each call. + - The demisto.setLastRun should be called with the correct last fetch time and IDs. + """ + from KeeperSecurity import get_audit_logs + from unittest.mock import call + + # Arrange + client_mock = mocker.Mock() + audit_events_first_batch = [ + {"id": "1", "created": "1609459200"}, + {"id": "2", "created": "1609459200"}, + ] + audit_events_second_batch = [ + {"id": "3", "created": "1609459300"}, + ] + + query_response_first = {"audit_event_overview_report_rows": audit_events_first_batch} + query_response_second = {"audit_event_overview_report_rows": audit_events_second_batch} + + # Simulate two batches + client_mock.query_audit_logs.side_effect = [query_response_first, query_response_second] + + # Mock API_MAX_FETCH to be 2 + mocker.patch("KeeperSecurity.API_MAX_FETCH", 2) + mocker.patch("KeeperSecurity.demisto.setLastRun") + + # Act + result = get_audit_logs( + client=client_mock, + last_latest_event_time=1609459100, + max_fetch_limit=3, # Set max_fetch_limit to 3 + last_fetched_ids=set(), + ) + + # Assert + assert result == audit_events_first_batch + audit_events_second_batch # Both batches should be returned + assert client_mock.query_audit_logs.call_count == 2 # Two calls should be made + + # Check that the first call was made with a limit of 2 and the second with a limit of 1 + expected_calls = [ + call(limit=2, start_event_time=1609459100), + call(limit=1, start_event_time=1609459200), + ] + assert client_mock.query_audit_logs.mock_calls == expected_calls + + demisto.setLastRun.assert_called_once_with({"last_fetch_epoch_time": "1609459300", "last_fetch_ids": ["3"]}) + + +def test_get_audit_logs_no_audit_events(mocker: MockerFixture): + """ + Given + - A mock client that returns an empty list of audit events. + - Mocking demisto.setLastRun to verify its arguments. + When + - Running the get_audit_logs function. + Then + - The result should be an empty list. + - The client.query_audit_logs should be called once. + - The demisto.setLastRun should be called with the initial last_latest_event_time and an empty list of IDs. + """ + from KeeperSecurity import get_audit_logs + + # Arrange + client_mock = mocker.Mock() + query_response_mock = {"audit_event_overview_report_rows": []} + client_mock.query_audit_logs.return_value = query_response_mock + mocker.patch("KeeperSecurity.demisto.setLastRun") + + # Act + result = get_audit_logs( + client=client_mock, + last_latest_event_time=1609459200, + max_fetch_limit=10, + last_fetched_ids=set(), + ) + + # Assert + assert result == [] + client_mock.query_audit_logs.assert_called_once() + demisto.setLastRun.assert_called_once_with({"last_fetch_epoch_time": "1609459200", "last_fetch_ids": []}) + + +def test_get_audit_logs_deduplication_results_no_new_events(mocker: MockerFixture): + """ + Given + - A mock client that returns audit events already present in last_fetched_ids. + - Mocking demisto.setLastRun to verify its arguments. + When + - Running the get_audit_logs function. + Then + - The result should be an empty list as no new events were found. + - The client.query_audit_logs should be called once. + - The demisto.setLastRun should be called with the same last_latest_event_time and the same set of fetched IDs. + """ + from KeeperSecurity import get_audit_logs + + # Arrange + client_mock = mocker.Mock() + audit_events = [{"id": "1", "created": "1609459200"}, {"id": "2", "created": "1609459201"}] + query_response_mock = {"audit_event_overview_report_rows": audit_events} + client_mock.query_audit_logs.return_value = query_response_mock + last_fetched_ids = {"1", "2"} + mocker.patch("KeeperSecurity.demisto.setLastRun") + + # Act + result = get_audit_logs( + client=client_mock, + last_latest_event_time=1609459200, + max_fetch_limit=10, + last_fetched_ids=last_fetched_ids, + ) + + # Assert + assert result == [] + client_mock.query_audit_logs.assert_called_once() + + args, _ = demisto.setLastRun.call_args + assert args[0]["last_fetch_epoch_time"] == "1609459200" + assert sorted(args[0]["last_fetch_ids"]) == sorted(["1", "2"]) + + +def test_get_audit_logs_pagination_stops_no_progress(mocker: MockerFixture): + """ + Given + - A mock client that returns audit events with the same creation time as the last_latest_event_time. + - Mocking demisto.setLastRun to verify its arguments. + When + - Running the get_audit_logs function. + Then + - The result should contain the audit events returned by the client. + - The client.query_audit_logs should be called once. + - The demisto.setLastRun should be called with the same last_latest_event_time and the corresponding fetched IDs. + """ + from KeeperSecurity import get_audit_logs + + # Arrange + client_mock = mocker.Mock() + audit_events = [ + {"id": "1", "created": "1609459200"}, + {"id": "2", "created": "1609459200"}, + ] + query_response_mock = {"audit_event_overview_report_rows": audit_events} + client_mock.query_audit_logs.return_value = query_response_mock + mocker.patch("KeeperSecurity.demisto.setLastRun") + + # Act + result = get_audit_logs( + client=client_mock, + last_latest_event_time=1609459200, + max_fetch_limit=10, + last_fetched_ids=set(), + ) + + # Assert + assert result == audit_events + client_mock.query_audit_logs.assert_called_once() + # Assert that setLastRun was called with the correct parameters + args, _ = demisto.setLastRun.call_args + assert args[0]["last_fetch_epoch_time"] == "1609459200" + assert sorted(args[0]["last_fetch_ids"]) == sorted(["1", "2"]) + + +def test_get_audit_logs_successful_fetching(mocker: MockerFixture): + """ + Given + - A mock client with two batches of audit events. + - Mocking API_MAX_FETCH to limit the number of events fetched in each call. + - Mocking add_time_to_events to track its calls. + - Mocking demisto.setLastRun to verify its arguments. + When + - Running the get_audit_logs function. + Then + - Both batches of events should be returned. + - The client.query_audit_logs should be called twice with appropriate limits. + - The demisto.setLastRun should be called with the correct parameters. + - The add_time_to_events should be called twice, once for each batch of events. + """ + + from KeeperSecurity import get_audit_logs + from unittest.mock import call + + # Arrange + client_mock = mocker.Mock() + audit_events_first_batch = [ + {"id": "1", "created": "1609459200"}, + {"id": "2", "created": "1609459200"}, + ] + audit_events_second_batch = [ + {"id": "3", "created": "1609459300"}, + {"id": "4", "created": "1609459300"}, + ] + query_response_first = {"audit_event_overview_report_rows": audit_events_first_batch} + query_response_second = {"audit_event_overview_report_rows": audit_events_second_batch} + client_mock.query_audit_logs.side_effect = [query_response_first, query_response_second] + + # Mock API_MAX_FETCH to be 2 + mocker.patch("KeeperSecurity.API_MAX_FETCH", 2) + mocker.patch("KeeperSecurity.demisto.setLastRun") + + # Mock add_time_to_events to track calls + mock_add_time_to_events = mocker.patch("KeeperSecurity.add_time_to_events") + + # Act + result = get_audit_logs( + client=client_mock, + last_latest_event_time=1609459100, + max_fetch_limit=4, + last_fetched_ids=set(), + ) + + # Assert + assert result == audit_events_first_batch + audit_events_second_batch # Both batches should be returned + assert client_mock.query_audit_logs.call_count == 2 # Two calls should be made + + # Check that the first call was made with a limit of 2 and the second with a limit of 2 + expected_calls = [ + call(limit=2, start_event_time=1609459100), + call(limit=2, start_event_time=1609459200), + ] + assert client_mock.query_audit_logs.mock_calls == expected_calls + + args, _ = demisto.setLastRun.call_args + assert args[0]["last_fetch_epoch_time"] == "1609459300" + assert sorted(args[0]["last_fetch_ids"]) == sorted(["3", "4"]) + + # Assert add_time_to_events was called twice, once for each batch + assert mock_add_time_to_events.call_count == 2 + assert mock_add_time_to_events.mock_calls == [call(audit_events_first_batch), call(audit_events_second_batch)] + + +def test_add_time_to_events(): + """ + Given + - A list of audit events with 'created' timestamps. + When + - Running the add_time_to_events function. + Then + - Each event in the list should have a '_time' key added, with the value matching the 'created' timestamp. + """ + from KeeperSecurity import add_time_to_events + + # Arrange + audit_events = [ + {"id": "1", "created": "1609459200"}, + {"id": "2", "created": "1609459300"}, + {"id": "3", "created": "1609459400"}, + ] + expected_events = [ + {"id": "1", "created": "1609459200", "_time": "1609459200"}, + {"id": "2", "created": "1609459300", "_time": "1609459300"}, + {"id": "3", "created": "1609459400", "_time": "1609459400"}, + ] + + # Act + add_time_to_events(audit_events) + + # Assert + assert audit_events == expected_events diff --git a/Packs/KeeperSecurity/Integrations/KeeperSecurity/README.md b/Packs/KeeperSecurity/Integrations/KeeperSecurity/README.md new file mode 100644 index 000000000000..1317026980cc --- /dev/null +++ b/Packs/KeeperSecurity/Integrations/KeeperSecurity/README.md @@ -0,0 +1,89 @@ +Use this integration to fetch audit logs from Keeper Security Admin Console as XSIAM events. +This integration was integrated and tested with version 16.11.8 of Keeper Commander. + +## Configure Keeper Secrets Manager Event Collector on Cortex XSOAR + +1. Navigate to **Settings** > **Integrations** > **Servers & Services**. +2. Search for Keeper Secrets Manager Event Collector. +3. Click **Add instance** to create and configure a new integration instance. + + | **Parameter** | **Description** | **Required** | + | --- | --- | --- | + | Server URL | The server URL. For more help, checkout the 'Server Regions' section in the description. | True | + | Username | | True | + | Password | | True | + | Maximum number of Alerts to fetch. | The maximum number of Alert events to fetch. | | + | Trust any certificate (not secure) | | False | + | Use system proxy settings | | False | + +4. Click **Test** to validate the URLs, token, and connection. + +## Commands + +You can execute these commands from the Cortex XSOAR CLI, as part of an automation, or in a playbook. +After you successfully execute a command, a DBot message appears in the War Room with the command details. + +### keeper-security-register-start + +*** +Use this command to start the registration process. + +#### Base Command + +`keeper-security-register-start` + +#### Input + +There are no input arguments for this command. + +#### Context Output + +There is no context output for this command. + +#### Human Readable Output + +>Code was sent successfully to the user's email + +### keeper-security-register-complete + +*** +Use this command to complete the registration process. + +#### Base Command + +`keeper-security-register-complete` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| code | The authorization code retrieved from user's email. | Optional | + +#### Context Output + +There is no context output for this command. + +#### Human Readable Output + +>Login completed + +### keeper-security-register-test + +*** +Use this command to test the connectivity of the instance. + +#### Base Command + +`keeper-security-register-test` + +#### Input + +There is no context output for this command. + +#### Context Output + +There is no context output for this command. + +#### Human Readable Output + +>Successful connection diff --git a/Packs/KeeperSecurity/Integrations/KeeperSecurity/command_examples b/Packs/KeeperSecurity/Integrations/KeeperSecurity/command_examples new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/Packs/KeeperSecurity/ModelingRules/KeeperSecurity/KeeperSecurity.xif b/Packs/KeeperSecurity/ModelingRules/KeeperSecurity/KeeperSecurity.xif new file mode 100644 index 000000000000..e1af3c6f1b01 --- /dev/null +++ b/Packs/KeeperSecurity/ModelingRules/KeeperSecurity/KeeperSecurity.xif @@ -0,0 +1,24 @@ +[MODEL: dataset = "keeper_security_raw"] +alter + xdm.event.type = audit_event_type, + xdm.event.id = to_string(id), + xdm.source.ipv4 = if(ip_address ~= "\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", ip_address, null), + xdm.source.ipv6 = if(ip_address ~= "[a-fA-F0-9\:]{1,5}[a-fA-F0-9\:]{1,5}[a-fA-F0-9\:]{1,5}[a-fA-F0-9\:]{1,5}[a-fA-F0-9\:]{1,5}[a-fA-F0-9\:]{1,5}[a-fA-F0-9\:]{1,5}[a-fA-F0-9\:]{1,5}", ip_address, null), + xdm.source.user.username = username, + xdm.source.user.ou = to_string(node_id), + xdm.intermediate.user.username = from_username, + xdm.target.user.username = if(lowercase(audit_event_type) in ("delete_pending_user", "auto_invite_user", "delete_user", "send_invitation"), email, coalesce(to_username, recipient)), + xdm.target.user.upn = email, + xdm.observer.type = arrayindex(regextract(keeper_version, "([^\d]+)\s\d"), 0), + xdm.observer.version = arrayindex(regextract(keeper_version, "\s([\d\.]+)"), 0), + xdm.target.file.file_type = file_format, + xdm.target.file.directory = folder_uid, + xdm.target.file.filename = attachment_id, + xdm.source.host.device_model = device_name, + xdm.target.resource.id = coalesce(record_uid, node, role_id, team_uid, shared_folder_uid, plan, secret_uid, gateway_uid), + xdm.target.resource.value = value, + xdm.target.resource.type = enforcement, + xdm.target.resource.name = coalesce(email_domain, report_name, name), + xdm.event.outcome = if(lowercase(result_code) contains "fail", XDM_CONST.OUTCOME_FAILED, lowercase(result_code) contains "succ", XDM_CONST.OUTCOME_SUCCESS, null), + xdm.event.outcome_reason = result_code, + xdm.network.application_protocol = protocol; \ No newline at end of file diff --git a/Packs/KeeperSecurity/ModelingRules/KeeperSecurity/KeeperSecurity.yml b/Packs/KeeperSecurity/ModelingRules/KeeperSecurity/KeeperSecurity.yml new file mode 100644 index 000000000000..2e40d2a74dad --- /dev/null +++ b/Packs/KeeperSecurity/ModelingRules/KeeperSecurity/KeeperSecurity.yml @@ -0,0 +1,6 @@ +fromversion: 8.5.0 +id: Keeper_Security_ModelingRule +name: Keeper Security Modeling Rule +rules: '' +schema: '' +tags: '' \ No newline at end of file diff --git a/Packs/KeeperSecurity/ModelingRules/KeeperSecurity/KeeperSecurity_schema.json b/Packs/KeeperSecurity/ModelingRules/KeeperSecurity/KeeperSecurity_schema.json new file mode 100644 index 000000000000..2cc30b387a10 --- /dev/null +++ b/Packs/KeeperSecurity/ModelingRules/KeeperSecurity/KeeperSecurity_schema.json @@ -0,0 +1,120 @@ +{ + "keeper_security_raw": { + "audit_event_type": { + "type": "string", + "is_array": false + }, + "id": { + "type": "int", + "is_array": false + }, + "ip_address": { + "type": "string", + "is_array": false + }, + "username": { + "type": "string", + "is_array": false + }, + "node_id": { + "type": "int", + "is_array": false + }, + "from_username": { + "type": "string", + "is_array": false + }, + "email": { + "type": "string", + "is_array": false + }, + "to_username": { + "type": "string", + "is_array": false + }, + "recipient": { + "type": "string", + "is_array": false + }, + "keeper_version": { + "type": "string", + "is_array": false + }, + "file_format": { + "type": "string", + "is_array": false + }, + "folder_uid": { + "type": "string", + "is_array": false + }, + "attachment_id": { + "type": "string", + "is_array": false + }, + "device_name": { + "type": "string", + "is_array": false + }, + "record_uid": { + "type": "string", + "is_array": false + }, + "node": { + "type": "string", + "is_array": false + }, + "role_id": { + "type": "string", + "is_array": false + }, + "team_uid": { + "type": "string", + "is_array": false + }, + "shared_folder_uid": { + "type": "string", + "is_array": false + }, + "plan": { + "type": "string", + "is_array": false + }, + "gateway_uid": { + "type": "string", + "is_array": false + }, + "value": { + "type": "string", + "is_array": false + }, + "enforcement": { + "type": "string", + "is_array": false + }, + "email_domain": { + "type": "string", + "is_array": false + }, + "secret_uid": { + "type": "string", + "is_array": false + }, + "report_name": { + "type": "string", + "is_array": false + }, + "name": { + "type": "string", + "is_array": false + }, + "result_code": { + "type": "string", + "is_array": false + }, + "protocol": { + "type": "string", + "is_array": false + } + } + } \ No newline at end of file diff --git a/Packs/KeeperSecurity/README.md b/Packs/KeeperSecurity/README.md new file mode 100644 index 000000000000..0a30b20423c8 --- /dev/null +++ b/Packs/KeeperSecurity/README.md @@ -0,0 +1,10 @@ +<~XSIAM> + +This pack provides access to Keeper Security Admin Console that is used to track and manage multiple Keeper Security products. + +## What does this pack do? + +- Fetches audit logs from Keeper Security Admin Console as XSIAM events. +- Log Normalization - XDM mapping for key event types. + + \ No newline at end of file diff --git a/Packs/KeeperSecurity/pack_metadata.json b/Packs/KeeperSecurity/pack_metadata.json new file mode 100644 index 000000000000..f32b09ccd874 --- /dev/null +++ b/Packs/KeeperSecurity/pack_metadata.json @@ -0,0 +1,19 @@ +{ + "name": "Keeper Security", + "description": "Use Keeper Security to manage and extract data regarding your Keeper Security products.", + "support": "xsoar", + "currentVersion": "1.0.0", + "author": "Cortex XSOAR", + "url": "https://www.paloaltonetworks.com/cortex", + "email": "", + "categories": [ + "Authentication & Identity Management" + ], + "tags": ["Security"], + "useCases": [], + "keywords": ["keeper", "security", "secret"], + "marketplaces": [ + "xsoar", + "marketplacev2" + ] +} \ No newline at end of file