Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New 1Password XSIAM Integration #37730

Merged
merged 69 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
af74b48
1Password shell pack and integration (dummy code)
kamalq97 Dec 17, 2024
769f84d
Fix default values in YML
kamalq97 Dec 18, 2024
2352d4b
Rename from 1Password to OnePassword to avoid import issues
kamalq97 Dec 18, 2024
d1d1f05
Rename integration
kamalq97 Dec 18, 2024
30ad60b
Rename pack
kamalq97 Dec 18, 2024
28c4167
Implement Client.get_events and improve docs
kamalq97 Dec 18, 2024
7fa5ae5
Implement get_events_command
kamalq97 Dec 19, 2024
60e476a
Write majority of event collector logic
kamalq97 Dec 22, 2024
6b6fc33
Add param in YML and write unit tests
kamalq97 Dec 22, 2024
572cabf
Add a unit test for fetch_events
kamalq97 Dec 22, 2024
fa9c8d0
Add more unit tests and test data
kamalq97 Dec 23, 2024
2d8941b
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Dec 23, 2024
77c46f5
Improve docs and fix next run logic
kamalq97 Dec 23, 2024
367b2bb
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Dec 23, 2024
8091605
Define DEFAULT_MAX_EVENTS_PER_FETCH constant
kamalq97 Dec 23, 2024
c473a04
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Dec 23, 2024
19d8c3e
Fix get_events_command doc string
kamalq97 Dec 23, 2024
4fb20a3
Use CaseInsensitiveDict
kamalq97 Dec 23, 2024
11970da
Improve docs
kamalq97 Dec 24, 2024
02b7c71
Remove single quotes from YML
kamalq97 Dec 24, 2024
4e158c7
Update OnePasswordEventCollector.py
kamalq97 Dec 24, 2024
fb05452
Cleanup exception raising in test module
kamalq97 Dec 24, 2024
1bb527d
Improve markdown table title
kamalq97 Dec 24, 2024
780893a
Remove assert_not_raises contextmanager
kamalq97 Dec 24, 2024
d43651e
Improve param name in unit tests
kamalq97 Dec 24, 2024
bcef245
Improve variable naming
kamalq97 Dec 24, 2024
9a850d3
Simplify events post request body
kamalq97 Dec 24, 2024
6bc0f70
Improve client.get_events method
kamalq97 Dec 24, 2024
82718d6
Add native image config for integration
kamalq97 Dec 24, 2024
dae5650
Improve get_events_from_client documentation
kamalq97 Dec 24, 2024
8f394b2
Update README.md
kamalq97 Dec 25, 2024
817dd35
Improve timezone handling and documentation
kamalq97 Dec 25, 2024
5e62ac7
Fix fetch events
kamalq97 Dec 25, 2024
a1e02dd
Fix datetime formatting
kamalq97 Dec 25, 2024
df3a79c
Update OnePasswordEventCollector.py
kamalq97 Dec 25, 2024
4b47eec
Update OnePasswordEventCollector.py
kamalq97 Dec 25, 2024
858fb5f
Improved logs
kamalq97 Dec 26, 2024
5885e9f
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Dec 29, 2024
bbfcff3
Update based on Jasmine's review (round 1)
kamalq97 Dec 30, 2024
06f4bf1
Add get_events_request_body unit tests
kamalq97 Dec 30, 2024
1deeeea
Update OnePasswordEventCollector.py
kamalq97 Dec 30, 2024
7bd00c0
Improve documenation
kamalq97 Dec 31, 2024
eb128c0
Change client base URL
kamalq97 Dec 31, 2024
59d82b6
Improve config params and refactor
kamalq97 Dec 31, 2024
c52903d
Improve test data, unit tests, config params, and variable names
kamalq97 Dec 31, 2024
bf773b6
Delete introspection_response.json
kamalq97 Dec 31, 2024
7d41ee5
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Jan 1, 2025
fd5fde0
Fix minor bugs
kamalq97 Jan 1, 2025
bd83a79
Update OnePasswordEventCollector_test.py
kamalq97 Jan 1, 2025
e8fba16
Separate two helper functions
kamalq97 Jan 2, 2025
3af0e8f
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Jan 5, 2025
dd55ed3
Remove section about deleted config param
kamalq97 Jan 5, 2025
3068c2b
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Jan 6, 2025
3546cb2
Rename integration
kamalq97 Jan 7, 2025
04f3c16
Post demo update
kamalq97 Jan 7, 2025
acfa172
Update docker_native_image_config.json
kamalq97 Jan 7, 2025
ecea73c
Rename (Again)
kamalq97 Jan 7, 2025
e099ef2
Rename pack
kamalq97 Jan 7, 2025
46af80c
Update docker_native_image_config.json
kamalq97 Jan 7, 2025
b333c44
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Jan 8, 2025
5f0058c
Update README.md
kamalq97 Jan 8, 2025
2ddaed7
Improve handling of fetch run edge case
kamalq97 Jan 9, 2025
98a08a6
Add another test case and more events
kamalq97 Jan 12, 2025
fed1ddd
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Jan 12, 2025
da63f46
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Jan 12, 2025
ce6ffdc
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Jan 13, 2025
dd402ad
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Jan 13, 2025
eb74cb8
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Jan 14, 2025
6be340e
Merge branch 'master' into CIAC-12024-1password-xsiam-collector-v1
kamalq97 Jan 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added Packs/1Password/.pack-ignore
Empty file.
Empty file added Packs/1Password/.secrets-ignore
Empty file.
Binary file added Packs/1Password/Author_image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,392 @@
import demistomock as demisto # noqa: F401
from CommonServerPython import * # noqa: F401
from CommonServerUserPython import * # noqa

import urllib3
from http import HTTPStatus
from requests.structures import CaseInsensitiveDict
from datetime import datetime, timedelta

# Disable insecure warnings
urllib3.disable_warnings()
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved


''' CONSTANTS '''

DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ' # ISO8601 format with UTC
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 = 1000
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

DEFAULT_FETCH_FROM_DATE = datetime.now() - timedelta(weeks=2)

EVENT_TYPE_MAPPING = CaseInsensitiveDict(
{
# Display name: API Feature and endpoint name
'Item usage actions': 'itemusages',
'Audit events': 'auditevents',
'Sign in attempts': 'signinattempts',
}
)


''' CLIENT CLASS '''


class Client(BaseClient):
"""Client class to interact with the 1Password Events API"""

def introspect(self) -> dict:
"""Performs an introspect API call to check authentication status and the features (scopes) that the token can access,
including one or more of: 'auditevents', 'itemusage', and 'signinattempts'.

Raises:
DemistoException: If API responds with an HTTP error status code.

Returns:
dict: The response JSON from the 'Auth introspect' endpoint.
"""
return self._http_request(method='GET', url_suffix='/auth/introspect', raise_on_status=True)

def get_events(
self,
event_type: str,
from_date: datetime | None = None,
results_per_page: int = DEFAULT_RESULTS_PER_PAGE,
pagination_cursor: str | None = None,
):
"""Gets events from 1Password based on the specified event type

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).
pagination_cursor (str | None): Pagination Cursor from previous API response.

Raises:
ValueError: If unknown event type or missing cursor and start time.
DemistoException: If API responds with an HTTP error status code.

Returns:
dict: The response JSON from the event endpoint.
"""
feature = EVENT_TYPE_MAPPING.get(event_type)
if not feature:
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError(f'Invalid or unsupported 1Password event type: {event_type}.')

body: dict[str, Any]
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved
if pagination_cursor:
body = {'cursor': pagination_cursor}
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

elif from_date:
body = {'limit': results_per_page, 'start_time': from_date.isoformat()}
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

else:
raise ValueError("Either a 'pagination_cursor' or a 'from_date' need to be specified.")

return self._http_request(method='POST', url_suffix=f'/{feature}', data=json.dumps(body), raise_on_status=True)
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved


''' HELPER FUNCTIONS '''


def get_limit_param(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.lower().replace(' ', '_') + '_limit'
return arg_to_number(params.get(param_name)) or DEFAULT_MAX_EVENTS_PER_FETCH
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved


def get_unauthorized_event_types(auth_introspection_response: dict[str, Any], event_types: list[str]) -> list[str]:
"""Checks if the bearer token has no access to any of the configured event types.

Args:
auth_introspection_response (dict): Response JSON from Client.introspect call.
event_types (list): List of event types from the integration configuration params.

Returns:
list[str]: List of unauthorized event types that the token does not have access to.
"""
authorized_features = auth_introspection_response['features']
return [
event_type for event_type in event_types
if EVENT_TYPE_MAPPING.get(event_type) not in authorized_features
]

kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

def add_fields_to_event(event: dict[str, Any], event_type: str):
"""Sets the '_time' and 'event_type' fields to an event.

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)
event['_time'] = event_time.strftime(DATE_FORMAT) # type: ignore[union-attr]
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved
event['event_type'] = event_type


def get_events_from_client(
client: Client,
event_type: str,
from_date: datetime,
max_events: int,
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.

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): The maximum number of events to fetch.
ids_to_skip (set | None): Optional set of already-fetched event UUIDs that should be skipped.

Returns:
list[dict]: List of events.
"""
events: list[dict] = []
ids_to_skip = ids_to_skip or set()
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

# First call to the paginated API needs to to include a date filter
has_more_events = True
client_kwargs: dict[str, Any] = {'event_type': event_type, 'from_date': from_date}

while has_more_events:
response = client.get_events(**client_kwargs)
pagination_cursor = response['cursor']

for event in response['items']:
event_id = event['uuid']

if event_id in ids_to_skip:
continue
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

if len(events) == max_events:
break
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

add_fields_to_event(event, event_type)
events.append(event)
ids_to_skip.add(event_id)

demisto.debug(
f'Response has {len(response["items"])} events and pagination cursor: {pagination_cursor}.'
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved
f'Used client arguments: {client_kwargs}.'
)

# Followup API calls need to include pagination cursor (if any more events)
has_more_events = False if len(events) == max_events else response['has_more']
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved
client_kwargs = {'event_type': event_type, 'pagination_cursor': pagination_cursor}

return events


def push_events(events: list[dict], next_run: dict[str, Any] | None = None) -> None:
"""Sends events to XSIAM and optionally sets next run (if `next_run` is given).

Args:
events (list): List of event dictionaries.
next_run (dict | None): Optional next run dictionary of all event types. Defaults to None.
"""
demisto.debug(f'Sending {len(events)} to XSIAM')
send_events_to_xsiam(events=events, vendor=VENDOR, product=PRODUCT)

if next_run:
demisto.debug(f'Setting next run to {next_run}')
demisto.setLastRun(next_run)
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved


''' COMMAND FUNCTIONS '''


def fetch_events(
client: Client,
event_type: str,
first_fetch_date: datetime,
type_last_run: dict[str, Any],
max_events: 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').
first_fetch_date (datetime): Datetime from which to get events.
type_last_run (dict): Dictionary of the last run (if any) of the event type with optional 'from_date' and 'ids' list.
max_events (int): The maximum number of events 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 = set(type_last_run.get('ids') or [])
from_date = arg_to_datetime(type_last_run.get('from_date')) or first_fetch_date

demisto.debug(f'Fetching events of type: {event_type} from date: {from_date.isoformat()}')

events = get_events_from_client(
client=client,
event_type=event_type,
from_date=from_date,
max_events=max_events,
ids_to_skip=last_run_ids,
)

# API returns events sorted by timestamp in ascending order (oldest to newest), so last event has max timestamp
max_timestamp = events[-1]['timestamp'] if events else None
next_run_ids = [event['uuid'] for event in events if event['timestamp'] == max_timestamp]
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved
type_next_run = {'from_date': max_timestamp, 'ids': next_run_ids}
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

demisto.debug(
f'Fetched {len(events)} events of type: {event_type} out of a maximum of {max_events}.'
f'Last event timestamp: {max_timestamp}'
)

return type_next_run, 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.
"""
try:
response = client.introspect()
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

if unauthorized_event_types := get_unauthorized_event_types(response, event_types):
return f'The API token does not have access to the event types: {", ".join(unauthorized_event_types)}'
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

return 'ok'

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 e
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved


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=f'Events of type: {event_type}', t=flattenTable(events))
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

return events, CommandResults(readable_output=human_readable)
JasBeilin marked this conversation as resolved.
Show resolved Hide resolved


''' MAIN FUNCTION '''


def main() -> None: # pragma: no cover
command = demisto.command()
params = demisto.params()
args = demisto.args()

# required
base_url: str = urljoin(params['url'], '/api/v2')
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved
token: str = params['credentials']['password']
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved
event_types: list[str] = argToList(params['event_types'])
first_fetch_date: datetime = dateparser.parse(params.get('first_fetch', '')) or DEFAULT_FETCH_FROM_DATE

# 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)

if 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)
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

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_MAPPING[event_type]

type_last_run: dict = last_run.get(event_type_key, {})
type_max_results: int = get_limit_param(params, event_type)

type_next_run, events = fetch_events(
client=client,
event_type=event_type,
first_fetch_date=first_fetch_date,
type_last_run=type_last_run,
max_events=type_max_results,
)
all_events.extend(events)
next_run[event_type_key] = type_next_run
kamalq97 marked this conversation as resolved.
Show resolved Hide resolved

push_events(events, 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()
Loading
Loading