ManageEngine
ManageEngine Endpoint Central is a Unified Endpoint Management solution that helps in managing thousands of servers, desktops, laptops and mobile devices from a single console..
Analytics & SIEM · ManageEngine
Details
| ID | ManageEngine |
|---|---|
| Provider | Zoho Corporation |
| Category | Analytics & SIEM |
| From Version | 8.3.0 |
| Docker Image | demisto/python3:3.12.13.10116658 |
| Supported Modules | XSIAM |
README
ManageEngine Endpoint Central is a Unified Endpoint Management solution that helps in managing thousands of servers, desktops, laptops and mobile devices from a single console. This integration serves as an Event Collector for Cortex XSIAM, enabling fetching AuditLogs from Endpoint Central.
Endpoint Central Cloud Domains
Endpoint Central cloud is hosted at multiple data centers, and therefore available on different domains. There are several domains for Endpoint Central Cloud APIs, so you can use the one that is applicable to you.
| Data Centre | Domain | EndpointCentral Server URI |
|---|---|---|
| United States | .com | https://endpointcentral.manageengine.com |
| Europe | .eu | https://endpointcentral.manageengine.eu |
| India | .in | https://endpointcentral.manageengine.in |
| Australia | .com.au | https://endpointcentral.manageengine.com.au |
| China | .cn | https://endpointcentral.manageengine.cn |
| Japan | .jp | https://endpointcentral.manageengine.jp |
| Canada | .ca | https://endpointcentral.manageengine.ca |
The APIs on this page are intended for organizations hosted on the .com domain. If your organization is on a different domain, replace “.com” with the appropriate domain for the API endpoints before using them.
Note: You can also find out which domain you’re accessing by checking the URL while logged in to Endpoint Central.
Setting Up the Instance
Step 1: Generate Client ID and Client Secret
- Register your application as a new client by accessing the developer console.
- Choose Self client as application type.
- After choosing the client type, provide the required details and click ‘Create’. On successful registration, you will be provided with a set of OAuth 2.0 credentials such as
Client_IDandClient_Secretthat will be only known to Zoho and your application. (Do not share this credentials anywhere).
Step 2: Authorization by generating the grant token
After generating Client_ID and Client_Secret, a grant code has to be generated.
Self Client Method - For Self Client type.
- After registration, click the
Self Clientmethod available on the Applications list. - Enter a valid scope: DesktopCentralCloud.Admin.READ
Click Create to generate Code.
Testing the configuration
To test the configuration, run the !manage-engine-test command instead of using the Test button.
Configure ManageEngine in Cortex
| Parameter | Required |
|---|---|
| Server URL | True |
| Client ID | True |
| Client Secret | True |
| Code | True |
| Trust any certificate (not secure) | False |
| Use system proxy settings | False |
| Max number of audit events per fetch | False |
| Fetch events | False |
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.
manage-engine-test
Tests connectivity of the server.
Base Command
manage-engine-test
manage-engine-get-events
Manual command to fetch events and display them.
Base Command
manage-engine-get-events
Input
| Argument Name | Description | Required |
|---|---|---|
| should_push_events | If true, the command will create events. Otherwise, it will only display them. Used for debugging purposes. | Required |
| limit | Maximum number of results to return. | Optional |
| start_date | Date from which to get events, For example ‘2018-11-06T08:56:41.000Z’. | Optional |
| end_date | Date to which to get events , For example ‘2018-11-06T08:56:41.000Z’. | Optional |
Context Output
There is no context output for this command.
Configuration parameters
server_url— Server URL (required)credentials— Client ID (required)client_code— Code (required)insecure— Trust any certificate (not secure)proxy— Use system proxy settingsmax_audit_events— Max number of audit events per fetchisFetchEvents— Fetch events
Commands (2)
-
manage-engine-get-eventsGets events from ManageEngine.
-
manage-engine-testTests connectivity of the server.
import pytest import random import json from ManageEngineEventCollector import Client, PAGE_LIMIT_DEFAULT import ManageEngineEventCollector as manage @pytest.fixture() def client() -> Client: return Client(base_url="https://endpointcentral.manageengine.com", client_id="id", client_secret="secret", client_code="code") def util_load_json(): with open("test_data/response.json", encoding="utf-8") as f: return json.loads(f.read()) # ─────── Tests for get_access_token ──────────────────────────────────────────── def test_get_access_token_cached(client: Client, mocker): """ Given: - An integration context with a valid (non-expired) access token and a refresh token. When: - Calling `get_access_token()`. Then: - The cached access token is returned. - No HTTP request is made to refresh the token. """ future = "3000-01-01T00:00:00" ctx = {"access_token": "cached_token", "refresh_token": "rtoken", "expire_date": future} mocker.patch("ManageEngineEventCollector.demisto.getIntegrationContext", return_value=ctx) mocker.patch.object(client, "_http_request", side_effect=AssertionError("HTTP should not be called when token is valid")) token = client.get_access_token() assert token == "cached_token" def test_get_access_token_refresh(client: Client, mocker): """ Given: - An integration context with an expired access token and a valid refresh token. When: - Calling `get_access_token()`. Then: - A new access token is requested via an HTTP call. - The integration context is updated with the new access token, refresh token, and expiration date. - The new access token is returned. """ past = "2000-01-01T00:00:00" ctx = {"access_token": "old_token", "refresh_token": "rtoken", "expire_date": past} mocker.patch("ManageEngineEventCollector.demisto.getIntegrationContext", return_value=ctx) stored = {} mocker.patch("ManageEngineEventCollector.demisto.setIntegrationContext", side_effect=lambda x: stored.update(x)) def fake_http_request(**kwargs): return {"access_token": "new_token", "refresh_token": "rtoken", "expires_in": "3600"} mocker.patch.object(client, "_http_request", side_effect=fake_http_request) token = client.get_access_token() assert token == "new_token" assert stored["access_token"] == "new_token" assert stored["refresh_token"] == "rtoken" assert "expire_date" in stored def test_get_access_token_code(client: Client, mocker): """ Given: - No refresh token is present in the integration context. - An authorization code flow is expected to be triggered (e.g., during first login). When: - Calling `get_access_token()`. Then: - An HTTP request is made to exchange the authorization code for access and refresh tokens. - The returned access token is used. - The integration context is updated with the new refresh token. """ mocker.patch("ManageEngineEventCollector.demisto.getIntegrationContext", return_value={}) stored = {} mocker.patch("ManageEngineEventCollector.demisto.setIntegrationContext", side_effect=lambda x: stored.update(x)) mocker.patch.object( client, "_http_request", return_value={"access_token": "code_token", "refresh_token": "code_refresh", "expires_in": "3600"}, ) token = client.get_access_token() assert token == "code_token" assert stored["refresh_token"] == "code_refresh" def test_get_access_token_refresh_updates_rotating_token(client: Client, mocker): """ Given: - An integration context with an expired access token and an old refresh token ("old_rtoken"). - The Zoho OAuth server returns a NEW refresh token ("new_rtoken") alongside the new access token (Zoho uses rotating refresh tokens — each refresh invalidates the previous token). When: - Calling `get_access_token()`. Then: - The NEW refresh token from the API response ("new_rtoken") is stored in the integration context, NOT the old one from context ("old_rtoken"). - Failing to update would cause the integration to stop ingesting events after Zoho invalidates the old token (typically within a few days). """ past = "2000-01-01T00:00:00" ctx = {"access_token": "old_token", "refresh_token": "old_rtoken", "expire_date": past} mocker.patch("ManageEngineEventCollector.demisto.getIntegrationContext", return_value=ctx) stored = {} mocker.patch("ManageEngineEventCollector.demisto.setIntegrationContext", side_effect=lambda x: stored.update(x)) mocker.patch.object( client, "_http_request", return_value={"access_token": "new_token", "refresh_token": "new_rtoken", "expires_in": "3600"}, ) token = client.get_access_token() assert token == "new_token" # The NEW refresh token from the response must be stored, not the old one from context assert ( stored["refresh_token"] == "new_rtoken" ), "Rotating refresh token from API response must overwrite the old token in context" # ─────── Tests for test_module ──────────────────────────────────────────────── def test_test_module_success(client: Client, mocker): """ Given: - The client's `search_events` method returns a non-empty list (indicating success). When: - Calling the `test_module`. Then: - test_module do not raise error and return message 'Connection is valid.' """ from ManageEngineEventCollector import test_module mocker.patch.object(client, "search_events", return_value=[{}]) result = test_module(client) assert result == "Connection is valid." def test_test_module_auth_error(client: Client, mocker): """ Given: - The client's `search_events` method raises an unauthorized access exception. When: - Calling the `test_module` function. Then: - The result should include an 'Authorization Error' message indicating invalid credentials or permissions. """ from ManageEngineEventCollector import test_module mocker.patch.object(client, "search_events", side_effect=Exception("Unauthorized access")) result = test_module(client) assert "Authorization Error" in result # ─────── Tests for add_time_to_events ───────────────────────────────────────── def test_add_time_to_events(): """ Given: - A list of events with 'eventTime' in timestamp. When: - Calling `add_time_to_events()` on the list. Then: - Each event should have an added '_time' field with the correct ISO 8601 UTC datetime string. """ from ManageEngineEventCollector import add_time_to_events iso = "2021-01-01T00:00:00.000000Z" ts = 1609459200000 # ms for that exact UTC moment evs = [{"eventTime": ts}] add_time_to_events(evs) assert evs[0]["_time"] == iso # ─────── Tests for get_events ───────────────────────────────────────────────── def test_get_events_no_push(client: Client, mocker): """ Given: - A list of events returned by the client's `search_events` method. - 'should_push_events' is 'false'. When: - Calling the `get_events` function. Then: - The function returns a `CommandResults` object with the fetched events. - The `send_events_to_xsiam` function is NOT called. - Each event includes a properly formatted '_time' field. """ from ManageEngineEventCollector import get_events, CommandResults from CommonServerPython import tableToMarkdown return_event = [{"id": "1", "eventTime": 1609459200000}] client = mocker.Mock(spec=Client) client.search_events.return_value = return_event # patch send_events_to_xsiam to error if called mocker.patch( "ManageEngineEventCollector.send_events_to_xsiam", side_effect=AssertionError("send_events_to_xsiam should NOT be called") ) result_markdown = tableToMarkdown( name="ManageEngine Audit Logs", t={"id": "1", "eventTime": 1609459200000, "_time": "2021-01-01T00:00:00.000000Z"}, ) args = { "should_push_events": "false", "limit": "1", "start_date": "2021-01-01T00:00:00.0Z", "end_date": "2021-01-01T00:00:00.0Z", } results = get_events(client, args) assert isinstance(results, CommandResults) assert results.readable_output == result_markdown def test_get_events_default_dates_start_before_end(mocker): """ Given: - No start_date or end_date are provided in args. When: - Calling `get_events()`. Then: - The start_date passed to search_events is BEFORE end_date (start = now-60s, end = now). - Previously the defaults were swapped (start=now, end=now-60s), causing start > end and the API returning no events. """ from ManageEngineEventCollector import get_events captured = {} def fake_search_events(start_time, end_time, limit): captured["start_time"] = int(start_time) captured["end_time"] = int(end_time) return [] mock_client = mocker.Mock(spec=Client) mock_client.search_events.side_effect = fake_search_events mocker.patch("ManageEngineEventCollector.send_events_to_xsiam") get_events(mock_client, {"should_push_events": "false"}) assert captured["start_time"] < captured["end_time"], ( "start_time must be less than end_time when no dates are provided; " "previously the defaults were swapped causing no events to be returned" ) # start should be approximately 60 seconds (60000ms) before end assert captured["end_time"] - captured["start_time"] == pytest.approx(60 * 1000, abs=500) def test_fetch_events_all_new_events_updates_to_max(client, mocker): """ Given: - A list of events with eventTime values. - last_run is empty When: - Calling `fetch_events()`. Then: - All events are returned without deduplication. - The `last_run` is updated to the maximum `eventTime` value among the events. """ from ManageEngineEventCollector import fetch_events events = [ {"eventTime": 1001}, {"eventTime": 1002}, {"eventTime": 1005}, ] mocker.patch.object(client, "search_events", return_value=events) next_run, events_returned = fetch_events(client, {}, max_events_per_fetch=10) # No dedup, so order preserved assert events_returned == events # last_run is the max timestamp seen: 1005 assert next_run["last_time"] == "1006" # ─────── Tests fetching logics ───────────────────────────────────────────────── class HttpRequestsMocker: """ Mocks Client._http_request for both token POSTs and paginated GETs over a synthetic universe of events with timestamps 0..num_events-1. """ def __init__(self, num_events: int): self.num_events = num_events self.all_events = [] def valid_http_request_side_effect( self, method: str, url_suffix: str = "", params: dict | None = None, full_url: str = "", **kwargs ) -> dict: # 1) Token exchange if method.upper() == "POST" and full_url.endswith("/oauth/v2/token"): return {"access_token": "fake_token", "refresh_token": "fake_rt", "expires_in": "3600"} # 2) Paginated audit‐logs GET if method.upper() == "GET" and url_suffix == "/emsapi/server/auditLogs": if not self.all_events: events = list(range(self.num_events)) random.shuffle(events) self.all_events = events limit = int(params.get("pageLimit", PAGE_LIMIT_DEFAULT)) # type: ignore page = int(params.get("page", "1")) # type: ignore # slice for this page lo = (page - 1) * limit hi = lo + limit page_ts = self.all_events[lo:hi] # build the response events = [{"id": ts, "eventTime": ts} for ts in page_ts] return {"status": "success", "messageResponse": events} return {} @pytest.fixture def stub_get_access_token(mocker): """Only stub get_access_token in tests that ask for it.""" mocker.patch.object(Client, "get_access_token", return_value="stub-token") @pytest.fixture def http_mocker(mocker): """ Stub out HTTP and auth for a universe of 100 events (0-99). """ http_mocker = HttpRequestsMocker(100) mocker.patch.object(Client, "_http_request", side_effect=http_mocker.valid_http_request_side_effect) mocker.patch.object(Client, "get_access_token", return_value="stub-token") return http_mocker def _run_search(start: int, end: int, limit: int) -> list[dict]: """ Helper to instantiate a real Client and call search_events(). """ client = Client( base_url="https://endpointcentral.manageengine.com", client_id="id", client_secret="secret", client_code="code" ) return client.search_events(str(start), str(end), limit) # ─────── Tests ──────────────────────────────────────── def test_returns_all_events_when_page_and_limit_exceed_total(http_mocker): """ Test when PAGE_LIMIT_DEFAULT exceeds total events and requested limit exceeds total. Given: - Total events in time range = 100. - PAGE_LIMIT_DEFAULT (5000) > 100 events. - Requested limit = 200 > total events. When: - search_events(0, 100, 200) is called. Then: - All 100 events are returned. - eventTime values range from 0 to 99. """ events = _run_search(0, 99, 200) assert len(events) == 100 assert {event["eventTime"] for event in events} == set(range(100)) def test_returns_first_n_events_when_limit_less_than_total(http_mocker): """ Test when PAGE_LIMIT_DEFAULT exceeds total events and requested limit is below total. Given: - Total events in time range = 100. - PAGE_LIMIT_DEFAULT (5000) > 100 events. - Requested limit = 30 < total events. When: - search_events(0, 100, 30) is called. Then: - 30 events are returned (timestamps 0-29). """ events = _run_search(0, 99, 30) assert len(events) == 30 assert {event["eventTime"] for event in events} == set(range(30)) def test_page_limit_less_and_limit_exceed_total(http_mocker, mocker): """ Test when a smaller PAGE_LIMIT_DEFAULT is set but overall limit exceeds total events. Given: - Total events in time range = 100. - PAGE_LIMIT_DEFAULT overridden to 20. - Requested limit = 150 > total events. When: - search_events(0, 100, 150) is called. Then: - All 100 events are returned despite the lower page limit. """ import ManageEngineEventCollector # Override PAGE_LIMIT to 20 < 100; limit=150 > 100 → should return all 100 mocker.patch.object(ManageEngineEventCollector, "PAGE_LIMIT_DEFAULT", 20) events = _run_search(0, 99, 150) assert len(events) == 100 assert {event["eventTime"] for event in events} == set(range(100)) def test_page_limit_greater_fetch_limit_less_total(http_mocker, mocker): """ Test fetching when limit is between page limit and total. Given: - Total events in time range = 100. - PAGE_LIMIT_DEFAULT overridden to 20. - Requested limit = 50 (20 < 50 < 100). When: - search_events(0, 100, 50) is called. Then: - 50 events are returned (timestamps 0-49). """ import ManageEngineEventCollector mocker.patch.object(ManageEngineEventCollector, "PAGE_LIMIT_DEFAULT", 20) events = _run_search(0, 99, 50) assert len(events) == 50 assert {event["eventTime"] for event in events} == set(range(50)) def test_zero_events(http_mocker, mocker): """ Test behavior when there are zero events in the server. Given: - No events in the server. When: - search_events is called with any range and limit. Then: - An empty list is returned. """ http_mocker = HttpRequestsMocker(0) mocker.patch.object(Client, "_http_request", side_effect=http_mocker.valid_http_request_side_effect) mocker.patch.object(Client, "get_access_token", return_value="stub-token") events = Client( base_url="https://endpointcentral.manageengine.com", client_id="id", client_secret="secret", client_code="code" ).search_events("0", "100", 10) assert events == [] # ─────── Tests Real Response ──────────────────────────────────── class HttpRealMocker: """ Mocker http_request with real data response. """ def __init__(self, payload: dict): self.payload = payload self.served_first_page = False def __call__(self, method, url_suffix="", params=None, full_url="", **kw): # noqa: ANN001 if method.upper() == "POST" and full_url.endswith("/oauth/v2/token"): return {"access_token": "stub", "refresh_token": "rt", "expires_in": "3600"} if method.upper() == "GET" and url_suffix == "/emsapi/server/auditLogs": return self.payload return {} @pytest.fixture def client_with_real_payload(mocker): data = util_load_json() mocker.patch.object(Client, "_http_request", side_effect=HttpRealMocker(data)) return Client( base_url="https://endpointcentral.manageengine.com", client_id="id", client_secret="secret", client_code="code", ) class TestRealResponse: raw_data = util_load_json() def test_fetch_all_events_when_limit_exceeds_total(self, client_with_real_payload): """ Given: - The http_request returns 17 events. When: - `search_events` is called with limit=50. Then: - Exactly 17 events are returned, sorted ascending by `eventTime`. """ events = client_with_real_payload.search_events("0", "9999999999999", limit=50) assert len(events) == 17 assert events == sorted(events, key=lambda ev: int(ev["eventTime"])) def test_fetch_first_n_when_limit_smaller(self, client_with_real_payload): """ Given: - The http_request returns 17 events. When: - `search_events` is called with limit=5. Then: - Exactly 5 earliest events are returned, sorted ascending by `eventTime`. """ events = client_with_real_payload.search_events("0", "9999999999999", limit=5) all_sorted = sorted( client_with_real_payload.search_events("0", "9999999999999", 17), key=lambda ev: int(ev["eventTime"]), ) assert events == all_sorted[:5] def test_fetch_events_dedup_logic(self, client_with_real_payload, mocker): """ Given: - The `_http_request` stub returns 17 events. When: - `fetch_events` is executed. Then: - `next_run['last_time']` equals the newest `eventTime` in the fixture. - Exactly 17 events are returned. """ newest_ts = max(int(ev["eventTime"]) for ev in self.raw_data["messageResponse"]) mocker.patch.object(manage, "send_events_to_xsiam") # avoid side-effect next_run, events = manage.fetch_events( client=client_with_real_payload, last_run={"last_time": 0}, max_events_per_fetch=50, ) assert len(events) == 17 assert int(next_run["last_time"]) == newest_ts + 1