DruvaEventCollector

Druva Ransomware Response Integration provides ransomware protection for endpoints, SaaS applications and data center workloads for Druva Ransomware Recovery customers.

Analytics & SIEM · Druva

Details

IDDruvaEventCollector
ProviderDruva
CategoryAnalytics & SIEM
From Version8.4.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix XSIAM

README

This is the Druva event collector integration for Cortex XSIAM.

Configure Druva Event Collector in Cortex

Parameter Required
Server URL True
Client ID True
Secret Key True
Trust any certificate (not secure)  
Use system proxy settings  
The maximum number of events per fetch per type  
Events to fetch  

Additional Information

  • The maximum number of events per fetch: The default value is 10,000. The API only returns up to 500 events at a time without limiting capabilities, so it is best to enter this parameter in multiples of 500.
  • Resetting event fetching: Note that resetting the event fetching (clearing the integration context) will cause duplicate events to be fetched, as the integration will restart the fetch process from the beginning.

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.

druva-get-events


Gets events from Druva API in one batch (max 500). If tracker is given, only its successive events will be fetched.

Base Command

druva-get-events

Input

Argument Name Description Required
should_push_events Set this argument to true in order to create Cortex XSIAM events, otherwise the command will only display them. Possible values are: true, false. Default is false. Required
tracker A string received in a previous run, marking the point in time from which we want to fetch. For InSync events, this is a tracker. For Cybersecurity events, this is a pageToken. Optional
event_types The types of events to fetch. Possible values are: InSync events, Cybersecurity events. Default is InSync events. Optional

Context Output

There is no context output for this command.

Configuration parameters

  • url — Server URL (required)
  • credentials — Client ID (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • max_fetch — The maximum number of events per fetch per type.
  • event_types — Events to fetch

Commands (1)

  • druva-get-events

    Gets events from Druva API in one batch (max 500). If tracker is given, only its successive events will be fetched.

from datetime import datetime

import demistomock as demisto
import pytest
import requests
from CommonServerPython import DemistoException
from DruvaEventCollector import (
    DATE_FORMAT_FOR_TOKEN,
    MAX_FETCH,
    Client,
    _filter_old_events,
    fetch_events,
    get_events,
)
from DruvaEventCollector import (
    test_module as run_test_module,
)
from freezegun import freeze_time

RESPONSE_WITH_EVENTS_1 = {
    "events": [
        {
            "eventID": 0,
            "eventType": "Backup",
            "profileName": "Default",
            "inSyncUserName": "user name",
            "clientVersion": "7.5.0r(23c42be5)",
            "clientOS": "Office 365 Exchange Online",
            "ip": "",
            "inSyncUserEmail": "test@test.com",
            "eventDetails": "",
            "timestamp": "2024-05-25T18:52:48Z",
            "inSyncUserID": 0,
            "profileID": 0,
            "initiator": None,
            "inSyncDataSourceID": 0,
            "eventState": "Backed up with Errors",
            "inSyncDataSourceName": "Exchange Online",
            "severity": 4,
            "facility": 23,
        }
    ],
    "nextPageExists": False,
    "tracker": "xxxx",
}

RESPONSE_CYBERSECURITY_EVENTS = {
    "events": [
        {
            "id": 1178203423,
            "productID": 8193,
            "globalID": "e65b5695-4ef4-453b-90c6-3a31b0e6b159",
            "category": "EVENT",
            "feature": "Alerts And Notifications",
            "type": "Alert",
            "timeStamp": "2024-05-25T18:52:48Z",
            "details": {"eventID": 36832945, "ip": None, "clientOS": None},
        }
    ],
    "nextPageToken": "cybertoken123",
}
RESPONSE_WITH_EVENTS_2 = {
    "events": [
        {
            "eventID": 1,
            "eventType": "Backup",
            "profileName": "Default",
            "inSyncUserName": "user name",
            "clientVersion": "7.5.0r(23c42be5)",
            "clientOS": "Office 365 Exchange Online",
            "ip": "",
            "inSyncUserEmail": "test@test.com",
            "eventDetails": "",
            "timestamp": "2024-05-25T18:52:48Z",
            "inSyncUserID": 1,
            "profileID": 1,
            "initiator": None,
            "inSyncDataSourceID": 1,
            "eventState": "Backed up with Errors",
            "inSyncDataSourceName": "Exchange Online",
            "severity": 4,
            "facility": 23,
        }
    ],
    "nextPageExists": False,
    "tracker": "yyyy",
}
RESPONSE_WITHOUT_EVENTS = {"events": [], "nextPageExists": False, "tracker": "xxxx"}
INVALID_RESPONSE = {
    "events": [
        {
            "eventID": 0,
            "eventType": "Backup",
            "profileName": "Default",
            "inSyncUserName": "user name",
            "clientVersion": "7.5.0r(23c42be5)",
            "clientOS": "Office 365 Exchange Online",
            "ip": "",
            "inSyncUserEmail": "test@test.com",
            "eventDetails": "",
            "timestamp": "2024-05-25T18:52:48Z",
            "inSyncUserID": 0,
            "profileID": 0,
            "initiator": None,
            "inSyncDataSourceID": 0,
            "eventState": "Backed up with Errors",
            "inSyncDataSourceName": "Exchange Online",
            "severity": 4,
            "facility": 23,
        }
    ],
    "nextPageExists": False,
}


@pytest.fixture()
def mock_client(mocker) -> Client:
    mocker.patch.object(Client, "login", return_value="DUMMY_TOKEN")
    client = Client(
        base_url="test",
        client_id="client_id",
        secret_key="secret_key",
        max_fetch=MAX_FETCH,
        verify=False,
        proxy=False,
    )
    client._set_headers("DUMMY_TOKEN")
    return client


def test_test_module_command(mocker, mock_client):
    """
    Given:
    - test module command
    - an empty (yet valid) response from Druva

    When:
    - Pressing test button

    Then:
    - Test module passed
    """
    mocker.patch.object(
        mock_client,
        "search_events",
        return_value={"events": [], "tracker": "DUMMY_TRACKER"},
    )
    result = run_test_module(client=mock_client, event_types=["InSync events"])
    assert result == "ok"


def test_test_module_command_failure(mocker, mock_client):
    """
    Given:
    - test module command

    When:
    - Pressing test button

    Then:
    - Test module failed
    """
    mocker.patch.object(
        mock_client,
        "_http_request",
        side_effect=DemistoException(message="Error: invalid_grant"),
    )
    with pytest.raises(DemistoException):
        run_test_module(client=mock_client, event_types=["InSync events"])


@pytest.mark.parametrize(
    "event_type,mock_response,expected_tracker,expected_event_count,expected_event_id_field",
    [
        ("InSync events", RESPONSE_WITH_EVENTS_1, "xxxx", 1, "eventID"),
    ],
)
def test_get_events_command(
    mocker, mock_client, event_type, mock_response, expected_tracker, expected_event_count, expected_event_id_field
):
    """
    Given:
    - get_events command for InSync events

    When:
    - running get events command

    Then:
    - Ensure events and tracker are returned as expected
    """
    mocker.patch.object(mock_client, "search_events", return_value=mock_response)
    events, tracker = get_events(client=mock_client, event_type=event_type)

    assert tracker == expected_tracker
    assert len(events) == expected_event_count
    assert expected_event_id_field in events[0]


def test_search_events_cybersecurity_normalization(mocker, mock_client):
    """
    Given:
    - Cybersecurity events API response with nextPageToken

    When:
    - running search_events method with event_type="Cybersecurity events"

    Then:
    - Ensure nextPageToken is normalized to tracker in the response
    """
    mocker.patch.object(mock_client, "_http_request", return_value=RESPONSE_CYBERSECURITY_EVENTS)
    response = mock_client.search_events(event_type="Cybersecurity events")

    # Verify normalization: tracker should be added with same value as nextPageToken
    assert response["tracker"] == "cybertoken123"
    assert response["nextPageToken"] == "cybertoken123"


def test_get_events_command_failure(mocker, mock_client):
    """
    Given:
    - a mocked client
    - mocked response: invalid response structure, since it does not have the 'tracker' key as expected

    When:
    - running get events command

    Then:
    - Ensure KeyError exception was thrown due to invalid response

    """
    mocker.patch.object(mock_client, "search_events", return_value=INVALID_RESPONSE)
    with pytest.raises(KeyError):
        get_events(client=mock_client, event_type="InSync events")


def test_refresh_access_token(mocker, mock_client):
    """
    Given:
    - a mock client

    When:
    - running _refresh_access_token method

    Then:
    - Ensure exception is thrown
    - Ensure informative message was shown
    """
    response = requests.Response()
    response.status_code = 400

    mocker.patch.object(
        mock_client,
        "_http_request",
        side_effect=DemistoException(message="invalid_grant", res=response),
    )

    error_message = "Error in test-module: Make sure Server URL, Client ID and Secret Key are correctly entered."

    with pytest.raises(DemistoException, match=error_message):
        mock_client._refresh_access_token()


@pytest.mark.parametrize(
    "event_type,tracker,expected_url_suffix,mock_response",
    [
        ("InSync events", "xxxx", "/insync/eventmanagement/v2/events?tracker=xxxx", RESPONSE_WITHOUT_EVENTS),
        ("InSync events", None, "/insync/eventmanagement/v2/events", RESPONSE_WITHOUT_EVENTS),
        (
            "Cybersecurity events",
            "token123",
            "/platform/eventmanagement/v3/events?pageToken=token123",
            RESPONSE_CYBERSECURITY_EVENTS,
        ),
        ("Cybersecurity events", None, "/platform/eventmanagement/v3/events?pageSize=500", RESPONSE_CYBERSECURITY_EVENTS),
    ],
)
def test_search_events_called_with(mocker, mock_client, event_type, tracker, expected_url_suffix, mock_response):
    """
    Given:
    - a mock client
    - different event types and tracker scenarios

    When:
    - running search_events method with various parameters

    Then:
    - Ensure correct URL is constructed for each scenario
    """
    http_mock = mocker.patch.object(mock_client, "_http_request", return_value=mock_response)

    mock_client.search_events(tracker=tracker, event_type=event_type)
    http_mock.assert_called_with(
        method="GET",
        url_suffix=expected_url_suffix,
        headers={"Authorization": "Bearer DUMMY_TOKEN", "accept": "application/json"},
    )


def test_search_events_failure(mocker, mock_client):
    """
    Given:
    - a mock client

    When:
    - running search_events method

    Then:
    -  Ensure an exception was thrown due to invalid tracker
    """

    mocker.patch.object(
        mock_client,
        "_http_request",
        side_effect=DemistoException(message="Error: Invalid tracker"),
    )
    with pytest.raises(DemistoException):
        mock_client.search_events(tracker="xxxx")


def test_fetch_events_command(mocker, mock_client):
    """
    Given:
    - fetch events command

    When:
    - Running fetch-events command

    Then:
    - Ensure number of events fetched, and the next run, match the response data
    """
    mocker.patch("DruvaEventCollector._filter_old_events", side_effect=lambda events: events)
    # First fetch
    mocker.patch.object(mock_client, "search_events", return_value=RESPONSE_WITH_EVENTS_1)
    first_run = {}
    events, tracker_for_second_run = fetch_events(
        client=mock_client, last_run=first_run, max_fetch=MAX_FETCH, event_types=["InSync events"]
    )

    assert len(events) == 1
    assert tracker_for_second_run["tracker_InSync events"] == "xxxx"
    assert events[0]["eventID"] == 0

    # Second fetch
    mock_search_events = mocker.patch.object(mock_client, "search_events", return_value=RESPONSE_WITH_EVENTS_2)
    events, tracker_for_third_run = fetch_events(
        client=mock_client, last_run=tracker_for_second_run, max_fetch=MAX_FETCH, event_types=["InSync events"]
    )
    # The second fetch uses the tracker from first fetch result (xxxx)
    mock_search_events.assert_called_with("xxxx", "InSync events")
    assert len(events) == 1
    assert tracker_for_third_run["tracker_InSync events"] == "yyyy"
    assert events[0]["eventID"] == 1


@freeze_time(datetime(2022, 2, 28, 11, 10))
@pytest.mark.parametrize(
    "integration_context",
    [
        ({}),
        (
            {
                "Token": "DUMMY TOKEN",
                "expiration_time": datetime(2022, 2, 28, 10, 50).strftime(DATE_FORMAT_FOR_TOKEN),
            }
        ),
    ],
)
def test_login_invalid_token(mocker, integration_context):
    """
    Given:
    - An IntegrationContext without a Token or one that has expired

    When:
    - Build a client (for checking login)

    Then:
    - Ensure a new token is generated
    """
    mocker.patch.object(demisto, "getIntegrationContext", return_value=integration_context)
    mocker.patch.object(demisto, "setIntegrationContext")
    mock_refresh_access_token = mocker.patch.object(Client, "_refresh_access_token", return_value=("", 0))
    Client(
        base_url="test",
        client_id="client_id",
        secret_key="secret_key",
        max_fetch=MAX_FETCH,
        verify=False,
        proxy=False,
    )
    mock_refresh_access_token.assert_called_once_with()


@freeze_time(datetime(2022, 2, 28, 11, 00))
def test_login_valid_token(mocker):
    """
    Given:
    - An IntegrationContext with valid Access Token

    When:
    - Build a client (for checking login)

    Then:
    - Ensure a new token is not generated
    """
    mocker.patch.object(
        demisto,
        "getIntegrationContext",
        return_value={
            "Token": "DUMMY TOKEN",
            "expiration_time": datetime(2022, 2, 28, 11, 10).strftime(DATE_FORMAT_FOR_TOKEN),
        },
    )
    mocker.patch.object(demisto, "setIntegrationContext")
    mock_refresh_access_token = mocker.patch.object(Client, "_refresh_access_token", return_value=("DUMMY_TOKEN", 1800))
    Client(
        base_url="test",
        client_id="client_id",
        secret_key="secret_key",
        max_fetch=MAX_FETCH,
        verify=False,
        proxy=False,
    )
    assert not mock_refresh_access_token.called


def test_max_fetch_validation():
    """
    Given:
    - invalid max_fetch (more than 10,000)

    When:
    - init the client

    Then:
    - DemistoException is thrown with an appropriate message
    """

    with pytest.raises(DemistoException, match=f"The maximum number of events per fetch should be between 1 - {MAX_FETCH}"):
        Client(
            base_url="test",
            client_id="client_id",
            secret_key="secret_key",
            max_fetch=(MAX_FETCH + 1),
            verify=False,
            proxy=False,
        )


def test_fetch_events_invalid_tracker(mocker, mock_client):
    """
    Given:
    - fetch events command

    When:
    - Running fetch-events command
    - Mocking the second fetch to throw an exception to Invalid tracker

    Then:
    - Ensure exception is caught
    - Ensure same tracker is returned (as we got at the previous call)
    - Ensure no events are returned
    """
    mocker.patch("DruvaEventCollector._filter_old_events", side_effect=lambda events: events)
    # First fetch
    mocker.patch.object(mock_client, "search_events", return_value=RESPONSE_WITH_EVENTS_1)
    events, tracker_for_second_run = fetch_events(
        client=mock_client, last_run={}, max_fetch=MAX_FETCH, event_types=["InSync events"]
    )

    # Second fetch
    mocker.patch.object(mock_client, "search_events", side_effect=Exception("Invalid tracker"))
    events, tracker_for_third_run = fetch_events(
        client=mock_client, last_run=tracker_for_second_run, max_fetch=MAX_FETCH, event_types=["InSync events"]
    )

    # same tracker should be returned when "Invalid tracker" exception is thrown and no events
    assert tracker_for_third_run["tracker_InSync events"] == tracker_for_second_run["tracker_InSync events"]
    assert events == []


@pytest.mark.parametrize(
    "event_types,expected_tracker_keys,mock_responses",
    [
        (["InSync events"], ["tracker_InSync events"], {"InSync events": RESPONSE_WITH_EVENTS_1}),
        (["Cybersecurity events"], ["tracker_Cybersecurity events"], {"Cybersecurity events": RESPONSE_CYBERSECURITY_EVENTS}),
        (
            ["InSync events", "Cybersecurity events"],
            ["tracker_InSync events", "tracker_Cybersecurity events"],
            {"InSync events": RESPONSE_WITH_EVENTS_1, "Cybersecurity events": RESPONSE_CYBERSECURITY_EVENTS},
        ),
    ],
)
def test_fetch_events_multiple_types(mocker, mock_client, event_types, expected_tracker_keys, mock_responses):
    """
    Given:
    - fetch events command with different event type configurations

    When:
    - Running fetch-events command with single or multiple event types

    Then:
    - Ensure correct trackers are stored for each event type
    - Ensure _time and source_log_type fields are added to events
    """
    mocker.patch("DruvaEventCollector._filter_old_events", side_effect=lambda events: events)

    def mock_search(tracker=None, event_type="InSync events"):
        return mock_responses.get(event_type, RESPONSE_WITH_EVENTS_1)

    mocker.patch.object(mock_client, "search_events", side_effect=mock_search)
    events, next_run = fetch_events(client=mock_client, last_run={}, max_fetch=MAX_FETCH, event_types=event_types)

    # Verify all expected tracker keys are present
    for key in expected_tracker_keys:
        assert key in next_run

    # Verify event count matches number of event types
    assert len(events) == len(event_types)

    # Verify _time and source_log_type fields are added
    for event in events:
        assert "_time" in event
        assert "source_log_type" in event
        # Verify source_log_type has correct value
        assert event["source_log_type"] in ["insync_events", "cybersecurity_events"]


@freeze_time(datetime(2024, 5, 25, 19, 30, 0))
@pytest.mark.parametrize(
    "events,expected_count,expected_ids",
    [
        pytest.param(
            [{"timestamp": "2024-05-25T18:52:48Z", "eventID": 1}, {"timestamp": "2024-05-25T19:00:00Z", "eventID": 2}],
            2,
            [1, 2],
            id="all_events_within_1h_kept",
        ),
        pytest.param(
            [{"timestamp": "2024-05-25T17:00:00Z", "eventID": 1}, {"timestamp": "2024-05-25T18:00:00Z", "eventID": 2}],
            0,
            [],
            id="all_events_older_than_1h_dropped",
        ),
        pytest.param(
            [
                {"timestamp": "2024-05-25T18:00:00Z", "eventID": 1},
                {"timestamp": "2024-05-25T18:30:00Z", "eventID": 2},
                {"timestamp": "2024-05-25T19:00:00Z", "eventID": 3},
            ],
            2,
            [2, 3],
            id="mixed_old_and_new_events",
        ),
        pytest.param(
            [],
            0,
            [],
            id="empty_events_list",
        ),
        pytest.param(
            [
                {"timeStamp": "2024-05-25T18:00:00Z", "eventID": 1},
                {"timeStamp": "2024-05-25T19:00:00Z", "eventID": 2},
            ],
            1,
            [2],
            id="cybersecurity_camelCase_timeStamp_field",
        ),
        pytest.param(
            [
                {"eventID": 1},
                {"timestamp": "2024-05-25T19:00:00Z", "eventID": 2},
            ],
            2,
            [1, 2],
            id="missing_timestamp_kept_not_dropped",
        ),
    ],
)
def test_filter_old_events(events, expected_count, expected_ids):
    """
    Given:
    - Various event lists with different timestamp scenarios (frozen time is 19:30, cutoff is 18:30)
      including both 'timestamp' (InSync) and 'timeStamp' (Cybersecurity) field formats

    When:
    - Calling _filter_old_events

    Then:
    - Only events within the last hour are kept
    """
    result = _filter_old_events(events)
    assert len(result) == expected_count
    assert [e["eventID"] for e in result] == expected_ids


@freeze_time(datetime(2024, 5, 25, 19, 30, 0))
def test_filter_old_events_logs_dropped_count(mocker):
    """
    Given:
    - Events older than 1 hour

    When:
    - Calling _filter_old_events

    Then:
    - A debug log is emitted with the count of dropped events
    """
    debug_mock = mocker.patch.object(demisto, "debug")
    events = [{"timestamp": "2024-05-25T17:00:00Z", "eventID": 1}]
    _filter_old_events(events)
    debug_mock.assert_called_once()
    assert "dropped 1 events" in debug_mock.call_args[0][0]


@freeze_time(datetime(2024, 5, 25, 19, 30, 0))
def test_fetch_events_no_tracker_filters_old_events(mocker, mock_client):
    """
    Given:
    - First fetch (empty last_run, no tracker)
    - API returns events, some older than 1 hour and some newer

    When:
    - Running fetch_events with no tracker

    Then:
    - Old events are filtered out
    - Tracker is still saved for subsequent fetches
    """
    response_mixed = {
        "events": [
            {
                "eventID": 100,
                "eventType": "Backup",
                "timestamp": "2024-05-25T18:00:00Z",  # older than 1h (cutoff 18:30) - dropped
                "severity": 4,
                "facility": 23,
            },
            {
                "eventID": 101,
                "eventType": "Backup",
                "timestamp": "2024-05-25T19:00:00Z",  # within 1h - kept
                "severity": 4,
                "facility": 23,
            },
        ],
        "nextPageExists": False,
        "tracker": "new_tracker_value",
    }

    mocker.patch.object(mock_client, "search_events", return_value=response_mixed)
    events, next_run = fetch_events(client=mock_client, last_run={}, max_fetch=MAX_FETCH, event_types=["InSync events"])

    # Only the newer event should be kept
    assert len(events) == 1
    assert events[0]["eventID"] == 101
    # Tracker should still be saved
    assert next_run["tracker_InSync events"] == "new_tracker_value"


@freeze_time(datetime(2024, 5, 25, 19, 30, 0))
def test_fetch_events_with_tracker_does_not_filter(mocker, mock_client):
    """
    Given:
    - A subsequent fetch (last_run has a tracker)
    - API returns events older than 1 hour

    When:
    - Running fetch_events with an existing tracker

    Then:
    - No events are filtered (filtering only applies on first fetch when no tracker)
    """
    mocker.patch.object(mock_client, "search_events", return_value=RESPONSE_WITH_EVENTS_1)
    events, next_run = fetch_events(
        client=mock_client,
        last_run={"tracker_InSync events": "existing_tracker"},
        max_fetch=MAX_FETCH,
        event_types=["InSync events"],
    )

    # All events should be kept because tracker exists (not a first fetch)
    assert len(events) == 1
    assert events[0]["eventID"] == 0