diff --git a/Packs/OnePassword/.pack-ignore b/Packs/OnePassword/.pack-ignore new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/Packs/OnePassword/.secrets-ignore b/Packs/OnePassword/.secrets-ignore new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/Packs/OnePassword/Author_image.png b/Packs/OnePassword/Author_image.png new file mode 100644 index 000000000000..57258f9f8764 Binary files /dev/null and b/Packs/OnePassword/Author_image.png differ diff --git a/Packs/OnePassword/Integrations/OnePassword/OnePassword.py b/Packs/OnePassword/Integrations/OnePassword/OnePassword.py new file mode 100644 index 000000000000..3f137b8b0f36 --- /dev/null +++ b/Packs/OnePassword/Integrations/OnePassword/OnePassword.py @@ -0,0 +1,407 @@ +import demistomock as demisto # noqa: F401 +from CommonServerPython import * # noqa: F401 +from CommonServerUserPython import * # noqa + +from http import HTTPStatus +from operator import itemgetter +from requests.structures import CaseInsensitiveDict +from datetime import datetime, timedelta, UTC + + +''' CONSTANTS ''' + +EVENT_DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ' # For XSIAM events - second precision +FILTER_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' # For 1Password date filter - microsecond precision +VENDOR = '1Password' +PRODUCT = '1Password' + +# Default results per page (optimal value) +# > 1000 causes HTTP 400 [Bad Request] +# < 1000 increases the number of requests and may eventually trigger HTTP 429 [Rate Limit] +DEFAULT_RESULTS_PER_PAGE = 1000 + +DEFAULT_MAX_EVENTS_PER_FETCH = 100 # Consistent with API default + +DEFAULT_FETCH_FROM_DATE = datetime.now(tz=UTC) - timedelta(minutes=1) # 1 minute ago in UTC timezone + +EVENT_TYPE_FEATURE = CaseInsensitiveDict( + { + # Display name: API Feature and endpoint name + 'Item usage actions': 'itemusages', + 'Audit events': 'auditevents', + 'Sign in attempts': 'signinattempts', + } +) + +EVENT_TYPE_LIMIT_PARAM = CaseInsensitiveDict( + { + # Display name: Max events per fetch (limit) param name + 'Item usage actions': 'item_usage_actions_limit', + 'Audit events': 'audit_events_limit', + 'Sign in attempts': 'sign_in_attempts_limit', + } +) + + +''' CLIENT CLASS ''' + + +class Client(BaseClient): + """Client class to interact with the 1Password Events API""" + + def get_events(self, event_feature: str, body: dict[str, Any]) -> dict[str, Any]: + """Gets events from 1Password based on the specified event type + + Args: + feature (str): 1Password event feature (e.g. 'itemusages', 'signinattempts'). + body (dict): The request body containing either a Reset Cursor (date filter) or a Pagination Cursor. + + Raises: + DemistoException: If API responds with an HTTP error status code. + + Returns: + dict: The response JSON from the event endpoint. + """ + demisto.debug(f'Requesting events of feature: {event_feature} using request body: {body}') + return self._http_request(method='POST', url_suffix=f'/api/v2/{event_feature}', json_data=body, raise_on_status=True) + + +''' HELPER FUNCTIONS ''' + + +def get_limit_param_for_event_type(params: dict[str, str], event_type: str) -> int: + """Gets the limit parameter for a given event type. The limit represents the maximum number of events per fetch. + + Args: + params (dict): The instance configuration parameters. + event_type (str): Type of 1Password event (e.g. 'Item usage actions', 'Sign in attempts'). + + Returns: + int: The maximum number of events per fetch. + """ + param_name = EVENT_TYPE_LIMIT_PARAM[event_type] + limit = arg_to_number(params.get(param_name)) or DEFAULT_MAX_EVENTS_PER_FETCH + demisto.debug(f'Maximum number of events per fetch for {event_type} is set to {limit}.') + return limit + + +def create_get_events_request_body( + from_date: datetime | None = None, + results_per_page: int = DEFAULT_RESULTS_PER_PAGE, + pagination_cursor: str | None = None, +) -> dict[str, Any]: + """Creates the request body for the `Client.get_events` method. + + Args: + event_type (str): Type of 1Password event (e.g. 'Item usage actions', 'Sign in attempts'). + from_date (datetime | None): Optional datetime from which to get events. + results_per_page (int): The maximum number of records in response (Recommended to use default value). + + Raises: + ValueError: If missing cursor and from date. + + Returns: + dict[str, Any]: The request body. + """ + if pagination_cursor: + return {'cursor': pagination_cursor} + + if from_date: + formatted_from_date: str = from_date.strftime(FILTER_DATE_FORMAT) + return {'limit': results_per_page, 'start_time': formatted_from_date} + + raise ValueError("Either a 'pagination_cursor' or a 'from_date' need to be specified.") + + +def add_fields_to_event(event: dict[str, Any], event_type: str): + """Sets the '_time', 'SOURCE_LOG_TYPE', and 'timestamp_ms' fields in the event dictionary. + + Args: + event (dict): Event dictionary with the new fields. + event_type (str): Type of 1Password event (e.g. 'Item usage actions', 'Sign in attempts'). + """ + event_time = arg_to_datetime(event['timestamp'], required=True) + # Required by XSIAM + event['SOURCE_LOG_TYPE'] = event_type.upper() + event['_time'] = event_time.strftime(EVENT_DATE_FORMAT) # type: ignore[union-attr] + # Matches precision of date filter - ensures correct and accurate list of already fetched IDs + event['timestamp_ms'] = event_time.strftime(FILTER_DATE_FORMAT) # type: ignore[union-attr] + + +def get_events_from_client( + client: Client, + event_type: str, + from_date: datetime, + max_events: int, + already_fetched_ids_to_skip: set[str] | None = None +) -> list[dict]: + """Gets events of the specified type based on the `from_date` filter and `max_events` argument using cursor-based pagination. + + The first API call in each fetch run uses a "Reset Cursor" (date filter). Subsequent API calls in the same fetch run use + "Pagination / Continuing Cursor". This ensures that even if we fetch part of the records on a given page and stop in the + middle because we reached `max_events`, we can continue exactly where we stopped in the next fetch run via the Reset Cursor. + + Args: + client (Client): 1Password Events API client. + event_type (str): Type of 1Password event (e.g. 'Item usage actions', 'Sign in attempts'). + from_date (datetime): Datetime from which to get events. + max_events (int): Maximum number of events to fetch. + already_fetched_ids_to_skip (set | None): Optional set of already-fetched event UUIDs that should be skipped. + + Raises: + ValueError: If invalid event type or request body. + DemistoException: If API request failed. + + Returns: + list[dict]: List of events. + """ + event_feature = EVENT_TYPE_FEATURE.get(event_type) + if not event_feature: + raise ValueError(f'Invalid or unsupported {VENDOR} event type: {event_type}.') + + events: list[dict] = [] + already_fetched_ids_to_skip = already_fetched_ids_to_skip or set() + + # Reset Cursor - First call to the paginated API needs to to include a date filter + has_more_events = True + request_body = create_get_events_request_body(from_date=from_date) + + while has_more_events: + response_events: list[dict] = [] + response_skipped_ids: set[str] = set() + response = client.get_events(event_feature, body=request_body) + pagination_cursor = response['cursor'] + + for event in response['items']: + event_id = event['uuid'] + + if event_id in already_fetched_ids_to_skip: + response_skipped_ids.add(event_id) + demisto.debug(f'Skipped duplicate event with ID: {event_id}') + continue + + if len(events) == max_events: + demisto.debug(f'Reached maximum number of events {max_events}. Last event ID: {event_id}') + break + + add_fields_to_event(event, event_type) + response_events.append(event) + already_fetched_ids_to_skip.add(event_id) + + demisto.debug( + f'Response has {len(response["items"])} events of type {event_type}. Saved {len(response_events)} events. ' + f'Skipped {len(response_skipped_ids)} duplicates: ({", ".join(response_skipped_ids)}). ' + f'Got pagination cursor: {pagination_cursor}. Used request body: {request_body}.' + ) + events.extend(response_events) + + # Pagination / Continuing cursor - Followup API calls need to the unique page ID (if any more events) + has_more_events = False if len(events) == max_events else response['has_more'] + request_body = create_get_events_request_body(pagination_cursor=pagination_cursor) + + return events + + +def push_events(events: list[dict]) -> None: + """Sends events to the relevant `VENDOR` and `PRODUCT` dataset in XSIAM. + + Args: + events (list): List of event dictionaries. + """ + demisto.debug(f'Starting to send {len(events)} events to XSIAM.') + send_events_to_xsiam(events=events, vendor=VENDOR, product=PRODUCT) + + +def set_next_run(next_run: dict[str, Any]) -> None: + """Sets next run for all event features. Should be called after events are successfully sent to XSIAM. + + Args: + next_run (dict): Next run dictionary containing `from_date` in `FILTER_DATE_FORMAT` and `ids` list per event feature. + + Example: + >>> set_next_run({"auditevents": {"from_date": "2024-12-02T11:54:19.710457Z", "ids": []}, "itemusages": ...}) + """ + demisto.debug(f'Setting next run to {next_run}.') + demisto.setLastRun(next_run) + + +''' COMMAND FUNCTIONS ''' + + +def fetch_events( + client: Client, + event_type: str, + event_type_last_run: dict[str, Any], + event_type_max_results: int, +) -> tuple[dict, list]: + """Fetches new events via 1Password Events API client based on a specified event type. + + Args: + client (Client): 1Password Events API client. + event_type (str): Type of 1Password event (e.g. 'Item usage actions', 'Sign in attempts'). + event_type_last_run (dict): Dictionary of the event type last run (if exists) with optional 'from_date' and 'ids' list. + event_type_max_results (int): Maximum number of events of the event type to fetch. + + Returns: + tuple[dict, list]: Dictionary of the next run of the event type with 'from_date' and 'ids' list, list of fetched events. + """ + last_run_ids_to_skip = set(event_type_last_run.get('ids') or []) + from_date = arg_to_datetime(event_type_last_run.get('from_date')) or DEFAULT_FETCH_FROM_DATE + + demisto.debug(f'Fetching events of type: {event_type} from date: {from_date.strftime(FILTER_DATE_FORMAT)}') + + event_type_events = get_events_from_client( + client=client, + event_type=event_type, + from_date=from_date, + max_events=event_type_max_results, + already_fetched_ids_to_skip=last_run_ids_to_skip, + ) + + if event_type_events: + # Use event 'timestamp_ms' since it is consistent with FILTER_DATE_FORMAT + last_event_time = max(event_type_events, key=itemgetter('timestamp_ms'))['timestamp_ms'] + next_run_ids_to_skip = {event['uuid'] for event in event_type_events if event['timestamp_ms'] == last_event_time} + event_type_next_run = {'from_date': last_event_time, 'ids': list(next_run_ids_to_skip)} + else: + last_event_time = None + event_type_next_run = {'from_date': from_date.strftime(FILTER_DATE_FORMAT), 'ids': list(last_run_ids_to_skip)} + + demisto.debug( + f'Fetched {len(event_type_events)} events of type: {event_type} out of a maximum of {event_type_max_results}. ' + f'Last event time: {last_event_time}.' + ) + + return event_type_next_run, event_type_events + + +def test_module_command(client: Client, event_types: list[str]) -> str: + """Tests connectivity and authentication with 1Password Events API and verifies that the token has access to the + configured event types. + + Args: + client (Client): 1Password Events API client. + event_types (list): List of event types from the integration configuration params. + + Returns: + str: 'ok' if test passed, anything else will fail the test. + + Raises: + DemistoException: If API call responds with an unhandled HTTP error status code or token does not have access to the + configured event types. + """ + for event_type in event_types: + try: + get_events_from_client(client, event_type=event_type, from_date=DEFAULT_FETCH_FROM_DATE, max_events=1) + + except DemistoException as e: + error_status_code = e.res.status_code if isinstance(e.res, requests.Response) else None + + if error_status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): + return 'Authorization Error: Make sure the API server URL and token are correctly set' + + if error_status_code == HTTPStatus.NOT_FOUND: + return 'Endpoint Not Found: Make sure the API server URL is correctly set' + + # Some other unknown / unexpected error + raise + + return 'ok' + + +def get_events_command(client: Client, args: dict[str, str]) -> tuple[list[dict], CommandResults]: + """Implements `one-password-get-events` command, which returns a markdown table of events to the war room. + + Args: + client (Client): 1Password Events API client. + args (dict): The '1password-get-events' command arguments. + + Returns: + tuple[list[dict], CommandResults]: List of events and CommandResults with human readable output. + """ + event_type = args['event_type'] + limit = arg_to_number(args.get('limit')) or DEFAULT_MAX_EVENTS_PER_FETCH + from_date = arg_to_datetime(args.get('from_date')) or DEFAULT_FETCH_FROM_DATE + + events = get_events_from_client(client, event_type=event_type, from_date=from_date, max_events=limit) + + human_readable = tableToMarkdown(name=event_type.capitalize(), t=flattenTable(events)) + + return events, CommandResults(readable_output=human_readable) + + +''' MAIN FUNCTION ''' + + +def main() -> None: # pragma: no cover + command = demisto.command() + params = demisto.params() + args = demisto.args() + + # required + base_url: str = params['url'] + token: str = params.get('credentials', {}).get('password', '') + event_types: list[str] = argToList(params['event_types'], transform=lambda event_type: event_type.strip()) + + # optional + verify_certificate: bool = not params.get('insecure', False) + proxy: bool = params.get('proxy', False) + + demisto.debug(f'Command being called is {command!r}') + try: + client = Client( + base_url=base_url, + verify=verify_certificate, + headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, + proxy=proxy, + ) + + if command == 'test-module': + result = test_module_command(client, event_types) + return_results(result) + + elif command == 'one-password-get-events': + should_push_events = argToBoolean(args.pop('should_push_events')) + + events, results = get_events_command(client, args) + return_results(results) + + if should_push_events: + push_events(events) + + elif command == 'fetch-events': + all_events: list[dict] = [] + last_run = demisto.getLastRun() + next_run: dict[str, Any] = {} + + for event_type in event_types: + event_type_key = EVENT_TYPE_FEATURE[event_type] + + event_type_last_run: dict = last_run.get(event_type_key, {}) + event_type_max_results: int = get_limit_param_for_event_type(params, event_type) + + event_type_next_run, event_type_events = fetch_events( + client=client, + event_type=event_type, + event_type_last_run=event_type_last_run, + event_type_max_results=event_type_max_results, + ) + all_events.extend(event_type_events) + next_run[event_type_key] = event_type_next_run + + push_events(all_events) + set_next_run(next_run) + + else: + raise NotImplementedError(f'Unknown command {command!r}') + + # Log exceptions and return errors + except Exception as e: + return_error(f'Failed to execute {command!r} command.\nError:\n{str(e)}') + + +''' ENTRY POINT ''' + + +if __name__ in ('__main__', '__builtin__', 'builtins'): + main() diff --git a/Packs/OnePassword/Integrations/OnePassword/OnePassword.yml b/Packs/OnePassword/Integrations/OnePassword/OnePassword.yml new file mode 100644 index 000000000000..bafd490b34d0 --- /dev/null +++ b/Packs/OnePassword/Integrations/OnePassword/OnePassword.yml @@ -0,0 +1,109 @@ +category: Analytics & SIEM +commonfields: + id: OnePassword + version: -1 +configuration: +- defaultvalue: https://events.1password.com + display: Server URL + additionalinfo: The API server URL depends on the domain where the account is hosted. Refer to the integration Help section for more details. + name: url + required: true + type: 0 + section: Connect +- displaypassword: API Token + additionalinfo: The bearer token used to authenticate with the 1Password Events API. This must include the required features (scopes) that correspond to the event types to be fetched. + name: credentials + required: true + hiddenusername: true + type: 9 + section: Connect +- display: Trust any certificate (not secure) + additionalinfo: Allow connections without verifying the SSL certificate of the server. + name: insecure + type: 8 + required: false + section: Connect + advanced: true +- display: Use system proxy settings + name: proxy + type: 8 + required: false + section: Connect + advanced: true +- defaultvalue: Audit events,Item usage actions,Sign in attempts + display: Types of events to fetch + name: event_types + type: 16 + options: + - Audit events + - Item usage actions + - Sign in attempts + required: true + section: Collect +- defaultvalue: 5000 + additionalinfo: If not specified, API default (100) will be used. + display: Maximum number of audit events per fetch + name: audit_events_limit + type: 0 + required: false + section: Collect + advanced: true +- defaultvalue: 5000 + additionalinfo: If not specified, API default (100) will be used. + display: Maximum number of item usage actions per fetch + name: item_usage_actions_limit + type: 0 + required: false + section: Collect + advanced: true +- defaultvalue: 5000 + additionalinfo: If not specified, API default (100) will be used. + display: Maximum number of sign-in attempts per fetch + name: sign_in_attempts_limit + required: false + type: 0 + section: Collect + advanced: true +description: 'Fetch events about actions performed by 1Password users within a specific account, access and modifications to items in shared vaults, and user sign-in attempts.' +display: 1Password +name: OnePassword +script: + commands: + - arguments: + - description: 'The maximum number of events to fetch for the given event type.' + name: limit + required: false + defaultValue: 1000 + - auto: PREDEFINED + defaultValue: 'false' + description: Set this argument to True in order to push events to Cortex XSIAM, otherwise the command will only display them. + name: should_push_events + predefined: + - 'True' + - 'False' + required: true + - auto: PREDEFINED + description: 1Password event type. + name: event_type + predefined: + - Audit events + - Item usage actions + - Sign in attempts + required: true + - default: false + description: The date from which to get events. If not specified, events from the last minute will be fetched. + name: from_date + required: false + description: Fetch events from 1Password. This command is intended for development and debugging purposes and should be used with caution as it may create duplicate events. + name: one-password-get-events + isfetchevents: true + runonce: false + script: '-' + type: python + subtype: python3 + dockerimage: demisto/python3:3.11.10.115186 +marketplaces: +- marketplacev2 +fromversion: 8.4.0 +tests: +- No tests (auto formatted) diff --git a/Packs/OnePassword/Integrations/OnePassword/OnePassword_description.md b/Packs/OnePassword/Integrations/OnePassword/OnePassword_description.md new file mode 100644 index 000000000000..c7de70876f59 --- /dev/null +++ b/Packs/OnePassword/Integrations/OnePassword/OnePassword_description.md @@ -0,0 +1,33 @@ +## 1Password + +### How to get the configuration parameters + +#### Server URL + +The API server URL depends on the region (domain) where the account is hosted and the pricing plan. + +| **Domain** | **Plan** | **API Server URL** | +| --- | --- | --- | +| 1Password.com | Business | https://events.1password.com | +| 1Password.com | Enterprise | https://events.ent.1password.com | +| 1Password.ca | Any | https://events.1password.ca | +| 1Password.eu | Any | https://events.1password.eu | +| {sub}.{domain}.com | Any | https://events.{domain}.com | + +#### API Token + +Every call to the 1Password Events API must be authorized with a bearer token. To issue a new bearer token: + +1. Sign in to your 1Password account and click **Integrations** in the sidebar. +2. Under the **Directory** tab, choose **(•••) Other** and enter a descriptive name for the integration, such as 'Cortex XSIAM'. +3. Enter a name for the bearer token and choose when it will expire. +4. Ensure the token has access to the event types: + * Audit events (`auditevents` feature) + * Item usage actions (`itemusages` feature) + * Sign-in attempts (`signinattempts` feature) +5. Click **Issue Token** to generate a new bearer token. +6. Save the token in a secure location and use it in configuring this integration instance. + +#### Maximum Number of Events per Fetch + +It is recommended to configure the integration instance so that the maximum number of fetched events does not exceed **100,000 per minute per event type**. Otherwise, the 1Password Events API may raise rate limit errors (HTTP 429). diff --git a/Packs/OnePassword/Integrations/OnePassword/OnePassword_image.png b/Packs/OnePassword/Integrations/OnePassword/OnePassword_image.png new file mode 100644 index 000000000000..57258f9f8764 Binary files /dev/null and b/Packs/OnePassword/Integrations/OnePassword/OnePassword_image.png differ diff --git a/Packs/OnePassword/Integrations/OnePassword/OnePassword_test.py b/Packs/OnePassword/Integrations/OnePassword/OnePassword_test.py new file mode 100644 index 000000000000..b90067a54b06 --- /dev/null +++ b/Packs/OnePassword/Integrations/OnePassword/OnePassword_test.py @@ -0,0 +1,356 @@ +import json +from datetime import datetime + + +import pytest +from pytest_mock import MockerFixture +from requests_mock import Mocker as RequestsMock +import dateparser + +from OnePassword import Client + + +BASE_URL = 'http://example.com' +HEADERS = {'Authorization': 'Bearer MY-TOKEN-123', 'Content-Type': 'application/json'} + + +def util_load_json(path: str): + """Loads the contents of a JSON file with the given path. + + Args: + path (str): Path to JSON file. + + Returns: + Decoded JSON file contents. + """ + with open(path, encoding='utf-8') as f: + return json.loads(f.read()) + + +@pytest.fixture +def authenticated_client() -> Client: + """Fixture to create a OnePassword.Client instance""" + return Client(base_url=BASE_URL, verify=False, proxy=False, headers=HEADERS) + + +def mock_client_get_events(event_feature: str, body: dict): + if body.get('start_time'): + # First iteration (from_date filter used) + # response = {'has_more': True, 'cursor': 'qwerty4567', ... } + response = util_load_json(f'test_data/{event_feature}_response_1.json') + else: + # Second and final iteration (pagination cursor from first response used) + # response = {'has_more': False, ... } + response = util_load_json(f'test_data/{event_feature}_response_2.json') + + return response + + +def test_get_limit_param_for_event_type(): + """ + Given: + - Integration instance Configuration params containing 'limit' values. + + When: + - Calling get_limit_param_for_event_type. + + Assert: + - Ensure the 'limit' value of the specified event type is as expected. + """ + from OnePassword import get_limit_param_for_event_type + + expected_audit_events_limit = '500' + expected_sign_in_attempts_limit = '4000' + params = { + 'audit_events_limit': expected_audit_events_limit, + 'sign_in_attempts_limit': expected_sign_in_attempts_limit, + } + + audit_events_limit = get_limit_param_for_event_type(params, event_type='audit events') + sign_in_attempts_limit = get_limit_param_for_event_type(params, event_type='sign in attempts') + + assert audit_events_limit == int(expected_audit_events_limit) + assert sign_in_attempts_limit == int(expected_sign_in_attempts_limit) + + +def test_add_fields_event(): + """ + Given: + - A raw 1Password event of type 'audit event'. + + When: + - Calling add_fields_to_event. + + Assert: + - Ensure the '_time' and 'SOURCE_LOG_TYPE' fields are added and correctly set. + """ + from OnePassword import add_fields_to_event, arg_to_datetime, EVENT_DATE_FORMAT, FILTER_DATE_FORMAT + + event_timestamp = '2024-12-02T11:54:19.710457472Z' + + event_type = 'audit event' + raw_event = { + 'uuid': '12345', + 'timestamp': event_timestamp, + 'action': 'create', + 'object_type': 'device', + } + add_fields_to_event(raw_event, event_type) + + assert raw_event['_time'] == arg_to_datetime(event_timestamp).strftime(EVENT_DATE_FORMAT) + assert raw_event['timestamp_ms'] == arg_to_datetime(event_timestamp).strftime(FILTER_DATE_FORMAT) + assert raw_event['SOURCE_LOG_TYPE'] == event_type.upper() + + +def test_create_get_events_request_body_invalid_inputs(): + """ + Given: + - Missing pagination cursor and from date. + + When: + - Calling create_get_events_request_body. + + Assert: + - Ensure a ValueError is raised with the appropriate error message. + """ + from OnePassword import create_get_events_request_body + + with pytest.raises(ValueError, match="Either a 'pagination_cursor' or a 'from_date' need to be specified."): + create_get_events_request_body() + + +@pytest.mark.parametrize( + 'from_date, pagination_cursor, expected_request_body', + [ + pytest.param( + datetime(2024, 12, 2, 11, 50), + None, + {'limit': 1000, 'start_time': '2024-12-02T11:50:00.000000Z'}, + id='Reset cursor (date filter)', + ), + pytest.param( + None, + 'PAGE123', + {'cursor': 'PAGE123'}, + id='Pagination cursor', + ) + ] +) +def test_create_get_events_request_body_valid_inputs( + from_date: datetime | None, + pagination_cursor: str | None, + expected_request_body: dict, +): + """ + Given: + - A from date or a pagination cursor. + + When: + - Calling create_get_events_request_body. + + Assert: + - Ensure the request body is as expected. + """ + from OnePassword import create_get_events_request_body + + request_body = create_get_events_request_body(from_date=from_date, pagination_cursor=pagination_cursor) + + assert request_body == expected_request_body + + +def test_client_get_events(authenticated_client: Client, mocker: MockerFixture): + """ + Given: + - A OnePassword.Client instance with valid inputs to the get_events method. + + When: + - Calling Client.get_events. + + Assert: + - Ensure no exception is raised and the raw API response is as expected. + """ + + event_feature = 'signinattempts' + request_body = {'cursor': '12345'} + + client_http_request = mocker.patch.object(authenticated_client, '_http_request') + + authenticated_client.get_events(event_feature, request_body) + + client_http_request_kwargs = client_http_request.call_args.kwargs + + assert client_http_request_kwargs['method'] == 'POST' + assert client_http_request_kwargs['url_suffix'] == '/api/v2/signinattempts' + assert client_http_request_kwargs['json_data'] == request_body + assert client_http_request_kwargs['raise_on_status'] is True + + +def test_get_events_from_client(authenticated_client: Client, mocker: MockerFixture): + """ + Given: + - A 1Password event type, from date, and the maximum number of events. + + When: + - Calling get_events_from_client (which calls Client.get_events). + + Assert: + - Ensure Client.get_events is called twice (because first response['has_more'] is True). + - Ensure the number of events does not exceed the specified maximum and the events are as expected. + """ + from OnePassword import get_events_from_client + + event_type = 'audit events' + from_date = datetime(2024, 12, 2, 11, 50) + max_events = 3 + + client_get_events = mocker.patch.object(authenticated_client, 'get_events', side_effect=mock_client_get_events) + + events = get_events_from_client(authenticated_client, event_type=event_type, from_date=from_date, max_events=max_events) + + expected_events = util_load_json('test_data/auditevents_expected_events.json') + + assert client_get_events.call_count == 2 + assert events == expected_events + + +def test_push_events(mocker: MockerFixture): + """ + Given: + - A list of 1Password events of type 'audit events'. + + When: + - Calling push_events. + + Assert: + - Ensure send_events_to_xsiam is called once with the correct inputs. + """ + from OnePassword import push_events, VENDOR as EXPECTED_VENDOR, PRODUCT as EXPECTED_PRODUCT + + send_events_to_xsiam = mocker.patch('OnePassword.send_events_to_xsiam') + + expected_events = util_load_json('test_data/auditevents_expected_events.json') + push_events(expected_events) + + send_events_to_xsiam_kwargs = send_events_to_xsiam.call_args.kwargs + + assert send_events_to_xsiam.call_count == 1 + assert send_events_to_xsiam_kwargs['events'] == expected_events + assert send_events_to_xsiam_kwargs['vendor'] == EXPECTED_VENDOR + assert send_events_to_xsiam_kwargs['product'] == EXPECTED_PRODUCT + + +def test_set_next_run(mocker: MockerFixture): + """ + Given: + - A next run dictionary for 1Password event feature 'auditevents'. + + When: + - Calling set_next_run. + + Assert: + - Ensure demisto.setLastRun is called once with the correct inputs. + """ + from OnePassword import set_next_run + + demisto_set_last_run = mocker.patch('OnePassword.demisto.setLastRun') + + next_run = {'auditevents': {'from_date': '2024-12-02T11:55:20.710457Z', 'ids': ['second (and last) event']}} + set_next_run(next_run) + + assert demisto_set_last_run.call_count == 1 + assert demisto_set_last_run.call_args[0][0] == next_run + + +def test_fetch_events(authenticated_client: Client, mocker: MockerFixture): + """ + Given: + - A 1Password event type, first fetch date, and the maximum number of events per fetch. + + When: + - Calling fetch_events (which calls get_events_from_client). + + Assert: + - Ensure correct inputs to get_events_from_client. + - Ensure correct fetch_events outputs (last_run, events). + """ + from OnePassword import fetch_events + + # Inputs + event_type = 'audit events' + from_date = '2024-12-02T11:54:11Z' + + # Expected outputs + expected_type_next_run = {'from_date': '2024-12-02T11:55:20.710457Z', 'ids': ['second (and last) event']} + expected_events = util_load_json('test_data/auditevents_expected_events.json') + + get_events_from_client = mocker.patch('OnePassword.get_events_from_client', return_value=expected_events) + type_next_run, events = fetch_events( + authenticated_client, + event_type=event_type, + event_type_last_run={'from_date': from_date, 'ids': []}, + event_type_max_results=1000, + ) + + get_events_from_client_kwargs = get_events_from_client.call_args.kwargs + + # Assert correct inputs + assert get_events_from_client_kwargs['event_type'] == event_type + assert get_events_from_client_kwargs['from_date'] == dateparser.parse(from_date) + + # Assert correct outputs + assert type_next_run == expected_type_next_run + assert events == expected_events + + +def test_test_module_command(authenticated_client: Client, requests_mock: RequestsMock): + """ + Given: + - A list of 1Password event types and a OnePassword.Client instance. + + When: + - Calling test_module_command (which calls Client.get_events). + + Assert: + - Ensure client errors are gracefully handled and the correct error message appears. + """ + from OnePassword import test_module_command, urljoin + + event_types = ['Sign in attempts'] + + event_url = urljoin(BASE_URL, 'api/v2/signinattempts') + mock_response = {"Error": {"Message": "Unauthorized"}} + requests_mock.post(event_url, json=mock_response, status_code=401) + + result = test_module_command(authenticated_client, event_types) + + assert result == 'Authorization Error: Make sure the API server URL and token are correctly set' + + +def test_get_events_command(authenticated_client: Client, mocker: MockerFixture): + """ + Given: + - A 1Password event type, from date, and the maximum number of events (limit). + + When: + - Calling get_events_command (which calls get_events_from_client and tableToMarkdown). + + Assert: + - Ensure correct inputs to tableToMarkdown. + - Ensure correct get_events_command outputs. + """ + from OnePassword import get_events_command, flattenTable + + expected_events = util_load_json('test_data/auditevents_expected_events.json') + + mocker.patch('OnePassword.get_events_from_client', return_value=expected_events) + table_to_markdown = mocker.patch('OnePassword.tableToMarkdown') + event_type = 'audit events' + args = {'event_type': event_type, 'limit': '10', 'from_date': '2024-12-02T11:55:00Z'} + + events, _ = get_events_command(authenticated_client, args) + table_to_markdown_kwargs = table_to_markdown.call_args.kwargs + + assert table_to_markdown_kwargs['name'] == event_type.capitalize() + assert table_to_markdown_kwargs['t'] == flattenTable(expected_events) + + assert events == expected_events diff --git a/Packs/OnePassword/Integrations/OnePassword/README.md b/Packs/OnePassword/Integrations/OnePassword/README.md new file mode 100644 index 000000000000..d49996a8eaa3 --- /dev/null +++ b/Packs/OnePassword/Integrations/OnePassword/README.md @@ -0,0 +1,80 @@ +# 1Password + +Fetch events about actions performed by 1Password users within a specific account, access and modifications to items in shared vaults, and user sign-in attempts. + +This integration was integrated and tested with V2 endpoints of the 1Password Events API. + +## Configure 1Password in Cortex + +The integration can be configured to fetch three types of events from 1Password: + +- **Audit events** - Information about actions performed by team members within a 1Password account. Events include details on a preformed action such as execution time and executing user, action type and any additional information about the activity. +- **Item usage actions** - Information about items in shared vaults that have been modified, accessed, or used. Events include the accessing user's name and IP address, time of access, and the vault where the item is stored. +- **Sign-in attempts** - Information about sign-in attempts. Events include the name and IP address of the user who attempted to sign in to the account, when the attempt was made, and, for failed attempts, the cause of the failure. + +All event timestamps, along with date and time configuration parameters and command arguments, are in the Coordinated Universal Time (UTC) timezone. + +Every call to the 1Password Events API must be authorized with a bearer token. To issue a new bearer token: + +1. Sign in to your 1Password account and click **Integrations** in the sidebar. +2. Under the **Directory** tab, choose **(•••) Other** and enter a descriptive name for the integration, such as 'Cortex XSIAM'. +3. Enter a name for the bearer token and choose when it will expire. +4. Ensure the token has access to the event types listed above. +5. Click **Issue Token** to generate a new bearer token. +6. Save the token in a secure location and use it in configuring this integration instance. + +| **Parameter** | **Description** | **Required** | +| --- | --- | --- | +| Server URL | The API server URL depends on the domain where the account is hosted. Refer to the integration Help section for more details. | True | +| API Token | The bearer token used to authenticate with the 1Password Events API. This must include the required features (scopes) that correspond to the event types to be fetched. Refer to the integration Help section for more details. | True | +| Trust any certificate (not secure) | Allow connections without verifying the SSL certificate of the server. | False | +| Use system proxy settings | | False | +| Fetch Events | Whether to fetch events from 1Password. | False | +| Types of events to fetch | Types of events to fetch from 1Password. Possible values are: Audit events, Item usage actions, Sign in attempts. Default value is Audit events, Item usage actions, Sign in attempts. | True | +| Maximum number of audit events per fetch | If not specified, API default (100) will be used. | False | +| Maximum number of item usage actions per fetch | If not specified, API default (100) will be used. | False | +| Maximum number of sign-in attempts per fetch | If not specified, API default (100) will be used. | False | + +## Limitations + +It is recommended to configure the integration instance so that the maximum number of fetched events does not exceed **100,000 per minute per event type**. Otherwise, the 1Password Events API may raise rate limit errors (HTTP 429). + +## Commands + +You can execute these commands from the 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. + +### one-password-get-events + +*** +Fetch events from 1Password. This command is intended for development and debugging purposes and should be used with caution as it may create duplicate events. + +#### Base Command + +`one-password-get-events` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| event_type | 1Password event type. Possible values are: Audit events, Item usage actions, Sign in attempts. | Required | +| limit | The maximum number of events to fetch for the given event type. Default is 1000. | Optional | +| from_date | The date from which to get events. If not specified, events from the last minute will be fetched. | Optional | +| should_push_events | Set this argument to True in order to push events to Cortex XSIAM, otherwise the command will only display them. Possible values are: True, False. Default is False. | Required | + +#### Command Example + +```!one-password-get-events event_type="Sign in attempts" limit=2 from_date="2024-12-12T12:00:00.000Z" should_push_events=False``` + +#### Context Output + +There is no context output for this command. + +#### Human Readable Output + +>### Sign in attempts +> +>| account_uuid | category | client | country | location | session_uuid | target_user | timestamp | type | uuid | +>| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +>| H9T8ZQ6P4F3JY1VK7D2N5L7XQ2W | success | app_name: 1Password Browser Extension
app_version: 81055002
... | UK | country: UK
region: Scotland
city: Glasgow
... | YB7RPKX9V6N2DAG3T0ZQ8YHWY4C | uuid: LJ5T8WK9U8FQGZ2D1Q4V9RLP3S
name: Jenny Bee
email: userB@example.com
type: user | 2024-12-13T19:52:14.658476952Z | credentials_ok | NAFGMYS3LZBCZLX2MVRSWNXIHI | +>| B7N3W5E9Y6JHQ2V1KZ8M4P0QX5T | success | app_name: 1Password for Web
app_version: 1895
... | IL | country: IL
region: Gush Dan
city: Tel Aviv
... | C4XUJWF8N2Y1K9V3WZ7M5E6T0H | uuid: MNY8UJ6PZG5BVW9QH3A1K2X3CK
name: John Doe
email: userA@example.com
type: user | 2024-12-16T13:27:33.375466135Z | credentials_ok | QXZTBRJULQX5ZJWKRFG8YTP8EX | diff --git a/Packs/OnePassword/Integrations/OnePassword/command_examples b/Packs/OnePassword/Integrations/OnePassword/command_examples new file mode 100644 index 000000000000..80c5bc2f4d07 --- /dev/null +++ b/Packs/OnePassword/Integrations/OnePassword/command_examples @@ -0,0 +1 @@ +!one-password-get-events event_type="Sign in attempts" limit=2 from_date="2024-12-12T12:00:00.000Z" should_push_events=False diff --git a/Packs/OnePassword/Integrations/OnePassword/test_data/auditevents_expected_events.json b/Packs/OnePassword/Integrations/OnePassword/test_data/auditevents_expected_events.json new file mode 100644 index 000000000000..4f466f46326f --- /dev/null +++ b/Packs/OnePassword/Integrations/OnePassword/test_data/auditevents_expected_events.json @@ -0,0 +1,56 @@ +[ + { + "uuid": "first event", + "timestamp": "2024-12-02T11:54:19.710457472Z", + "actor_details": { + "name": "Test User", + "email": "userA@example.com" + }, + "action": "create", + "object_type": "device", + "aux_details": { + "name": "Test User", + "email": "userA@example.com" + }, + "session": { + "login_time": "2024-12-02T11:54:19.693369325Z", + "ip": "8.8.8.8" + }, + "location": { + "country": "Israel", + "region": "Tel Aviv", + "city": "Tel Aviv" + }, + "actor_type": "user", + "_time": "2024-12-02T11:54:19Z", + "timestamp_ms": "2024-12-02T11:54:19.710457Z", + "SOURCE_LOG_TYPE": "AUDIT EVENTS" + }, + { + "uuid": "second (and last) event", + "timestamp": "2024-12-02T11:55:20.710457472Z", + "actor_details": { + "name": "Jenny Bee", + "email": "userB@example.com" + }, + "action": "create", + "object_type": "device", + "aux_details": { + "name": "Jenny Bee", + "email": "userB@example.com" + }, + "session": { + "login_time": "2024-12-02T11:55:20.693369325Z", + "ip": "7.7.7.7" + }, + "location": { + "country": "United States", + "region": "California", + "city": "Santa Clara" + }, + "actor_type": "user", + "_time": "2024-12-02T11:55:20Z", + "timestamp_ms": "2024-12-02T11:55:20.710457Z", + "SOURCE_LOG_TYPE": "AUDIT EVENTS" + } +] diff --git a/Packs/OnePassword/Integrations/OnePassword/test_data/auditevents_response_1.json b/Packs/OnePassword/Integrations/OnePassword/test_data/auditevents_response_1.json new file mode 100644 index 000000000000..9a7de54ca3c4 --- /dev/null +++ b/Packs/OnePassword/Integrations/OnePassword/test_data/auditevents_response_1.json @@ -0,0 +1,30 @@ +{ + "cursor": "qwerty4567", + "has_more": true, + "items": [ + { + "uuid": "first event", + "timestamp": "2024-12-02T11:54:19.710457472Z", + "actor_details": { + "name": "Test User", + "email": "userA@example.com" + }, + "action": "create", + "object_type": "device", + "aux_details": { + "name": "Test User", + "email": "userA@example.com" + }, + "session": { + "login_time": "2024-12-02T11:54:19.693369325Z", + "ip": "8.8.8.8" + }, + "location": { + "country": "Israel", + "region": "Tel Aviv", + "city": "Tel Aviv" + }, + "actor_type": "user" + } + ] +} diff --git a/Packs/OnePassword/Integrations/OnePassword/test_data/auditevents_response_2.json b/Packs/OnePassword/Integrations/OnePassword/test_data/auditevents_response_2.json new file mode 100644 index 000000000000..b2b5d27d9ec4 --- /dev/null +++ b/Packs/OnePassword/Integrations/OnePassword/test_data/auditevents_response_2.json @@ -0,0 +1,30 @@ +{ + "cursor": "qwerty9999", + "has_more": false, + "items": [ + { + "uuid": "second (and last) event", + "timestamp": "2024-12-02T11:55:20.710457472Z", + "actor_details": { + "name": "Jenny Bee", + "email": "userB@example.com" + }, + "action": "create", + "object_type": "device", + "aux_details": { + "name": "Jenny Bee", + "email": "userB@example.com" + }, + "session": { + "login_time": "2024-12-02T11:55:20.693369325Z", + "ip": "7.7.7.7" + }, + "location": { + "country": "United States", + "region": "California", + "city": "Santa Clara" + }, + "actor_type": "user" + } + ] +} diff --git a/Packs/OnePassword/Integrations/OnePassword/test_data/signinattempts_response.json b/Packs/OnePassword/Integrations/OnePassword/test_data/signinattempts_response.json new file mode 100644 index 000000000000..22f046f5d612 --- /dev/null +++ b/Packs/OnePassword/Integrations/OnePassword/test_data/signinattempts_response.json @@ -0,0 +1,58 @@ +{ + "cursor": "qwerty4567", + "has_more": false, + "items": [ + { + "uuid": "1234", + "session_uuid": "session-il-1234", + "timestamp": "2024-12-02T11:54:19.716235495Z", + "country": "IL", + "category": "success", + "type": "credentials_ok", + "client": { + "app_name": "1Password for Web", + "app_version": "1883", + "platform_name": "Chrome", + "os_name": "MacOSX", + "os_version": "15.1.1" + }, + "location": { + "country": "IL", + "region": "Gush Dan", + "city": "Tel Aviv" + }, + "target_user": { + "name": "John Doe", + "email": "userA@example.com", + "type": "user" + }, + "account_uuid": "account-aaa" + }, + { + "uuid": "5678", + "session_uuid": "session-uk-5678", + "timestamp": "2024-12-02T11:59:40.510481428Z", + "country": "UK", + "category": "success", + "type": "credentials_ok", + "client": { + "app_name": "1Password for Web", + "app_version": "121", + "platform_name": "Edge", + "os_name": "Windows 11", + "os_version": "24H2" + }, + "location": { + "country": "UK", + "region": "Scotland", + "city": "Glasgow" + }, + "target_user": { + "name": "Foo Bar", + "email": "userB@example.com", + "type": "user" + }, + "account_uuid": "account-bbb" + } + ] +} diff --git a/Packs/OnePassword/README.md b/Packs/OnePassword/README.md new file mode 100644 index 000000000000..df045abcac36 --- /dev/null +++ b/Packs/OnePassword/README.md @@ -0,0 +1,9 @@ +# 1Password + +1Password can be used to store and manage account credentials, financial information, documents, and other sensitive data. It provides secure password generation, quick form filling, and cross-device synchronization. It also offers features like secure password sharing and monitoring for compromised accounts. + +## What does this pack do? + +Fetch events about actions performed by 1Password users within a specific account, access and modifications to items in shared vaults, and user sign-in attempts. Events from Managed Service Provider (MSP) accounts include additional details about the users performing the actions. + +For more information on fetched events, refer to the 1Password integration documentation. diff --git a/Packs/OnePassword/pack_metadata.json b/Packs/OnePassword/pack_metadata.json new file mode 100644 index 000000000000..f93bdbb6ee8d --- /dev/null +++ b/Packs/OnePassword/pack_metadata.json @@ -0,0 +1,18 @@ +{ + "name": "1Password", + "description": "1Password is a password manager that you can use to store and manage your account credentials, financial information, documents, and other sensitive data. It provides secure password generation, quick form filling, and cross-device synchronization. It also offers features like secure password sharing and monitoring for compromised accounts.", + "support": "xsoar", + "currentVersion": "1.0.0", + "author": "Cortex XSOAR", + "url": "https://www.paloaltonetworks.com/cortex", + "email": "", + "categories": [ + "Analytics & SIEM" + ], + "tags": [], + "useCases": [], + "keywords": [], + "marketplaces": [ + "marketplacev2" + ] +} diff --git a/Tests/docker_native_image_config.json b/Tests/docker_native_image_config.json index 32b9c4ab92c2..02ccbffb2985 100644 --- a/Tests/docker_native_image_config.json +++ b/Tests/docker_native_image_config.json @@ -372,6 +372,13 @@ "ignored_native_images": [ "native:8.6" ] + }, + { + "id": "OnePassword", + "reason": "CIAC-12024, This integration support only from python 3.11", + "ignored_native_images": [ + "native:8.6" + ] } ], "flags_versions_mapping": {