From c6604bbccbd4ad9c89eceadaa151c6bae3fcb905 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Mon, 29 Dec 2025 20:59:31 +0000 Subject: [PATCH 01/41] refactor: Use API version in URL building --- src/secops/chronicle/utils/request_utils.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 0cfe925..54444f6 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -25,7 +25,7 @@ def chronicle_paginated_request( client: "ChronicleClient", - base_url: str, + api_version: str, path: str, items_key: str, *, @@ -37,9 +37,10 @@ def chronicle_paginated_request( Args: client: ChronicleClient instance - base_url: The base URL to use, example: - - v1alpha (ChronicleClient.base_url) - - v1 (ChronicleClient.base_v1_url) + api_version: The API version to use, as a string. options: + - v1 (secops.chronicle.models.APIVersion.V1) + - v1alpha (secops.chronicle.models.APIVersion.V1ALPHA) + - v1beta (secops.chronicle.models.APIVersion.V1BETA) path: URL path after {base_url}/{instance_id}/ items_key: JSON key holding the array of items (e.g., 'curatedRules') page_size: Maximum number of rules to return per page. @@ -54,7 +55,7 @@ def chronicle_paginated_request( Raises: APIError: If the HTTP request fails. """ - url = f"{base_url}/{client.instance_id}/{path}" + url = f"{client.base_url(api_version)}/{client.instance_id}/{path}" results = [] next_token = page_token @@ -112,7 +113,10 @@ def chronicle_request( client: requests.Session (or compatible) instance method: HTTP method, e.g. 'GET', 'POST', 'PATCH' endpoint_path: URL path after {base_url}/{instance_id}/ - api_version: API version to use + api_version: The API version to use, as a string. options: + - v1 (secops.chronicle.models.APIVersion.V1) + - v1alpha (secops.chronicle.models.APIVersion.V1ALPHA) + - v1beta (secops.chronicle.models.APIVersion.V1BETA) params: Optional query parameters json: Optional JSON body expected_status: Expected HTTP status code (default: 200) From 8a475037f6b48dea00dcdb0b311f8130f689418d Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Mon, 29 Dec 2025 21:00:42 +0000 Subject: [PATCH 02/41] refactor: Update variable names --- src/secops/chronicle/watchlist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/secops/chronicle/watchlist.py b/src/secops/chronicle/watchlist.py index ee8327d..23c2d19 100644 --- a/src/secops/chronicle/watchlist.py +++ b/src/secops/chronicle/watchlist.py @@ -43,7 +43,7 @@ def list_watchlists( """ return chronicle_paginated_request( client, - base_url=client.base_url(APIVersion.V1), + api_version=client.base_url(APIVersion.V1), path="watchlists", items_key="watchlists", page_size=page_size, From d8b227c4b0ae2d9007eb398bb424f10abd835025 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Mon, 29 Dec 2025 21:29:43 +0000 Subject: [PATCH 03/41] refactor: Update variable names --- src/secops/chronicle/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index c96aedc..f7b1006 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -2380,7 +2380,7 @@ def list_curated_rule_set_deployments( page_token: str | None = None, only_enabled: bool | None = False, only_alerting: bool | None = False, - ) -> list[dict[str, Any]]: + ) -> dict[str, Any]: """Get a list of all curated rule set deployments. Args: @@ -2446,7 +2446,7 @@ def list_curated_rules( self, page_size: int | None = None, page_token: str | None = None, - ) -> list[dict[str, Any]]: + ) -> dict[str, Any]: """Get a list of all curated rules. Args: From e1b10125602e37949b8d3084a32d344920c42720 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Mon, 29 Dec 2025 21:30:02 +0000 Subject: [PATCH 04/41] refactor: Refactor to use request helpers --- src/secops/chronicle/rule_set.py | 294 ++++++++++++------------------- 1 file changed, 115 insertions(+), 179 deletions(-) diff --git a/src/secops/chronicle/rule_set.py b/src/secops/chronicle/rule_set.py index 8dea188..0a3676a 100644 --- a/src/secops/chronicle/rule_set.py +++ b/src/secops/chronicle/rule_set.py @@ -17,75 +17,19 @@ from datetime import datetime from typing import Any -from secops.chronicle.models import AlertState, ListBasis -from secops.exceptions import APIError, SecOpsError - - -def _paginated_request( - client, - path: str, - items_key: str, - *, - page_size: int | None = None, - page_token: str | None = None, - extra_params: dict[str, Any] | None = None, -) -> dict[str, Any]: - """ - Helper to get items from endpoints that use pagination. - - Args: - client: ChronicleClient instance - path: URL path after {base_url}/{instance_id}/ - items_key: JSON key holding the array of items (e.g., 'curatedRules') - page_size: Maximum number of rules to return per page. - page_token: Token for the next page of results, if available. - extra_params: extra query params to include on every request - - Returns: - Full response dict with items in items_key. - - If page_size is None: All items accumulated, no nextPageToken - - If page_size provided: Single page with nextPageToken - - Raises: - APIError: If the HTTP request fails. - """ - url = f"{client.base_url}/{client.instance_id}/{path}" - results = [] - next_token = page_token - last_response = {} - - while True: - params = {"pageSize": 1000 if not page_size else page_size} - if next_token: - params["pageToken"] = next_token - if extra_params: - params.update(dict(extra_params)) - - response = client.session.get(url, params=params) - if response.status_code != 200: - raise APIError(f"Failed to list {items_key}: {response.text}") - - data = response.json() - results.extend(data.get(items_key, [])) - last_response = data - - if page_size is not None: - return data - - next_token = data.get("nextPageToken") - if not next_token: - break - - last_response[items_key] = results - last_response.pop("nextPageToken", None) - return last_response +from secops.chronicle.models import AlertState, ListBasis, APIVersion +from secops.exceptions import SecOpsError +from secops.chronicle.utils.request_utils import ( + chronicle_request, + chronicle_paginated_request, +) def list_curated_rule_sets( client, page_size: str | None = None, page_token: str | None = None, -) -> list[dict[str, Any]] | dict[str, Any]: +) -> dict[str, Any]: """Get a list of all curated rule sets Args: @@ -101,17 +45,14 @@ def list_curated_rule_sets( Raises: APIError: If the API request fails """ - result = _paginated_request( + return chronicle_paginated_request( client, + api_version=APIVersion.V1ALPHA, path="curatedRuleSetCategories/-/curatedRuleSets", items_key="curatedRuleSets", page_size=page_size, page_token=page_token, ) - # Return full dict if page_size provided, else just the list - if page_size is not None: - return result - return result.get("curatedRuleSets", []) def get_curated_rule_set(client, rule_set_id: str) -> dict[str, Any]: @@ -127,23 +68,22 @@ def get_curated_rule_set(client, rule_set_id: str) -> dict[str, Any]: Raises: APIError: If the API request fails """ - base_url = ( - f"{client.base_url}/{client.instance_id}/" - f"curatedRuleSetCategories/-/curatedRuleSets/{rule_set_id}" + return chronicle_request( + client, + method="GET", + endpoint_path=( + f"curatedRuleSetCategories/-/" f"curatedRuleSets/{rule_set_id}" + ), + api_version=APIVersion.V1ALPHA, + error_message=f"Failed to get rule set: {rule_set_id}", ) - response = client.session.get(base_url) - if response.status_code != 200: - raise APIError(f"Failed to get rule set: {response.text}") - - return response.json() - def list_curated_rule_set_categories( client, page_size: str | None = None, page_token: str | None = None, -) -> list[dict[str, Any]] | dict[str, Any]: +) -> dict[str, Any]: """Get a list of all curated rule set categories Args: @@ -159,17 +99,14 @@ def list_curated_rule_set_categories( Raises: APIError: If the API request fails """ - result = _paginated_request( + return chronicle_paginated_request( client, + api_version=APIVersion.V1ALPHA, path="curatedRuleSetCategories", items_key="curatedRuleSetCategories", page_size=page_size, page_token=page_token, ) - # Return full dict if page_size provided, else just the list - if page_size is not None: - return result - return result.get("curatedRuleSetCategories", []) def get_curated_rule_set_category(client, category_id: str) -> dict[str, Any]: @@ -185,25 +122,20 @@ def get_curated_rule_set_category(client, category_id: str) -> dict[str, Any]: Raises: APIError: If the API request fails """ - base_url = ( - f"{client.base_url}/{client.instance_id}/" - f"curatedRuleSetCategories/{category_id}" + return chronicle_request( + client, + method="GET", + endpoint_path=f"curatedRuleSetCategories/{category_id}", + api_version=APIVersion.V1ALPHA, + error_message=f"Failed to get rule set category: {category_id}", ) - response = client.session.get(base_url) - if response.status_code != 200: - raise APIError( - f"Failed to get curated rule set category: {response.text}" - ) - - return response.json() - def list_curated_rules( client, page_size: str | None = None, page_token: str | None = None, -) -> list[dict[str, Any]] | dict[str, Any]: +) -> dict[str, Any]: """Get a list of all curated rules Args: @@ -219,17 +151,14 @@ def list_curated_rules( Raises: APIError: If the API request fails """ - result = _paginated_request( + return chronicle_paginated_request( client, + api_version=APIVersion.V1ALPHA, path="curatedRules", items_key="curatedRules", page_size=page_size, page_token=page_token, ) - # Return full dict if page_size provided, else just the list - if page_size is not None: - return result - return result.get("curatedRules", []) def get_curated_rule(client, rule_id: str) -> dict[str, Any]: @@ -249,16 +178,18 @@ def get_curated_rule(client, rule_id: str) -> dict[str, Any]: Raises: APIError: If the API request fails """ - base_url = f"{client.base_url}/{client.instance_id}/curatedRules/{rule_id}" - - response = client.session.get(base_url) - if response.status_code != 200: - raise APIError(f"Failed to get curated rule: {response.text}") - - return response.json() + return chronicle_request( + client, + method="GET", + endpoint_path=f"curatedRules/{rule_id}", + api_version=APIVersion.V1ALPHA, + error_message=f"Failed to get curated rule: {rule_id}", + ) -def get_curated_rule_by_name(client, display_name: str) -> dict[str, Any]: +def get_curated_rule_by_name( + client, display_name: str +) -> dict[str, Any] | None: """Get a curated rule by display name NOTE: This is a linear scan of all curated rules, so it may be inefficient for large rule sets. @@ -274,7 +205,7 @@ def get_curated_rule_by_name(client, display_name: str) -> dict[str, Any]: APIError: If the API request fails """ rule = None - for r in list_curated_rules(client): + for r in list_curated_rules(client).get("curatedRules", []): if r.get("displayName", "").lower() == display_name.lower(): rule = r break @@ -290,7 +221,7 @@ def list_curated_rule_set_deployments( page_token: str | None = None, only_enabled: bool | None = False, only_alerting: bool | None = False, -) -> list[dict[str, Any]] | dict[str, Any]: +) -> dict[str, Any]: """Get a list of all curated rule set deployment statuses Args: @@ -308,8 +239,9 @@ def list_curated_rule_set_deployments( Raises: APIError: If the API request fails """ - result = _paginated_request( + result = chronicle_paginated_request( client, + api_version=APIVersion.V1ALPHA, path="curatedRuleSetCategories/-/curatedRuleSets/" "-/curatedRuleSetDeployments", items_key="curatedRuleSetDeployments", @@ -329,7 +261,7 @@ def list_curated_rule_set_deployments( .split("curatedRuleSetDeployment")[0] .rstrip("/") ) - for rule_set in all_rule_sets: + for rule_set in all_rule_sets.get("curatedRuleSets", []): if rule_set.get("name", "") == rule_set_id: deployment["displayName"] = rule_set.get("displayName", "") # Apply filters for only enabled and/or alerting rule sets @@ -349,10 +281,7 @@ def list_curated_rule_set_deployments( # Update result with filtered deployments result["curatedRuleSetDeployments"] = rule_set_deployments - # Return full dict if page_size provided, else just the list - if page_size is not None: - return result - return rule_set_deployments + return result def get_curated_rule_set_deployment( @@ -379,23 +308,26 @@ def get_curated_rule_set_deployment( # Get the rule set by ID rule_set = get_curated_rule_set(client, rule_set_id) + print(rule_set) - url = ( - f'{client.base_url}/{rule_set.get("name", "")}/' - f"curatedRuleSetDeployments/{precision}" + rule_set_id = rule_set.get("name", "").split("curatedRuleSetCategories")[-1] + response = chronicle_request( + client, + method="GET", + endpoint_path=( + f"curatedRuleSetCategories{rule_set_id}" + f"/curatedRuleSetDeployments/{precision}" + ), + api_version=APIVersion.V1ALPHA, + error_message=( + f"Failed to get curated rule set deployment: {rule_set_id}" + ), ) - response = client.session.get(url) - if response.status_code != 200: - raise APIError( - f"Failed to get curated rule set deployment: {response.text}" - ) - # Enrich the deployment data with the rule set displayName - deployment = response.json() - deployment["displayName"] = rule_set.get("displayName", "") + response["displayName"] = rule_set.get("displayName", "") - return deployment + return response def get_curated_rule_set_deployment_by_name( @@ -423,7 +355,7 @@ def get_curated_rule_set_deployment_by_name( raise SecOpsError("Precision must be 'precise' or 'broad'") rule_set = None - for rs in list_curated_rule_sets(client): + for rs in list_curated_rule_sets(client).get("curatedRuleSets", []): # Names normalised as lowercase if rs.get("displayName", "").lower() == display_name.lower(): rule_set = rs @@ -440,6 +372,24 @@ def get_curated_rule_set_deployment_by_name( return get_curated_rule_set_deployment(client, rule_set_id, precision) +def _make_deployment_name( + category_id: str, rule_set_id: str, precision: str, instance_id: str = None +): + """Helper function to create a deployment name""" + if instance_id: + return ( + f"{instance_id}/curatedRuleSetCategories/{category_id}" + f"/curatedRuleSets/{rule_set_id}" + f"/curatedRuleSetDeployments/{precision}" + ) + else: + return ( + f"curatedRuleSetCategories/{category_id}" + f"/curatedRuleSets/{rule_set_id}" + f"/curatedRuleSetDeployments/{precision}" + ) + + def update_curated_rule_set_deployment( client, deployment: dict[str, Any] ) -> dict[str, Any]: @@ -480,28 +430,26 @@ def update_curated_rule_set_deployment( enabled = deployment["enabled"] alerting = deployment.get("alerting", False) - deployment_name = ( - f"{client.instance_id}/curatedRuleSetCategories/{category_id}" - f"/curatedRuleSets/{rule_set_id}" - f"/curatedRuleSetDeployments/{precision}" - ) + deployment_name = _make_deployment_name(category_id, rule_set_id, precision) deployment = { - "name": deployment_name, + "name": f"{client.instance_id}/{deployment_name}", "precision": precision, "enabled": enabled, "alerting": alerting, } - url = f"{client.base_url}/{deployment_name}" - - response = client.session.patch(url, json=deployment) - if response.status_code != 200: - raise APIError( - f"Failed to patch curated rule set deployment: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="PATCH", + endpoint_path=deployment_name, + api_version=APIVersion.V1ALPHA, + json=deployment, + expected_status=200, + error_message=( + f"Failed to patch curated rule set deployment: {deployment_name}" + ), + ) def batch_update_curated_rule_set_deployments( @@ -525,19 +473,6 @@ def batch_update_curated_rule_set_deployments( APIError: If the API request fails ValueError: If required fields are missing from the deployments """ - url = ( - f"{client.base_url}/{client.instance_id}/curatedRuleSetCategories/-" - "/curatedRuleSets/-/curatedRuleSetDeployments:batchUpdate" - ) - - # Helper function to create a deployment name - def make_deployment_name(category_id, rule_set_id, precision): - return ( - f"{client.instance_id}/curatedRuleSetCategories/{category_id}" - f"/curatedRuleSets/{rule_set_id}" - f"/curatedRuleSetDeployments/{precision}" - ) - # Build the request data request_items = [] @@ -563,8 +498,8 @@ def make_deployment_name(category_id, rule_set_id, precision): # Create the request item request_item = { "curated_rule_set_deployment": { - "name": make_deployment_name( - category_id, rule_set_id, precision + "name": _make_deployment_name( + category_id, rule_set_id, precision, client.instance_id ), "enabled": enabled, "alerting": alerting, @@ -585,14 +520,18 @@ def make_deployment_name(category_id, rule_set_id, precision): "requests": request_items, } - response = client.session.post(url, json=json_data) - - if response.status_code != 200: - raise APIError( - f"Failed to batch update rule set deployments: {response.text}" - ) - - return response.json() + return chronicle_request( + client, + method="POST", + endpoint_path=( + "curatedRuleSetCategories/-/curatedRuleSets" + "/-/curatedRuleSetDeployments:batchUpdate" + ), + api_version=APIVersion.V1ALPHA, + json=json_data, + expected_status=200, + error_message="Failed to batch update curated rule set deployments", + ) def search_curated_detections( @@ -689,15 +628,12 @@ def search_curated_detections( else "curatedDetections" ) - try: - return _paginated_request( - client, - path="legacy:legacySearchCuratedDetections", - items_key=items_key, - page_size=page_size, - page_token=page_token, - extra_params=extra_params, - ) - except Exception as e: - print(f"Error searching curated detections for rule " f"{rule_id}: {e}") - raise + return chronicle_paginated_request( + client, + api_version=APIVersion.V1ALPHA, + path="legacy:legacySearchCuratedDetections", + items_key=items_key, + page_size=page_size, + page_token=page_token, + extra_params=extra_params, + ) From f0dfb5aa884c0ee5a7aab6cb826708e4ddf123d7 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 06:55:03 +0000 Subject: [PATCH 05/41] refactor: Update return type --- src/secops/chronicle/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index f7b1006..c59de10 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -2340,7 +2340,7 @@ def list_curated_rule_sets( self, page_size: int | None = None, page_token: str | None = None, - ) -> list[dict[str, Any]]: + ) -> dict[str, Any]: """Get a list of all curated rule sets. Args: From ef3d6eb82f59deafb99e8e9f5f05f125951c8cc8 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 06:56:18 +0000 Subject: [PATCH 06/41] refactor: Refactor paginated request logic to fix issues --- src/secops/chronicle/utils/request_utils.py | 76 +++++++++++++++------ 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 54444f6..a7baf7e 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -32,9 +32,16 @@ def chronicle_paginated_request( page_size: int | None = None, page_token: str | None = None, extra_params: dict[str, Any] | None = None, -) -> dict[str, list[Any]] | list[Any]: +) -> dict[str, Any] | list[Any]: """Helper to get items from endpoints that use pagination. + Function behaviour: + - If `page_size` OR `page_token` is provided: a single page is returned with the + upstream JSON as-is, including all potential metadata. + - Else: auto-paginate responses until all pages are consumed, then return a single + object with the aggregated items and no token. The object will have the same shape + as the API response. + Args: client: ChronicleClient instance api_version: The API version to use, as a string. options: @@ -42,7 +49,7 @@ def chronicle_paginated_request( - v1alpha (secops.chronicle.models.APIVersion.V1ALPHA) - v1beta (secops.chronicle.models.APIVersion.V1BETA) path: URL path after {base_url}/{instance_id}/ - items_key: JSON key holding the array of items (e.g., 'curatedRules') + items_key: JSON key holding the array of items (e.g. 'curatedRules') page_size: Maximum number of rules to return per page. page_token: Token for the next page of results, if available. extra_params: extra query params to include on every request @@ -55,45 +62,70 @@ def chronicle_paginated_request( Raises: APIError: If the HTTP request fails. """ - url = f"{client.base_url(api_version)}/{client.instance_id}/{path}" - results = [] + # Determine if we should return a single page or aggregate results from all pages + single_page_mode = (page_size is not None) or (page_token is not None) + + effective_page_size = DEFAULT_PAGE_SIZE if page_size is None else page_size + + aggregated_results = [] + first_response_dict = None next_token = page_token while True: # Build params each loop to prevent stale keys being # included in the next request - params = {"pageSize": DEFAULT_PAGE_SIZE if not page_size else page_size} + params = {"pageSize": effective_page_size} if next_token: params["pageToken"] = next_token if extra_params: # copy to avoid passed dict being mutated params.update(dict(extra_params)) - response = client.session.get(url, params=params) - if response.status_code != 200: - raise APIError(f"Failed to list {items_key}: {response.text}") + data = chronicle_request( + client=client, + method="GET", + api_version=api_version, + endpoint_path=path, + params=params, + ) - data = response.json() - results.extend(data.get(items_key, [])) + if single_page_mode: + return data - # If caller provided page_size, return only this page - if page_size is not None: - break + # Return a list if the API returns a list + if isinstance(data, list): + return data + + if not isinstance(data, dict): + raise APIError( + f"Unexpected response type for {path}: {type(data).__name__}" + ) + + if first_response_dict is None: + first_response_dict = data + + page_results = data.get(items_key, []) + if page_results: + if not isinstance(page_results, list): + raise APIError( + f"Expected '{items_key}' to be a list for {path}, got {type(page_results).__name__}" + ) + aggregated_results.extend(page_results) - # Otherwise, auto-paginate next_token = data.get("nextPageToken") if not next_token: break - # Return a list if the API returns a list, otherwise return a dict - if isinstance(data, list): - return results - response = {items_key: results} - - if data.get("nextPageToken"): - response["nextPageToken"] = data.get("nextPageToken") + # Return a dict with the item key and an empty list if no results were returned + if first_response_dict is None: + return {items_key: aggregated_results} - return response + output = dict(first_response_dict) + # Build a dict object with the aggregated results using the key + output[items_key] = aggregated_results + # Remove nextPageToken from the response + output.pop("nextPageToken", None) + return output def chronicle_request( From 5ec0267d3b1dec751a4c824f88eeeff1b30e2016 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 09:26:17 +0000 Subject: [PATCH 07/41] refactor: Update to supply API version only instead of full base path --- src/secops/chronicle/watchlist.py | 2 +- tests/chronicle/test_watchlist.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/secops/chronicle/watchlist.py b/src/secops/chronicle/watchlist.py index 23c2d19..7854ef3 100644 --- a/src/secops/chronicle/watchlist.py +++ b/src/secops/chronicle/watchlist.py @@ -43,7 +43,7 @@ def list_watchlists( """ return chronicle_paginated_request( client, - api_version=client.base_url(APIVersion.V1), + api_version=APIVersion.V1, path="watchlists", items_key="watchlists", page_size=page_size, diff --git a/tests/chronicle/test_watchlist.py b/tests/chronicle/test_watchlist.py index 10e0d64..37b7707 100644 --- a/tests/chronicle/test_watchlist.py +++ b/tests/chronicle/test_watchlist.py @@ -90,7 +90,7 @@ def test_list_watchlists_success(chronicle_client): mock_paginated.assert_called_once_with( chronicle_client, - base_url=chronicle_client.base_url(APIVersion.V1), + api_version=APIVersion.V1, path="watchlists", items_key="watchlists", page_size=10, @@ -112,7 +112,7 @@ def test_list_watchlists_default_args(chronicle_client): mock_paginated.assert_called_once_with( chronicle_client, - base_url=chronicle_client.base_url(APIVersion.V1), + api_version=APIVersion.V1, path="watchlists", items_key="watchlists", page_size=None, From e5e97ee98131fa689a188b305dcbfce3c05c02e5 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 09:26:48 +0000 Subject: [PATCH 08/41] refactor: Update tests to account for use of request helper functions --- tests/chronicle/test_rule_set.py | 1109 ++++++++---------------------- 1 file changed, 283 insertions(+), 826 deletions(-) diff --git a/tests/chronicle/test_rule_set.py b/tests/chronicle/test_rule_set.py index 8ebacd6..8443223 100644 --- a/tests/chronicle/test_rule_set.py +++ b/tests/chronicle/test_rule_set.py @@ -12,22 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Tests for Chronicle curated rule set functions.""" +# """Tests for Chronicle curated rule set functions.""" + +from datetime import datetime, timezone +from unittest.mock import patch, Mock import pytest -from datetime import datetime, timedelta, timezone -from typing import Optional -from unittest.mock import Mock, patch + from secops.chronicle.client import ChronicleClient +from secops.chronicle.models import APIVersion, AlertState, ListBasis from secops.chronicle.rule_set import ( - _paginated_request, get_curated_rule, - list_curated_rules, get_curated_rule_by_name, - get_curated_rule_set, - get_curated_rule_set_category, + list_curated_rules, list_curated_rule_sets, list_curated_rule_set_categories, + get_curated_rule_set, + get_curated_rule_set_category, list_curated_rule_set_deployments, get_curated_rule_set_deployment, get_curated_rule_set_deployment_by_name, @@ -35,475 +36,304 @@ batch_update_curated_rule_set_deployments, search_curated_detections, ) -from secops.chronicle.models import AlertState, ListBasis -from secops.exceptions import APIError, SecOpsError +from secops.exceptions import SecOpsError @pytest.fixture def chronicle_client(): - """Create a Chronicle client for testing.""" - with patch("secops.auth.SecOpsAuth") as mock_auth: - mock_session = Mock() - mock_session.headers = {} - mock_auth.return_value.session = mock_session - return ChronicleClient( - customer_id="test-customer", project_id="test-project" - ) + # Construct a client without exercising auth/session behaviour + client = Mock(spec=ChronicleClient) + client.instance_id = "instances/instance-1" + return client -@pytest.fixture -def mock_response(): - """Create a mock API response object.""" - mock = Mock() - mock.status_code = 200 - # Default return value, can be overridden in specific tests - mock.json.return_value = {} - return mock +# ----------------------------------------------------------------------------- +# Simple wrappers around chronicle_request() +# ----------------------------------------------------------------------------- -@pytest.fixture -def mock_error_response(): - """Create a mock error API response object.""" - mock = Mock() - mock.status_code = 400 - mock.text = "Error message" - mock.raise_for_status.side_effect = Exception( - "API Error" - ) # To simulate requests.exceptions.HTTPError - return mock - - -# --- get_curated_rule tests --- -def test_get_curated_rule_success(chronicle_client, mock_response): - """Test get_curated_rule returns the JSON for a curated rule when the request succeeds.""" - mock_response.json.return_value = { - "name": "projects/test-project/locations/us/curatedRules/ur_abc-123", - "displayName": "Test ABC 123", - } - with patch.object( - chronicle_client.session, "get", return_value=mock_response - ) as mocked_request: - result = get_curated_rule(chronicle_client, "ur_abc-123") - assert result == mock_response.json.return_value - # Verify URL - expected_url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - f"curatedRules/ur_abc-123" - ) - mocked_request.assert_called_once_with(expected_url) +def test_get_curated_rule_success(chronicle_client): + expected = {"name": "curatedRules/ur_abc-123", "displayName": "Rule X"} + with patch( + "secops.chronicle.rule_set.chronicle_request", return_value=expected + ) as req: + out = get_curated_rule(chronicle_client, "ur_abc-123") + assert out == expected + + req.assert_called_once() + kwargs = req.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["api_version"] == APIVersion.V1ALPHA + assert kwargs["endpoint_path"] == "curatedRules/ur_abc-123" + assert ( + "Failed to get curated rule: ur_abc-123" in kwargs["error_message"] + ) -def test_get_curated_rule_error(chronicle_client, mock_error_response): - """Test get_curated_rule raises APIError when the API returns non-200.""" - # Arrange - with patch.object( - chronicle_client.session, "get", return_value=mock_error_response - ): - # Act and Assert - with pytest.raises(APIError): - get_curated_rule(chronicle_client, "ur_abc-123") +def test_get_curated_rule_set_success(chronicle_client): + expected = {"name": "whatever", "displayName": "Ruleset 1"} -# --- helpers --- + with patch( + "secops.chronicle.rule_set.chronicle_request", return_value=expected + ) as req: + out = get_curated_rule_set(chronicle_client, "crs_1") + assert out == expected + kwargs = req.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["api_version"] == APIVersion.V1ALPHA + # rule_set.py builds the endpoint path; assert the important suffix + assert kwargs["endpoint_path"].endswith("curatedRuleSets/crs_1") -def _page(items_key: str, items: list[dict], next_token: Optional[str] = None): - """Helper function for paginated 200 OK responses.""" - data = {items_key: items} - if next_token: - data["nextPageToken"] = next_token - resp = Mock() - resp.status_code = 200 - resp.json.return_value = data - return resp +def test_get_curated_rule_set_category_success(chronicle_client): + expected = { + "name": "curatedRuleSetCategories/cat_1", + "displayName": "Category 1", + } -# --- _paginated_request tests --- + with patch( + "secops.chronicle.rule_set.chronicle_request", return_value=expected + ) as req: + out = get_curated_rule_set_category(chronicle_client, "cat_1") + assert out == expected + kwargs = req.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["api_version"] == APIVersion.V1ALPHA + assert kwargs["endpoint_path"] == "curatedRuleSetCategories/cat_1" -def test_paginated_request_auto_paginates_success(chronicle_client): - p1 = _page("curatedRules", [{"name": ".../ur_1"}], next_token="t2") - p2 = _page("curatedRules", [{"name": ".../ur_2"}]) - with patch.object( - chronicle_client.session, "get", side_effect=[p1, p2] - ) as mocked: - result = _paginated_request( - chronicle_client, - path="curatedRules", - items_key="curatedRules", - page_size=None, - ) - assert [r["name"] for r in result.get("curatedRules")] == [ - ".../ur_1", - ".../ur_2", - ] - base = f"{chronicle_client.base_url}/{chronicle_client.instance_id}/curatedRules" - assert mocked.call_args_list[0].args[0] == base - assert mocked.call_args_list[0].kwargs["params"] == {"pageSize": 1000} - assert mocked.call_args_list[1].kwargs["params"] == { - "pageSize": 1000, - "pageToken": "t2", - } - - -def test_paginated_request_when_page_size_given_success(chronicle_client): - p1 = _page("curatedRules", [{"name": ".../ur_1"}], next_token="t2") - with patch.object( - chronicle_client.session, "get", return_value=p1 - ) as mocked: - result = _paginated_request( - chronicle_client, - path="curatedRules", - items_key="curatedRules", - page_size=1000, - ) - assert [r["name"] for r in result.get("curatedRules")] == [".../ur_1"] - # Only one call, no follow-up with nextPageToken - assert mocked.call_count == 1 - assert mocked.call_args.kwargs["params"] == {"pageSize": 1000} +# ----------------------------------------------------------------------------- +# Simple wrappers around chronicle_paginated_request() +# ----------------------------------------------------------------------------- -def test_paginated_request_error(chronicle_client, mock_error_response): - """Test helper function _paginated_request raises APIError on HTTP errors.""" - with patch.object( - chronicle_client.session, "get", return_value=mock_error_response - ): - with pytest.raises(APIError): - _paginated_request( - chronicle_client, - path="curatedRules", - items_key="curatedRules", - ) +def test_list_curated_rules_success(chronicle_client): + expected = { + "curatedRules": [{"name": "curatedRules/a"}], + "nextPageToken": "t2", + } -# --- list_curated_rule_sets & list_curated_rule_set_categories function tests --- -def test_list_curated_rules_success(chronicle_client, mock_response): - """Test list_curated_rules""" - mock_response.json.return_value = {"curatedRules": [{"name": "n1"}]} - with patch.object( - chronicle_client.session, "get", return_value=mock_response - ): - rules = list_curated_rules(chronicle_client, page_size=50) - assert rules == {"curatedRules": [{"name": "n1"}]} + with patch( + "secops.chronicle.rule_set.chronicle_paginated_request", + return_value=expected, + ) as paged: + out = list_curated_rules( + chronicle_client, page_size=100, page_token="t1" + ) + assert out == expected + kwargs = paged.call_args.kwargs + assert kwargs["api_version"] == APIVersion.V1ALPHA + assert kwargs["path"] == "curatedRules" + assert kwargs["items_key"] == "curatedRules" + assert kwargs["page_size"] == 100 + assert kwargs["page_token"] == "t1" -def test_list_curated_rules_error(chronicle_client, mock_error_response): - """Test list_curated_rules failure.""" - with patch.object( - chronicle_client.session, "get", return_value=mock_error_response - ): - with pytest.raises(APIError): - list_curated_rules(chronicle_client) - - -def test_list_curated_rule_sets_and_categories_success( - chronicle_client, mock_response -): - """Test the two list_ functions.""" - mock_response.json.side_effect = [ - {"curatedRuleSets": [{"name": "rs1"}]}, - {"curatedRuleSetCategories": [{"name": "cat1"}]}, - ] - with patch.object( - chronicle_client.session, "get", return_value=mock_response - ) as mocked_response: - rule_sets = list_curated_rule_sets(chronicle_client) - categories = list_curated_rule_set_categories(chronicle_client) - assert rule_sets == [{"name": "rs1"}] - assert categories == [{"name": "cat1"}] - # ensure two calls happened - assert mocked_response.call_count == 2 - - -def test_list_curated_rule_sets_and_categories_error( - chronicle_client, mock_error_response -): - """Test the two list_ functions error correctly.""" - with patch.object( - chronicle_client.session, "get", return_value=mock_error_response - ): - with pytest.raises(APIError): - list_curated_rule_sets(chronicle_client) - list_curated_rule_set_categories(chronicle_client) +def test_list_curated_rule_sets_success(chronicle_client): + expected = {"curatedRuleSets": [{"name": "rs1"}]} -# --- get_curated_rule_by_name tests--- + with patch( + "secops.chronicle.rule_set.chronicle_paginated_request", + return_value=expected, + ) as paged: + out = list_curated_rule_sets(chronicle_client) + assert out == expected + kwargs = paged.call_args.kwargs + assert kwargs["path"] == "curatedRuleSetCategories/-/curatedRuleSets" + assert kwargs["items_key"] == "curatedRuleSets" -def test_get_curated_rule_by_name_success(chronicle_client): - """Test get_curated_rule_by_name returns the rule matching displayName (case-insensitive).""" - p = _page( - "curatedRules", - [ - {"displayName": "Alpha", "name": ".../ur_A"}, - {"displayName": "Bravo", "name": ".../ur_B"}, - ], - ) - with patch.object(chronicle_client.session, "get", return_value=p): - out = get_curated_rule_by_name(chronicle_client, "bravo") - assert out["name"].endswith("ur_B") +def test_list_curated_rule_set_categories_success(chronicle_client): + expected = {"curatedRuleSetCategories": [{"name": "cat1"}]} -def test_get_curated_rule_by_name_error(chronicle_client): - """Test get_curated_rule_by_name raises SecOpsError when not found.""" - p = _page("curatedRules", [{"displayName": "Alpha"}]) - with patch.object(chronicle_client.session, "get", return_value=p): - with pytest.raises(SecOpsError): - get_curated_rule_by_name(chronicle_client, "charlie") + with patch( + "secops.chronicle.rule_set.chronicle_paginated_request", + return_value=expected, + ) as paged: + out = list_curated_rule_set_categories(chronicle_client) + assert out == expected + kwargs = paged.call_args.kwargs + assert kwargs["path"] == "curatedRuleSetCategories" + assert kwargs["items_key"] == "curatedRuleSetCategories" -# --- get_curated_rule_set tests --- +# ----------------------------------------------------------------------------- +# Functions that transform/search returned data +# ----------------------------------------------------------------------------- -def test_get_curated_rule_set_success(chronicle_client, mock_response): - """Test get_curated_rule_set returns the rule set matching name.""" - mock_response.json.return_value = {"name": ".../curatedRuleSets/crs_1"} - with patch.object( - chronicle_client.session, "get", return_value=mock_response - ) as get_: - out = get_curated_rule_set(chronicle_client, "crs_1") - assert out["name"].endswith("/crs_1") - expected = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - "curatedRuleSetCategories/-/curatedRuleSets/crs_1" - ) - get_.assert_called_once() - assert get_.call_args.args[0] == expected +def test_get_curated_rule_by_name_success(chronicle_client): + all_rules = { + "curatedRules": [ + {"displayName": "Alpha", "name": "curatedRules/a"}, + {"displayName": "Bravo", "name": "curatedRules/b"}, + ] + } -def test_get_curated_rule_set_error(chronicle_client, mock_error_response): - """Test get_curated_rule_set raises APIError on HTTP errors.""" - with patch.object( - chronicle_client.session, "get", return_value=mock_error_response + with patch( + "secops.chronicle.rule_set.list_curated_rules", return_value=all_rules ): - with pytest.raises(APIError): - get_curated_rule_set(chronicle_client, "crs_1") + out = get_curated_rule_by_name(chronicle_client, "bravo") + assert out["name"] == "curatedRules/b" -# --- get_curated_rule_set_category tests --- -def test_get_curated_rule_set_category_success(chronicle_client, mock_response): - mock_response.json.return_value = { - "name": ".../curatedRuleSetCategories/cat_1" +def test_get_curated_rule_by_name_not_found(chronicle_client): + all_rules = { + "curatedRules": [{"displayName": "Alpha", "name": "curatedRules/a"}] } - with patch.object( - chronicle_client.session, "get", return_value=mock_response - ) as get_: - out = get_curated_rule_set_category(chronicle_client, "cat_1") - assert out["name"].endswith("/cat_1") - expected = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}/" - "curatedRuleSetCategories/cat_1" - ) - assert get_.call_args.args[0] == expected - -def test_get_curated_rule_set_category_error( - chronicle_client, mock_error_response -): - with patch.object( - chronicle_client.session, "get", return_value=mock_error_response + with patch( + "secops.chronicle.rule_set.list_curated_rules", return_value=all_rules ): - with pytest.raises(APIError): - get_curated_rule_set_category(chronicle_client, "cat_1") - - -# --- list_curated_rule_set_deployments --- + with pytest.raises(SecOpsError): + get_curated_rule_by_name(chronicle_client, "missing") -def test_list_deployments_success(chronicle_client): - """Test list_curated_rule_set_deployments enriches deployments with displayName and respects filters.""" - deployments_page = _page( - "curatedRuleSetDeployments", - [ +def test_list_curated_rule_set_deployments_adds_display_names(chronicle_client): + deployments = { + "curatedRuleSetDeployments": [ { - "name": f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_1/curatedRuleSetDeployments/precise", + "name": "instances/instance-1/curatedRuleSetCategories/c1/curatedRuleSets/crs_1/curatedRuleSetDeployments/precise", "enabled": True, "alerting": False, }, { - "name": f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_2/curatedRuleSetDeployments/broad", + "name": "instances/instance-1/curatedRuleSetCategories/c1/curatedRuleSets/crs_2/curatedRuleSetDeployments/broad", "enabled": False, "alerting": True, }, - ], - ) - rulesets_page = _page( - "curatedRuleSets", - [ + ] + } + rule_sets = { + "curatedRuleSets": [ { - "name": f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_1", + "name": "instances/instance-1/curatedRuleSetCategories/c1/curatedRuleSets/crs_1", "displayName": "One", }, { - "name": f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_2", + "name": "instances/instance-1/curatedRuleSetCategories/c1/curatedRuleSets/crs_2", "displayName": "Two", }, - ], - ) + ] + } - # First: list deployments & list rulesets for enrichment - with patch.object( - chronicle_client.session, - "get", - side_effect=[deployments_page, rulesets_page], - ): - out = list_curated_rule_set_deployments(chronicle_client) - names = {d["displayName"] for d in out} - assert names == {"One", "Two"} - - # Now verify only_enabled and only_alerting filters - with patch.object( - chronicle_client.session, - "get", - side_effect=[deployments_page, rulesets_page], + with patch( + "secops.chronicle.rule_set.chronicle_paginated_request", + return_value=deployments, ): - out_enabled = list_curated_rule_set_deployments( - chronicle_client, only_enabled=True - ) - assert len(out_enabled) == 1 and out_enabled[0]["displayName"] == "One" + with patch( + "secops.chronicle.rule_set.list_curated_rule_sets", + return_value=rule_sets, + ): + out = list_curated_rule_set_deployments(chronicle_client) - deployments_page_alerting = _page( - "curatedRuleSetDeployments", - [ + items = out["curatedRuleSetDeployments"] + assert {d["displayName"] for d in items} == {"One", "Two"} + + +def test_list_curated_rule_set_deployments_filters_enabled(chronicle_client): + deployments = { + "curatedRuleSetDeployments": [ { - "name": f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_1/curatedRuleSetDeployments/precise", + "name": "instances/instance-1/curatedRuleSetCategories/c1/curatedRuleSets/crs_1/curatedRuleSetDeployments/precise", "enabled": True, - "alerting": False, }, { - "name": f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_2/curatedRuleSetDeployments/broad", + "name": "instances/instance-1/curatedRuleSetCategories/c1/curatedRuleSets/crs_2/curatedRuleSetDeployments/broad", "enabled": False, - "alerting": True, }, - ], - ) - rulesets_page_alerting = _page( - "curatedRuleSets", - [ - { - "name": f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_1", - "displayName": "One", - }, - { - "name": f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_2", - "displayName": "Two", - }, - ], - ) + ] + } + rule_sets = {"curatedRuleSets": []} - with patch.object( - chronicle_client.session, - "get", - side_effect=[deployments_page_alerting, rulesets_page_alerting], + with patch( + "secops.chronicle.rule_set.chronicle_paginated_request", + return_value=deployments, ): - out_alerting = list_curated_rule_set_deployments( - chronicle_client, only_alerting=True - ) - assert ( - len(out_alerting) == 1 and out_alerting[0]["displayName"] == "Two" - ) - + with patch( + "secops.chronicle.rule_set.list_curated_rule_sets", + return_value=rule_sets, + ): + out = list_curated_rule_set_deployments( + chronicle_client, only_enabled=True + ) -# --- get_curated_rule_set_deployment --- + assert len(out["curatedRuleSetDeployments"]) == 1 + assert out["curatedRuleSetDeployments"][0]["enabled"] is True -def test_get_ruleset_deployment_success(chronicle_client, mock_response): - """Test get_curated_rule_set_deployment returns the deployment matching name.""" - ruleset = Mock() - ruleset.status_code = 200 - ruleset.json.return_value = { - "name": f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_1", +def test_get_curated_rule_set_deployment_success(chronicle_client): + # First call returns the ruleset (for displayName), second returns the deployment details + rule_set = { + "name": "instances/instance-1/curatedRuleSetCategories/c1/curatedRuleSets/crs_1", "displayName": "My Ruleset", } + deployment = {"enabled": True, "alerting": False} - deployment = Mock() - deployment.status_code = 200 - deployment.json.return_value = {"enabled": True, "alerting": False} + with patch( + "secops.chronicle.rule_set.get_curated_rule_set", return_value=rule_set + ): + with patch( + "secops.chronicle.rule_set.chronicle_request", + return_value=deployment, + ) as req: + out = get_curated_rule_set_deployment( + chronicle_client, "crs_1", "precise" + ) - with patch.object( - chronicle_client.session, "get", side_effect=[ruleset, deployment] - ) as mocked_request: - out = get_curated_rule_set_deployment( - chronicle_client, "crs_1", "precise" - ) - assert out["displayName"] == "My Ruleset" + assert out["enabled"] is True + assert out["displayName"] == "My Ruleset" - dep_url = ( - f"{chronicle_client.base_url}/" - f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_1/" - "curatedRuleSetDeployments/precise" - ) - assert mocked_request.call_args_list[1].args[0] == dep_url + kwargs = req.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["api_version"] == APIVersion.V1ALPHA + assert kwargs["endpoint_path"].endswith("curatedRuleSetDeployments/precise") -def test_get_ruleset_deployment_error_invalid_precision(chronicle_client): - """Test get_curated_rule_set_deployment failure.""" +def test_get_curated_rule_set_deployment_invalid_precision(chronicle_client): with pytest.raises(SecOpsError): get_curated_rule_set_deployment(chronicle_client, "crs_1", "medium") -def test_get_ruleset_deployment_ruleset_error_not_found(chronicle_client): - """Test get_curated_rule_set_deployment failure when ruleset ID doesn't exist.""" - not_found = Mock() - not_found.status_code = 404 - not_found.text = "Not found" - - with patch.object(chronicle_client.session, "get", return_value=not_found): - with pytest.raises(APIError): - get_curated_rule_set_deployment( - chronicle_client, "crs_404", "precise" - ) - - -# --- get_curated_rule_set_deployment_by_name --- - - -def test_get_ruleset_deployment_by_name_success(chronicle_client): - """Test get_curated_rule_set_deployment_by_name success.""" - rulesets_data = [ - { - "name": f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/curatedRuleSets/crs_1", - "displayName": "Case Insensitive Name", - } - ] - deployment = Mock() - deployment.status_code = 200 - deployment.json.return_value = {"enabled": True} +def test_get_curated_rule_set_deployment_by_name_success(chronicle_client): + rule_sets = { + "curatedRuleSets": [ + { + "name": "instances/instance-1/curatedRuleSetCategories/c1/curatedRuleSets/crs_1", + "displayName": "Ruleset A", + }, + ] + } + deployment = {"enabled": True} with patch( "secops.chronicle.rule_set.list_curated_rule_sets", - return_value=rulesets_data, + return_value=rule_sets, ): - with patch.object( - chronicle_client.session, "get", return_value=deployment - ): + with patch( + "secops.chronicle.rule_set.get_curated_rule_set_deployment", + return_value=deployment, + ) as g: out = get_curated_rule_set_deployment_by_name( - chronicle_client, "case insensitive name", "broad" + chronicle_client, "ruleset a", "broad" ) - assert out["enabled"] is True + assert out == deployment + g.assert_called_once() -def test_get_ruleset_deployment_by_name_error(chronicle_client): - """Test get_curated_rule_set_deployment_by_name failure.""" - rulesets = _page("curatedRuleSets", [{"displayName": "Other"}]) - with patch.object(chronicle_client.session, "get", return_value=rulesets): - with pytest.raises(SecOpsError): - get_curated_rule_set_deployment_by_name(chronicle_client, "missing") - - -# --- update_curated_rule_set_deployment --- +def test_update_curated_rule_set_deployment_success(chronicle_client): + expected = {"ok": True} -def test_update_ruleset_deployment_success( - chronicle_client, -): - """Test update_curated_rule_set_deployment builds the correct PATCH payload and URL.""" - patch_resp = Mock() - patch_resp.status_code = 200 - patch_resp.json.return_value = {"ok": True} - with patch.object( - chronicle_client.session, "patch", return_value=patch_resp - ) as mocked_request: + with patch( + "secops.chronicle.rule_set.chronicle_request", return_value=expected + ) as req: out = update_curated_rule_set_deployment( chronicle_client, { @@ -514,66 +344,24 @@ def test_update_ruleset_deployment_success( "alerting": False, }, ) - assert out == {"ok": True} - name = ( - f"{chronicle_client.instance_id}/curatedRuleSetCategories/c1/" - "curatedRuleSets/crs_1/curatedRuleSetDeployments/precise" - ) - expected_url = f"{chronicle_client.base_url}/{name}" - mocked_request.assert_called_once() - assert mocked_request.call_args.args[0] == expected_url - assert mocked_request.call_args.kwargs["json"] == { - "name": name, - "precision": "precise", - "enabled": True, - "alerting": False, - } - - -def test_update_ruleset_deployment_error_missing_fields(chronicle_client): - """Test update_curated_rule_set_deployment failure.""" - with pytest.raises(ValueError): - update_curated_rule_set_deployment( - chronicle_client, - { - "category_id": "c1", - # 'rule_set_id' missing - "precision": "precise", - "enabled": True, - }, - ) - - -def test_update_ruleset_deployment_error_http( - chronicle_client, mock_error_response -): - """Test update_curated_rule_set_deployment failure.""" - with patch.object( - chronicle_client.session, "patch", return_value=mock_error_response - ): - with pytest.raises(APIError): - update_curated_rule_set_deployment( - chronicle_client, - { - "category_id": "c1", - "rule_set_id": "crs_1", - "precision": "precise", - "enabled": True, - }, - ) + assert out == expected + kwargs = req.call_args.kwargs + assert kwargs["method"] == "PATCH" + assert kwargs["api_version"] == APIVersion.V1ALPHA + assert ( + kwargs["endpoint_path"] + == "curatedRuleSetCategories/c1/curatedRuleSets/crs_1/curatedRuleSetDeployments/precise" + ) + assert kwargs["json"]["enabled"] is True -# --- batch_update_curated_rule_set_deployments --- +def test_batch_update_curated_rule_set_deployments_success(chronicle_client): + expected = {"status": "ok"} -def test_batch_update_curated_rule_set_success(chronicle_client): - """Test batch_update_curated_rule_set_deployments success.""" - post_resp = Mock() - post_resp.status_code = 200 - post_resp.json.return_value = {"status": "ok"} - with patch.object( - chronicle_client.session, "post", return_value=post_resp - ) as post_: + with patch( + "secops.chronicle.rule_set.chronicle_request", return_value=expected + ) as req: out = batch_update_curated_rule_set_deployments( chronicle_client, [ @@ -592,416 +380,85 @@ def test_batch_update_curated_rule_set_success(chronicle_client): }, ], ) - assert out == {"status": "ok"} - - # Inspect payload - payload = post_.call_args.kwargs["json"] - assert payload["parent"].startswith( - chronicle_client.instance_id + "/curatedRuleSetCategories/-" - ) - reqs = payload["requests"] - assert len(reqs) == 2 - assert reqs[0]["curated_rule_set_deployment"]["enabled"] is True - assert reqs[0]["curated_rule_set_deployment"]["alerting"] is True - assert reqs[0]["update_mask"]["paths"] == ["alerting", "enabled"] - - -def test_batch_update_curated_rule_set_error_missing_fields(chronicle_client): - """Test batch_update_curated_rule_set_deployments failure.""" - with pytest.raises(ValueError): - batch_update_curated_rule_set_deployments( - chronicle_client, - [ - { - "category_id": "c1", - "precision": "precise", - "enabled": True, - }, # rule_set_id missing - ], - ) + assert out == expected + kwargs = req.call_args.kwargs + assert kwargs["method"] == "POST" + assert kwargs["api_version"] == APIVersion.V1ALPHA + assert ( + kwargs["endpoint_path"] + == "curatedRuleSetCategories/-/curatedRuleSets/-/curatedRuleSetDeployments:batchUpdate" + ) -def test_batch_update_curated_rule_set_error_http( - chronicle_client, mock_error_response -): - """Test batch_update_curated_rule_set_deployments failure.""" - with patch.object( - chronicle_client.session, "post", return_value=mock_error_response - ): - with pytest.raises(APIError): - batch_update_curated_rule_set_deployments( - chronicle_client, - [ - { - "category_id": "c1", - "rule_set_id": "r1", - "precision": "precise", - "enabled": True, - }, - ], - ) +# ----------------------------------------------------------------------------- +# search_curated_detections() — validate params and items_key selection +# ----------------------------------------------------------------------------- -# --- search_curated_detections tests --- +def test_search_curated_detections_builds_params(chronicle_client): + expected = {"curatedDetections": [{"id": "d1"}]} + start = datetime(2024, 1, 1, tzinfo=timezone.utc) + end = datetime(2024, 1, 2, tzinfo=timezone.utc) -def test_search_curated_detections_success_with_results(chronicle_client): - """Test search_curated_detections returns detections successfully.""" - detection_page = _page( - "curatedDetections", - [ - { - "id": "det_123", - "detectionTime": "2024-01-15T10:00:00Z", - "ruleId": "ur_abc123", - }, - { - "id": "det_456", - "detectionTime": "2024-01-15T11:00:00Z", - "ruleId": "ur_abc123", - }, - ], - ) - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - end_time = datetime.now(timezone.utc) - start_time = end_time - timedelta(days=7) - result = search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - start_time=start_time, - end_time=end_time, - list_basis="DETECTION_TIME", - alert_state="ALERTING", - ) - assert "curatedDetections" in result - assert len(result["curatedDetections"]) == 2 - assert result["curatedDetections"][0]["id"] == "det_123" - # Verify URL and params - expected_url = ( - f"{chronicle_client.base_url}/" - f"{chronicle_client.instance_id}/" - f"legacy:legacySearchCuratedDetections" - ) - mocked.assert_called_once() - assert mocked.call_args.args[0] == expected_url - params = mocked.call_args.kwargs["params"] - assert params["ruleId"] == "ur_abc123" - assert params["listBasis"] == "DETECTION_TIME" - assert params["alertState"] == "ALERTING" - assert "startTime" in params - assert "endTime" in params - - -def test_search_curated_detections_success_empty_results(chronicle_client): - """Test search_curated_detections with no detections found.""" - detection_page = _page("curatedDetections", []) - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ): - result = search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - ) - assert "curatedDetections" in result - assert len(result["curatedDetections"]) == 0 - - -def test_search_curated_detections_with_enums(chronicle_client): - """Test search_curated_detections using enum values.""" - detection_page = _page( - "curatedDetections", - [{"id": "det_789", "detectionTime": "2024-01-15T12:00:00Z"}], - ) - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - result = search_curated_detections( + with patch( + "secops.chronicle.rule_set.chronicle_paginated_request", + return_value=expected, + ) as paged: + out = search_curated_detections( chronicle_client, - rule_id="ur_xyz789", + rule_id="ur_1", + start_time=start, + end_time=end, list_basis=ListBasis.DETECTION_TIME, alert_state=AlertState.ALERTING, ) - assert len(result["curatedDetections"]) == 1 - # Verify enum values converted to strings - params = mocked.call_args.kwargs["params"] - assert params["listBasis"] == "DETECTION_TIME" - assert params["alertState"] == "ALERTING" - - -def test_search_curated_detections_with_nested_detections( - chronicle_client, -): - """Test search_curated_detections with nested detections enabled.""" - detection_page = _page( - "nestedDetectionSamples", - [ - { - "id": "det_nested_1", - "detectionTime": "2024-01-15T10:00:00Z", - "nestedDetections": [ - {"id": "nested_1a"}, - {"id": "nested_1b"}, - ], - } - ], - ) - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - result = search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - include_nested_detections=True, - ) - assert "nestedDetectionSamples" in result - assert len(result["nestedDetectionSamples"]) == 1 - # Verify includeNestedDetections param - params = mocked.call_args.kwargs["params"] - assert params["includeNestedDetections"] is True - - -def test_search_curated_detections_with_pagination(chronicle_client): - """Test search_curated_detections with manual pagination.""" - detection_page = _page( - "curatedDetections", - [{"id": "det_1"}], - next_token="next_page_token", - ) - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ): - result = search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - page_size=10, - ) - assert "curatedDetections" in result - assert len(result["curatedDetections"]) == 1 - assert result["nextPageToken"] == "next_page_token" - - -def test_search_curated_detections_auto_pagination(chronicle_client): - """Test search_curated_detections with auto-pagination.""" - p1 = _page("curatedDetections", [{"id": "det_1"}], next_token="page2") - p2 = _page("curatedDetections", [{"id": "det_2"}]) - with patch.object( - chronicle_client.session, "get", side_effect=[p1, p2] - ) as mocked: - result = search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - ) - assert len(result["curatedDetections"]) == 2 - assert result["curatedDetections"][0]["id"] == "det_1" - assert result["curatedDetections"][1]["id"] == "det_2" - assert "nextPageToken" not in result - assert mocked.call_count == 2 - - -def test_search_curated_detections_with_max_resp_size( - chronicle_client, -): - """Test search_curated_detections with max response size limit.""" - detection_page = _page("curatedDetections", [{"id": "det_1"}]) - detection_page.json.return_value["respTooLargeDetectionsTruncated"] = True - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - result = search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - max_resp_size_bytes=1048576, - ) - assert result["respTooLargeDetectionsTruncated"] is True - params = mocked.call_args.kwargs["params"] - assert params["maxRespSizeBytes"] == 1048576 - - -def test_search_curated_detections_with_page_token(chronicle_client): - """Test search_curated_detections with page_token for continuation.""" - detection_page = _page("curatedDetections", [{"id": "det_2"}]) - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - result = search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - page_size=10, - page_token="existing_token", - ) - assert len(result["curatedDetections"]) == 1 - params = mocked.call_args.kwargs["params"] - assert params["pageToken"] == "existing_token" - - -def test_search_curated_detections_minimal_params(chronicle_client): - """Test search_curated_detections with only required parameters.""" - detection_page = _page("curatedDetections", [{"id": "det_1"}]) - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - result = search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - ) - assert "curatedDetections" in result - params = mocked.call_args.kwargs["params"] - assert params["ruleId"] == "ur_abc123" - assert params["listBasis"] == "DETECTION_TIME" - assert "alertState" not in params - assert "startTime" not in params - assert "endTime" not in params - - -def test_search_curated_detections_with_all_filter_types( - chronicle_client, -): - """Test search_curated_detections with all list_basis types.""" - detection_page = _page("curatedDetections", [{"id": "det_1"}]) - - # Test DETECTION_TIME - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis=ListBasis.DETECTION_TIME, - ) - params = mocked.call_args.kwargs["params"] - assert params["listBasis"] == "DETECTION_TIME" - # Test CREATED_TIME - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis=ListBasis.CREATED_TIME, - ) - params = mocked.call_args.kwargs["params"] - assert params["listBasis"] == "CREATED_TIME" + assert out == expected + kwargs = paged.call_args.kwargs + assert kwargs["path"] == "legacy:legacySearchCuratedDetections" + assert kwargs["items_key"] == "curatedDetections" + params = kwargs["extra_params"] + assert params["ruleId"] == "ur_1" + assert params["listBasis"] == "DETECTION_TIME" + assert params["alertState"] == "ALERTING" + assert params["startTime"].endswith("Z") + assert params["endTime"].endswith("Z") -def test_search_curated_detections_with_all_alert_states( - chronicle_client, -): - """Test search_curated_detections with all alert_state types.""" - detection_page = _page("curatedDetections", [{"id": "det_1"}]) +def test_search_curated_detections_nested_switches_items_key(chronicle_client): + expected = {"nestedDetectionSamples": [{"id": "n1"}]} - # Test ALERTING - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - alert_state=AlertState.ALERTING, - ) - params = mocked.call_args.kwargs["params"] - assert params["alertState"] == "ALERTING" - - # Test NOT_ALERTING - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - search_curated_detections( + with patch( + "secops.chronicle.rule_set.chronicle_paginated_request", + return_value=expected, + ) as paged: + out = search_curated_detections( chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - alert_state=AlertState.NOT_ALERTING, + rule_id="ur_1", + include_nested_detections=True, ) - params = mocked.call_args.kwargs["params"] - assert params["alertState"] == "NOT_ALERTING" - - -def test_search_curated_detections_error_api_failure( - chronicle_client, mock_error_response -): - """Test search_curated_detections raises APIError on API failure.""" - with patch.object( - chronicle_client.session, "get", return_value=mock_error_response - ): - with pytest.raises(APIError): - search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - ) - -def test_search_curated_detections_error_invalid_list_basis( - chronicle_client, -): - """Test search_curated_detections raises ValueError for invalid - list_basis.""" - with pytest.raises(ValueError, match="Invalid list_basis"): - search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="INVALID_BASIS", - ) + assert out == expected + assert paged.call_args.kwargs["items_key"] == "nestedDetectionSamples" + assert ( + paged.call_args.kwargs["extra_params"]["includeNestedDetections"] + is True + ) -def test_search_curated_detections_error_invalid_alert_state( - chronicle_client, -): - """Test search_curated_detections raises ValueError for invalid - alert_state.""" - with pytest.raises(ValueError, match="Invalid alert_state"): +def test_search_curated_detections_invalid_list_basis(chronicle_client): + with pytest.raises(ValueError): search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - alert_state="INVALID_STATE", + chronicle_client, rule_id="ur_1", list_basis="INVALID" ) -def test_search_curated_detections_none_alert_state_allowed( - chronicle_client, -): - """Test search_curated_detections allows None for alert_state.""" - detection_page = _page("curatedDetections", [{"id": "det_1"}]) - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - result = search_curated_detections( - chronicle_client, - rule_id="ur_abc123", - list_basis="DETECTION_TIME", - alert_state=None, - ) - assert "curatedDetections" in result - params = mocked.call_args.kwargs["params"] - assert "alertState" not in params - - -def test_search_curated_detections_time_format(chronicle_client): - """Test search_curated_detections formats time correctly.""" - detection_page = _page("curatedDetections", [{"id": "det_1"}]) - with patch.object( - chronicle_client.session, "get", return_value=detection_page - ) as mocked: - end_time = datetime(2024, 1, 15, 23, 59, 59, 999999, timezone.utc) - start_time = datetime(2024, 1, 1, 0, 0, 0, 0, timezone.utc) +def test_search_curated_detections_invalid_alert_state(chronicle_client): + with pytest.raises(ValueError): search_curated_detections( chronicle_client, - rule_id="ur_abc123", - start_time=start_time, - end_time=end_time, + rule_id="ur_1", list_basis="DETECTION_TIME", + alert_state="INVALID", ) - params = mocked.call_args.kwargs["params"] - assert params["startTime"] == "2024-01-01T00:00:00.000000Z" - assert params["endTime"] == "2024-01-15T23:59:59.999999Z" From 600c05519835f0e198b85f629bce3ee13c2ca2f3 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 09:45:53 +0000 Subject: [PATCH 09/41] feat: Added tests for request_utils.py --- tests/chronicle/utils/test_request_utils.py | 299 ++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 tests/chronicle/utils/test_request_utils.py diff --git a/tests/chronicle/utils/test_request_utils.py b/tests/chronicle/utils/test_request_utils.py new file mode 100644 index 0000000..d07e39f --- /dev/null +++ b/tests/chronicle/utils/test_request_utils.py @@ -0,0 +1,299 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# """Tests for request helper functions.""" +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock + +import pytest + +from secops.chronicle.models import APIVersion +from secops.chronicle.utils.request_utils import ( + DEFAULT_PAGE_SIZE, + chronicle_request, + chronicle_paginated_request, +) +from secops.exceptions import APIError + + +@pytest.fixture +def client() -> Mock: + # Construct mocked ChronicleClient + client = Mock() + client.instance_id = "instances/instance-1" + client.base_url = Mock(return_value="https://example.test/chronicle") + client.session = Mock() + return client + + +def _mock_response( + *, + status_code: int = 200, + json_value: Any | None = None, + json_raises: bool = False, + text: str = "", +) -> Mock: + response = Mock() + response.status_code = status_code + response.text = text + + if json_raises: + response.json.side_effect = ValueError("non-json") + else: + response.json.return_value = json_value + + return response + + +# --------------------------------------------------------------------------- +# chronicle_request() tests +# --------------------------------------------------------------------------- + +def test_chronicle_request_success_json(client: Mock) -> None: + # Test successful JSON response + response = _mock_response(status_code=200, json_value={"ok": True}) + client.session.request.return_value = response + + output = chronicle_request( + client=client, + method="GET", + endpoint_path="curatedRules", + api_version=APIVersion.V1ALPHA, + params={"pageSize": 10}, + ) + + assert output == {"ok": True} + + client.base_url.assert_called_once_with(APIVersion.V1ALPHA) + client.session.request.assert_called_once_with( + method="GET", + url="https://example.test/chronicle/instances/instance-1/curatedRules", + params={"pageSize": 10}, + json=None, + ) + + +def test_chronicle_request_non_json_body_raises(client: Mock) -> None: + # Test that a non-JSON body response raises an error + response = _mock_response(status_code=200, json_raises=True, text="not json") + client.session.request.return_value = response + + with pytest.raises(APIError, match="Expected JSON response"): + chronicle_request( + client=client, + method="GET", + endpoint_path="curatedRules", + api_version=APIVersion.V1ALPHA, + ) + + +def test_chronicle_request_status_mismatch_with_json_includes_json(client: Mock) -> None: + # Test that a non-expected status with a JSON body raises an error + response = _mock_response(status_code=400, json_value={"error": "bad"}) + client.session.request.return_value = response + + with pytest.raises(APIError, match=r"API request failed: status=400, response=\{'error': 'bad'\}"): + chronicle_request( + client=client, + method="GET", + endpoint_path="curatedRules", + api_version=APIVersion.V1ALPHA, + ) + + +def test_chronicle_request_status_mismatch_non_json_includes_text(client: Mock) -> None: + # Test that a non-expected status without a JSON body raises an error + response = _mock_response(status_code=500, json_raises=True, text="boom") + client.session.request.return_value = response + + with pytest.raises(APIError, match=r"API request failed: status=500, response_text=boom"): + chronicle_request( + client=client, + method="GET", + endpoint_path="curatedRules", + api_version=APIVersion.V1ALPHA, + ) + + +def test_chronicle_request_custom_error_message_used(client: Mock) -> None: + # Test that a custom error message is returned when provided + response = _mock_response(status_code=404, json_value={"message": "not found"}) + client.session.request.return_value = response + + with pytest.raises(APIError, match=r"Failed to get curated rule: status=404"): + chronicle_request( + client=client, + method="GET", + endpoint_path="curatedRules/ur_1", + api_version=APIVersion.V1ALPHA, + error_message="Failed to get curated rule", + ) + + +# --------------------------------------------------------------------------- +# chronicle_paginated_request() tests +# --------------------------------------------------------------------------- + +def test_paginated_request_single_page_mode_page_size_returns_upstream_json(client: Mock) -> None: + # Test single_page_mode triggers when page_size is provided + response = _mock_response(status_code=200, json_value={"items": [1], "nextPageToken": "t2"}) + client.session.request.return_value = response + + output = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="items", + page_size=10, + ) + + assert output == {"items": [1], "nextPageToken": "t2"} + + client.session.request.assert_called_once() + _, kwargs = client.session.request.call_args + assert kwargs["params"] == {"pageSize": 10} + + +def test_paginated_request_single_page_mode_page_token_returns_upstream_json(client: Mock) -> None: + # Test single_page_mode triggers when page_token is provided + response = _mock_response(status_code=200, json_value={"items": [1], "nextPageToken": "t2"}) + client.session.request.return_value = response + + output = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="items", + page_token="t1", + ) + + assert output == {"items": [1], "nextPageToken": "t2"} + + _, kwargs = client.session.request.call_args + assert kwargs["params"] == {"pageSize": DEFAULT_PAGE_SIZE, "pageToken": "t1"} + + +def test_paginated_request_auto_paginates_aggregates_items_and_removes_token(client: Mock) -> None: + # Test auto-pagination when both page_size and page_token are None + resp1 = _mock_response( + status_code=200, + json_value={"curatedRules": [{"id": 1}], "nextPageToken": "t2", "meta": {"x": 1}}, + ) + resp2 = _mock_response( + status_code=200, + json_value={"curatedRules": [{"id": 2}], "meta": {"x": 1}}, + ) + client.session.request.side_effect = [resp1, resp2] + + output = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="curatedRules", + ) + + # Shape preserved from first response, but items aggregated, token removed + assert output["meta"] == {"x": 1} + assert output["curatedRules"] == [{"id": 1}, {"id": 2}] + assert "nextPageToken" not in output + + assert client.session.request.call_count == 2 + call1 = client.session.request.call_args_list[0].kwargs + call2 = client.session.request.call_args_list[1].kwargs + assert call1["params"] == {"pageSize": DEFAULT_PAGE_SIZE} + assert call2["params"] == {"pageSize": DEFAULT_PAGE_SIZE, "pageToken": "t2"} + + +def test_paginated_request_auto_mode_list_response_returns_list(client: Mock) -> None: + # Test that if upstream returns a top-level list, the helper returns it immediately (no pagination possible) + response = _mock_response(status_code=200, json_value=[{"id": 1}, {"id": 2}]) + client.session.request.return_value = response + + output = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="feeds", + items_key="feeds", + ) + + assert output == [{"id": 1}, {"id": 2}] + assert client.session.request.call_count == 1 + + +def test_paginated_request_unexpected_response_type_raises(client: Mock) -> None: + # Test that an unexpected response type returns an error + response = _mock_response(status_code=200, json_value="not a dict or list") + client.session.request.return_value = response + + with pytest.raises(APIError, match=r"Unexpected response type for curatedRules: str"): + chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="curatedRules", + ) + + +def test_paginated_request_items_key_not_list_raises(client: Mock) -> None: + # Test that an incorrect items_key raises an error + response = _mock_response(status_code=200, json_value={"curatedRules": {"id": 1}}) + client.session.request.return_value = response + + with pytest.raises(APIError, match=r"Expected 'curatedRules' to be a list"): + chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="curatedRules", + ) + + +def test_paginated_request_no_results_returns_dict_with_empty_list(client: Mock) -> None: + # Test that no results gets returned as a dict with an empty list + response = _mock_response(status_code=200, json_value={"curatedRules": []}) + client.session.request.return_value = response + + output = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="curatedRules", + ) + + assert output["curatedRules"] == [] + assert "nextPageToken" not in output + + +def test_paginated_request_extra_params_not_mutated(client: Mock) -> None: + # Test that extra params provided don't get mutated + extra = {"filter": "x"} + response = _mock_response(status_code=200, json_value={"curatedRules": []}) + client.session.request.return_value = response + + chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="curatedRules", + extra_params=extra, + ) + + # Ensure we didn't mutate the caller's dict + assert extra == {"filter": "x"} + + # Ensure merged into params as expected + _, kwargs = client.session.request.call_args + assert kwargs["params"] == {"pageSize": DEFAULT_PAGE_SIZE, "filter": "x"} From 2d2141993bf0128ac5ecc04c24421c9a52ddde98 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 09:51:29 +0000 Subject: [PATCH 10/41] chore: Update formatting --- tests/chronicle/utils/test_request_utils.py | 86 ++++++++++++++++----- 1 file changed, 66 insertions(+), 20 deletions(-) diff --git a/tests/chronicle/utils/test_request_utils.py b/tests/chronicle/utils/test_request_utils.py index d07e39f..094031f 100644 --- a/tests/chronicle/utils/test_request_utils.py +++ b/tests/chronicle/utils/test_request_utils.py @@ -62,6 +62,7 @@ def _mock_response( # chronicle_request() tests # --------------------------------------------------------------------------- + def test_chronicle_request_success_json(client: Mock) -> None: # Test successful JSON response response = _mock_response(status_code=200, json_value={"ok": True}) @@ -88,7 +89,9 @@ def test_chronicle_request_success_json(client: Mock) -> None: def test_chronicle_request_non_json_body_raises(client: Mock) -> None: # Test that a non-JSON body response raises an error - response = _mock_response(status_code=200, json_raises=True, text="not json") + response = _mock_response( + status_code=200, json_raises=True, text="not json" + ) client.session.request.return_value = response with pytest.raises(APIError, match="Expected JSON response"): @@ -100,12 +103,17 @@ def test_chronicle_request_non_json_body_raises(client: Mock) -> None: ) -def test_chronicle_request_status_mismatch_with_json_includes_json(client: Mock) -> None: +def test_chronicle_request_status_mismatch_with_json_includes_json( + client: Mock, +) -> None: # Test that a non-expected status with a JSON body raises an error response = _mock_response(status_code=400, json_value={"error": "bad"}) client.session.request.return_value = response - with pytest.raises(APIError, match=r"API request failed: status=400, response=\{'error': 'bad'\}"): + with pytest.raises( + APIError, + match=r"API request failed: status=400, response=\{'error': 'bad'\}", + ): chronicle_request( client=client, method="GET", @@ -114,12 +122,16 @@ def test_chronicle_request_status_mismatch_with_json_includes_json(client: Mock) ) -def test_chronicle_request_status_mismatch_non_json_includes_text(client: Mock) -> None: +def test_chronicle_request_status_mismatch_non_json_includes_text( + client: Mock, +) -> None: # Test that a non-expected status without a JSON body raises an error response = _mock_response(status_code=500, json_raises=True, text="boom") client.session.request.return_value = response - with pytest.raises(APIError, match=r"API request failed: status=500, response_text=boom"): + with pytest.raises( + APIError, match=r"API request failed: status=500, response_text=boom" + ): chronicle_request( client=client, method="GET", @@ -130,10 +142,14 @@ def test_chronicle_request_status_mismatch_non_json_includes_text(client: Mock) def test_chronicle_request_custom_error_message_used(client: Mock) -> None: # Test that a custom error message is returned when provided - response = _mock_response(status_code=404, json_value={"message": "not found"}) + response = _mock_response( + status_code=404, json_value={"message": "not found"} + ) client.session.request.return_value = response - with pytest.raises(APIError, match=r"Failed to get curated rule: status=404"): + with pytest.raises( + APIError, match=r"Failed to get curated rule: status=404" + ): chronicle_request( client=client, method="GET", @@ -147,9 +163,14 @@ def test_chronicle_request_custom_error_message_used(client: Mock) -> None: # chronicle_paginated_request() tests # --------------------------------------------------------------------------- -def test_paginated_request_single_page_mode_page_size_returns_upstream_json(client: Mock) -> None: + +def test_paginated_request_single_page_mode_page_size_returns_upstream_json( + client: Mock, +) -> None: # Test single_page_mode triggers when page_size is provided - response = _mock_response(status_code=200, json_value={"items": [1], "nextPageToken": "t2"}) + response = _mock_response( + status_code=200, json_value={"items": [1], "nextPageToken": "t2"} + ) client.session.request.return_value = response output = chronicle_paginated_request( @@ -167,9 +188,13 @@ def test_paginated_request_single_page_mode_page_size_returns_upstream_json(clie assert kwargs["params"] == {"pageSize": 10} -def test_paginated_request_single_page_mode_page_token_returns_upstream_json(client: Mock) -> None: +def test_paginated_request_single_page_mode_page_token_returns_upstream_json( + client: Mock, +) -> None: # Test single_page_mode triggers when page_token is provided - response = _mock_response(status_code=200, json_value={"items": [1], "nextPageToken": "t2"}) + response = _mock_response( + status_code=200, json_value={"items": [1], "nextPageToken": "t2"} + ) client.session.request.return_value = response output = chronicle_paginated_request( @@ -183,14 +208,23 @@ def test_paginated_request_single_page_mode_page_token_returns_upstream_json(cli assert output == {"items": [1], "nextPageToken": "t2"} _, kwargs = client.session.request.call_args - assert kwargs["params"] == {"pageSize": DEFAULT_PAGE_SIZE, "pageToken": "t1"} + assert kwargs["params"] == { + "pageSize": DEFAULT_PAGE_SIZE, + "pageToken": "t1", + } -def test_paginated_request_auto_paginates_aggregates_items_and_removes_token(client: Mock) -> None: +def test_paginated_request_auto_paginates_aggregates_items_and_removes_token( + client: Mock, +) -> None: # Test auto-pagination when both page_size and page_token are None resp1 = _mock_response( status_code=200, - json_value={"curatedRules": [{"id": 1}], "nextPageToken": "t2", "meta": {"x": 1}}, + json_value={ + "curatedRules": [{"id": 1}], + "nextPageToken": "t2", + "meta": {"x": 1}, + }, ) resp2 = _mock_response( status_code=200, @@ -217,9 +251,13 @@ def test_paginated_request_auto_paginates_aggregates_items_and_removes_token(cli assert call2["params"] == {"pageSize": DEFAULT_PAGE_SIZE, "pageToken": "t2"} -def test_paginated_request_auto_mode_list_response_returns_list(client: Mock) -> None: +def test_paginated_request_auto_mode_list_response_returns_list( + client: Mock, +) -> None: # Test that if upstream returns a top-level list, the helper returns it immediately (no pagination possible) - response = _mock_response(status_code=200, json_value=[{"id": 1}, {"id": 2}]) + response = _mock_response( + status_code=200, json_value=[{"id": 1}, {"id": 2}] + ) client.session.request.return_value = response output = chronicle_paginated_request( @@ -233,12 +271,16 @@ def test_paginated_request_auto_mode_list_response_returns_list(client: Mock) -> assert client.session.request.call_count == 1 -def test_paginated_request_unexpected_response_type_raises(client: Mock) -> None: +def test_paginated_request_unexpected_response_type_raises( + client: Mock, +) -> None: # Test that an unexpected response type returns an error response = _mock_response(status_code=200, json_value="not a dict or list") client.session.request.return_value = response - with pytest.raises(APIError, match=r"Unexpected response type for curatedRules: str"): + with pytest.raises( + APIError, match=r"Unexpected response type for curatedRules: str" + ): chronicle_paginated_request( client=client, api_version=APIVersion.V1ALPHA, @@ -249,7 +291,9 @@ def test_paginated_request_unexpected_response_type_raises(client: Mock) -> None def test_paginated_request_items_key_not_list_raises(client: Mock) -> None: # Test that an incorrect items_key raises an error - response = _mock_response(status_code=200, json_value={"curatedRules": {"id": 1}}) + response = _mock_response( + status_code=200, json_value={"curatedRules": {"id": 1}} + ) client.session.request.return_value = response with pytest.raises(APIError, match=r"Expected 'curatedRules' to be a list"): @@ -261,7 +305,9 @@ def test_paginated_request_items_key_not_list_raises(client: Mock) -> None: ) -def test_paginated_request_no_results_returns_dict_with_empty_list(client: Mock) -> None: +def test_paginated_request_no_results_returns_dict_with_empty_list( + client: Mock, +) -> None: # Test that no results gets returned as a dict with an empty list response = _mock_response(status_code=200, json_value={"curatedRules": []}) client.session.request.return_value = response From 0ea15efc5897adfd97437ab05610a794c145edf2 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 20:16:09 +0000 Subject: [PATCH 11/41] feat: Implement optional return as list --- src/secops/chronicle/client.py | 22 ++- src/secops/chronicle/rule_set.py | 25 ++- src/secops/chronicle/utils/request_utils.py | 182 +++++++++++++++++++- tests/chronicle/utils/test_request_utils.py | 134 ++++++++++++++ 4 files changed, 347 insertions(+), 16 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index c59de10..942b65c 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -2340,12 +2340,14 @@ def list_curated_rule_sets( self, page_size: int | None = None, page_token: str | None = None, - ) -> dict[str, Any]: + as_list: bool = False, + ) -> list[dict[str, Any]] | dict[str, Any]: """Get a list of all curated rule sets. Args: page_size: Number of results to return per page page_token: Token for the page to retrieve + as_list: Whether to return the list of curated rule sets as a list instead of a dict Returns: Dictionary containing the list of curated rule sets @@ -2353,18 +2355,20 @@ def list_curated_rule_sets( Raises: APIError: If the API request fails """ - return _list_curated_rule_sets(self, page_size, page_token) + return _list_curated_rule_sets(self, page_size, page_token, as_list) def list_curated_rule_set_categories( self, page_size: int | None = None, page_token: str | None = None, - ) -> list[dict[str, Any]]: + as_list: bool = False, + ) -> list[dict[str, Any]] | dict[str, Any]: """Get a list of all curated rule set categories. Args: page_size: Number of results to return per page page_token: Token for the page to retrieve + as_list: Whether to return the list of curated rule set categories as a list instead of a dict Returns: Dictionary containing the list of curated rule set categories @@ -2372,7 +2376,9 @@ def list_curated_rule_set_categories( Raises: APIError: If the API request fails """ - return _list_curated_rule_set_categories(self, page_size, page_token) + return _list_curated_rule_set_categories( + self, page_size, page_token, as_list + ) def list_curated_rule_set_deployments( self, @@ -2380,6 +2386,7 @@ def list_curated_rule_set_deployments( page_token: str | None = None, only_enabled: bool | None = False, only_alerting: bool | None = False, + as_list: bool = False, ) -> dict[str, Any]: """Get a list of all curated rule set deployments. @@ -2388,6 +2395,7 @@ def list_curated_rule_set_deployments( page_token: Token for the page to retrieve only_enabled: Only return enabled rule set deployments only_alerting: Only return alerting rule set deployments + as_list: Whether to return the list of curated rule set deployments as a list instead of a dict Returns: Dictionary containing the list of curated rule set deployments @@ -2396,7 +2404,7 @@ def list_curated_rule_set_deployments( APIError: If the API request fails """ return _list_curated_rule_set_deployments( - self, page_size, page_token, only_enabled, only_alerting + self, page_size, page_token, only_enabled, only_alerting, as_list ) def get_curated_rule_set_deployment( @@ -2446,12 +2454,14 @@ def list_curated_rules( self, page_size: int | None = None, page_token: str | None = None, + as_list: bool = False, ) -> dict[str, Any]: """Get a list of all curated rules. Args: page_size: Number of results to return per page page_token: Token for the page to retrieve + as_list: Whether to return the list of curated rules as a list instead of a dict Returns: Dictionary containing the list of curated rules @@ -2459,7 +2469,7 @@ def list_curated_rules( Raises: APIError: If the API request fails """ - return _list_curated_rules(self, page_size, page_token) + return _list_curated_rules(self, page_size, page_token, as_list) def get_curated_rule(self, rule_id: str) -> dict[str, Any]: """Get a curated rule by ID. diff --git a/src/secops/chronicle/rule_set.py b/src/secops/chronicle/rule_set.py index 0a3676a..ec648de 100644 --- a/src/secops/chronicle/rule_set.py +++ b/src/secops/chronicle/rule_set.py @@ -29,13 +29,16 @@ def list_curated_rule_sets( client, page_size: str | None = None, page_token: str | None = None, -) -> dict[str, Any]: + as_list: bool = False, +) -> dict[str, Any] | list[dict[str, Any]]: """Get a list of all curated rule sets Args: client: ChronicleClient instance page_size: Number of results to return per page. page_token: Token for the page to retrieve + as_list: If True, return a list of curated rule sets + instead of a dict with curatedRuleSets list and nextPageToken. Returns: If page_size is None: List of all curated rule sets. @@ -52,6 +55,7 @@ def list_curated_rule_sets( items_key="curatedRuleSets", page_size=page_size, page_token=page_token, + as_list=as_list, ) @@ -83,13 +87,16 @@ def list_curated_rule_set_categories( client, page_size: str | None = None, page_token: str | None = None, -) -> dict[str, Any]: + as_list: bool = False, +) -> dict[str, Any] | list[dict[str, Any]]: """Get a list of all curated rule set categories Args: client: ChronicleClient instance page_size: Number of results to return per page. page_token: Token for the page to retrieve + as_list: If True, return a list of curated rule set categories + instead of a dict with curatedRuleSetCategories list and nextPageToken. Returns: If page_size is None: List of all categories. @@ -106,6 +113,7 @@ def list_curated_rule_set_categories( items_key="curatedRuleSetCategories", page_size=page_size, page_token=page_token, + as_list=as_list, ) @@ -135,13 +143,16 @@ def list_curated_rules( client, page_size: str | None = None, page_token: str | None = None, -) -> dict[str, Any]: + as_list: bool = False, +) -> dict[str, Any] | list[dict[str, Any]]: """Get a list of all curated rules Args: client: ChronicleClient instance page_size: Number of results to return per page. page_token: Token for the page to retrieve + as_list: If True, return a list of curated rules + instead of a dict with curatedRules list and nextPageToken. Returns: If page_size is None: List of all curated rules. @@ -158,6 +169,7 @@ def list_curated_rules( items_key="curatedRules", page_size=page_size, page_token=page_token, + as_list=as_list, ) @@ -221,7 +233,8 @@ def list_curated_rule_set_deployments( page_token: str | None = None, only_enabled: bool | None = False, only_alerting: bool | None = False, -) -> dict[str, Any]: + as_list: bool = False, +) -> dict[str, Any] | list[dict[str, Any]]: """Get a list of all curated rule set deployment statuses Args: @@ -230,6 +243,8 @@ def list_curated_rule_set_deployments( page_token: Token for the page to retrieve only_enabled: Only return enabled rule set deployments only_alerting: Only return alerting rule set deployments + as_list: If True, return a list of curated rule set deployments + instead of a dict with curatedRuleSetDeployments list and nextPageToken. Returns: If page_size is None: List of all deployments. @@ -247,6 +262,7 @@ def list_curated_rule_set_deployments( items_key="curatedRuleSetDeployments", page_size=page_size, page_token=page_token, + as_list=as_list, ) # Extract deployments from response @@ -308,7 +324,6 @@ def get_curated_rule_set_deployment( # Get the rule set by ID rule_set = get_curated_rule_set(client, rule_set_id) - print(rule_set) rule_set_id = rule_set.get("name", "").split("curatedRuleSetCategories")[-1] response = chronicle_request( diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index a7baf7e..8390366 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -22,6 +22,143 @@ DEFAULT_PAGE_SIZE = 1000 +# def chronicle_paginated_request( +# client: "ChronicleClient", +# api_version: str, +# path: str, +# items_key: str, +# *, +# page_size: int | None = None, +# page_token: str | None = None, +# extra_params: dict[str, Any] | None = None, +# list_only: bool = False, +# ) -> dict[str, Any] | list[Any]: +# """Helper to get items from endpoints that use pagination. +# +# Function behaviour: +# - If `page_size` OR `page_token` is provided: single page is returned. +# - list_only=False: return upstream JSON as-is (dict or list) +# - list_only=True: return only the list of items (drops metadata/tokens) +# - Else: auto-paginate until all pages are consumed. +# - list_only=False: return dict shaped like first response with aggregated items and no token +# - list_only=True: return aggregated flat list (drops metadata/tokens) +# +# Notes: +# - list_only=True intentionally discards pagination metadata (e.g. nextPageToken). +# If callers need page tokens, they should use list_only=False in single-page mode. +# """ +# single_page_mode = (page_size is not None) or (page_token is not None) +# effective_page_size = DEFAULT_PAGE_SIZE if page_size is None else page_size +# +# aggregated_results: list[Any] = [] +# first_response_dict: dict[str, Any] | None = None +# next_token = page_token +# +# while True: +# params: dict[str, Any] = {"pageSize": effective_page_size} +# if next_token: +# params["pageToken"] = next_token +# if extra_params: +# params.update(dict(extra_params)) +# +# data = chronicle_request( +# client=client, +# method="GET", +# api_version=api_version, +# endpoint_path=path, +# params=params, +# ) +# +# # --- Single-page mode: return immediately --- +# if single_page_mode: +# if not list_only: +# return data +# +# # list_only=True: extract list from dict responses, or pass through lists +# if isinstance(data, list): +# return data +# if isinstance(data, dict): +# page_results = data.get(items_key, []) +# if page_results and not isinstance(page_results, list): +# raise APIError( +# f"Expected '{items_key}' to be a list for {path}, got {type(page_results).__name__}" +# ) +# return page_results +# raise APIError(f"Unexpected response type for {path}: {type(data).__name__}") +# +# # --- Auto-pagination mode --- +# if isinstance(data, list): +# # Top-level list responses can't expose nextPageToken; return as-is +# return data +# +# if not isinstance(data, dict): +# raise APIError(f"Unexpected response type for {path}: {type(data).__name__}") +# +# if first_response_dict is None: +# first_response_dict = data +# +# page_results = data.get(items_key, []) +# if page_results: +# if not isinstance(page_results, list): +# raise APIError( +# f"Expected '{items_key}' to be a list for {path}, got {type(page_results).__name__}" +# ) +# aggregated_results.extend(page_results) +# +# next_token = data.get("nextPageToken") +# if not next_token: +# break +# +# # If list_only=True, return just the aggregated list. +# if list_only: +# return aggregated_results +# +# # No dict pages? Return minimal dict. +# if first_response_dict is None: +# return {items_key: aggregated_results} +# +# output = dict(first_response_dict) +# output[items_key] = aggregated_results +# output.pop("nextPageToken", None) +# return output +# +# +# def chronicle_request( +# client: "ChronicleClient", +# method: str, +# endpoint_path: str, +# *, +# api_version: str = APIVersion.V1, +# params: dict[str, Any] | None = None, +# json: dict[str, Any] | None = None, +# expected_status: int = 200, +# error_message: str | None = None, +# ) -> dict[str, Any]: +# # (unchanged) +# url = f"{client.base_url(api_version)}/{client.instance_id}/{endpoint_path}" +# response = client.session.request(method=method, url=url, params=params, json=json) +# +# try: +# data = response.json() +# except ValueError: +# data = None +# +# if response.status_code != expected_status: +# base_msg = error_message or "API request failed" +# if data is not None: +# raise APIError(f"{base_msg}: status={response.status_code}, response={data}") from None +# +# raise APIError( +# f"{base_msg}: status={response.status_code}, response_text={response.text}" +# ) from None +# +# if data is None: +# raise APIError( +# f"Expected JSON response from {url} but got non-JSON body: {response.text}" +# ) +# +# return data + def chronicle_paginated_request( client: "ChronicleClient", @@ -32,15 +169,23 @@ def chronicle_paginated_request( page_size: int | None = None, page_token: str | None = None, extra_params: dict[str, Any] | None = None, + as_list: bool = False, ) -> dict[str, Any] | list[Any]: """Helper to get items from endpoints that use pagination. Function behaviour: - If `page_size` OR `page_token` is provided: a single page is returned with the upstream JSON as-is, including all potential metadata. - - Else: auto-paginate responses until all pages are consumed, then return a single - object with the aggregated items and no token. The object will have the same shape - as the API response. + - If `as_list` is True, return only the list of items (drops metadata/tokens) + - If `as_list` is False, return the upstream JSON as-is (dict or list) + - Else: auto-paginate responses until all pages are consumed: + - If `as_list` is True, return a list of items directly, without the + pagination metadata. + - If `as_list` is False, return a dict shaped like the first response with aggregated items and no tokens or metadata. + + Notes: + - as_list=True intentionally discards pagination metadata (e.g. nextPageToken). + If callers need page tokens, they should use list_only=False in single-page mode. Args: client: ChronicleClient instance @@ -53,6 +198,8 @@ def chronicle_paginated_request( page_size: Maximum number of rules to return per page. page_token: Token for the next page of results, if available. extra_params: extra query params to include on every request + as_list: If True, return a list of items directly, without the + pagination metadata in a dict. Returns: Union[Dict[str, List[Any]], List[Any]]: List of items from the @@ -89,11 +236,32 @@ def chronicle_paginated_request( params=params, ) + print(as_list) + + # If single page mode return immediately if single_page_mode: - return data + # Return the upstream JSON as-is if not as_list + if not as_list: + return data + + # Return a list if the API returns a list + if isinstance(data, list): + return data + + # Return a list of items if the API returns a dict + if isinstance(data, dict): + page_results = data.get(items_key, []) + if page_results and not isinstance(page_results, list): + raise APIError( + f"Expected '{items_key}' to be a list for {path}, got {type(page_results).__name__}" + ) + return page_results + raise APIError( + f"Unexpected response type for {path}: {type(data).__name__}" + ) - # Return a list if the API returns a list if isinstance(data, list): + # Top-level list responses can't expose nextPageToken; return as-is return data if not isinstance(data, dict): @@ -116,6 +284,10 @@ def chronicle_paginated_request( if not next_token: break + # Return the aggregated list if as_list is True + if as_list: + return aggregated_results + # Return a dict with the item key and an empty list if no results were returned if first_response_dict is None: return {items_key: aggregated_results} diff --git a/tests/chronicle/utils/test_request_utils.py b/tests/chronicle/utils/test_request_utils.py index 094031f..f1c8915 100644 --- a/tests/chronicle/utils/test_request_utils.py +++ b/tests/chronicle/utils/test_request_utils.py @@ -343,3 +343,137 @@ def test_paginated_request_extra_params_not_mutated(client: Mock) -> None: # Ensure merged into params as expected _, kwargs = client.session.request.call_args assert kwargs["params"] == {"pageSize": DEFAULT_PAGE_SIZE, "filter": "x"} + + +def test_paginated_request_single_page_mode_list_only_dict_extracts_items(client: Mock) -> None: + # Single page mode when page_size is provided; list_only should return just the list under items_key. + resp = _mock_response( + status_code=200, + json_value={"curatedRules": [{"id": 1}], "nextPageToken": "t2", "meta": {"x": 1}}, + ) + client.session.request.return_value = resp + + out = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="curatedRules", + page_size=10, + as_list=True, + ) + + assert out == [{"id": 1}] + + _, kwargs = client.session.request.call_args + assert kwargs["params"] == {"pageSize": 10} + + +def test_paginated_request_single_page_mode_list_only_dict_missing_key_returns_empty_list(client: Mock) -> None: + # If dict response does not include items_key, list_only should return [] (consistent with .get default) + resp = _mock_response(status_code=200, json_value={"meta": {"x": 1}, "nextPageToken": "t2"}) + client.session.request.return_value = resp + + out = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="curatedRules", + page_size=10, + as_list=True, + ) + + assert out == [] + + +def test_paginated_request_single_page_mode_list_only_list_passthrough(client: Mock) -> None: + # If upstream returns a top-level list and list_only=True, it should be returned as-is. + resp = _mock_response(status_code=200, json_value=[{"id": 1}, {"id": 2}]) + client.session.request.return_value = resp + + out = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="feeds", + items_key="feeds", + page_size=10, + as_list=True, + ) + + assert out == [{"id": 1}, {"id": 2}] + + +def test_paginated_request_single_page_mode_list_only_items_key_not_list_raises(client: Mock) -> None: + # list_only=True should still validate that items_key is a list when present + resp = _mock_response(status_code=200, json_value={"curatedRules": {"id": 1}}) + client.session.request.return_value = resp + + with pytest.raises(APIError, match=r"Expected 'curatedRules' to be a list"): + chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="curatedRules", + page_size=10, + as_list=True, + ) + + +def test_paginated_request_auto_mode_list_only_aggregates_items(client: Mock) -> None: + # Auto mode (no page_size/page_token): list_only=True should return aggregated flat list. + resp1 = _mock_response( + status_code=200, + json_value={"curatedRules": [{"id": 1}], "nextPageToken": "t2", "meta": {"x": 1}}, + ) + resp2 = _mock_response( + status_code=200, + json_value={"curatedRules": [{"id": 2}], "meta": {"x": 1}}, + ) + client.session.request.side_effect = [resp1, resp2] + + out = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="curatedRules", + as_list=True, + ) + + assert out == [{"id": 1}, {"id": 2}] + assert client.session.request.call_count == 2 + + call1 = client.session.request.call_args_list[0].kwargs + call2 = client.session.request.call_args_list[1].kwargs + assert call1["params"] == {"pageSize": DEFAULT_PAGE_SIZE} + assert call2["params"] == {"pageSize": DEFAULT_PAGE_SIZE, "pageToken": "t2"} + + +def test_paginated_request_auto_mode_list_only_empty_returns_empty_list(client: Mock) -> None: + resp = _mock_response(status_code=200, json_value={"curatedRules": []}) + client.session.request.return_value = resp + + out = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="curatedRules", + items_key="curatedRules", + as_list=True, + ) + + assert out == [] + + +def test_paginated_request_auto_mode_list_only_list_response_returns_list(client: Mock) -> None: + # Auto mode + top-level list response: return list and stop. + resp = _mock_response(status_code=200, json_value=[{"id": 1}, {"id": 2}]) + client.session.request.return_value = resp + + out = chronicle_paginated_request( + client=client, + api_version=APIVersion.V1ALPHA, + path="feeds", + items_key="feeds", + as_list=True, + ) + + assert out == [{"id": 1}, {"id": 2}] + assert client.session.request.call_count == 1 From 9137f8e303b6a7974a879ba8ecc5de6dda97842d Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 20:22:02 +0000 Subject: [PATCH 12/41] chore: Remove testing prints and commented code --- src/secops/chronicle/watchlist.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/secops/chronicle/watchlist.py b/src/secops/chronicle/watchlist.py index 7854ef3..dc5ce75 100644 --- a/src/secops/chronicle/watchlist.py +++ b/src/secops/chronicle/watchlist.py @@ -27,6 +27,7 @@ def list_watchlists( client: "ChronicleClient", page_size: int | None = None, page_token: str | None = None, + as_list: bool = False, ) -> dict[str, Any]: """Get a list of watchlists. @@ -34,6 +35,8 @@ def list_watchlists( client: ChronicleClient instance page_size: Number of results to return per page page_token: Token for the page to retrieve + as_list: If True, return a list of watchlists instead of a dict + with watchlists list and nextPageToken. Returns: List of watchlists @@ -48,6 +51,7 @@ def list_watchlists( items_key="watchlists", page_size=page_size, page_token=page_token, + as_list=as_list, ) From e069394b4c7df6b7e93cd1288550d93298c7d0e6 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 20:22:49 +0000 Subject: [PATCH 13/41] chore: Remove testing prints and commented code --- src/secops/chronicle/utils/request_utils.py | 140 -------------------- 1 file changed, 140 deletions(-) diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 8390366..f0aca15 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -22,144 +22,6 @@ DEFAULT_PAGE_SIZE = 1000 -# def chronicle_paginated_request( -# client: "ChronicleClient", -# api_version: str, -# path: str, -# items_key: str, -# *, -# page_size: int | None = None, -# page_token: str | None = None, -# extra_params: dict[str, Any] | None = None, -# list_only: bool = False, -# ) -> dict[str, Any] | list[Any]: -# """Helper to get items from endpoints that use pagination. -# -# Function behaviour: -# - If `page_size` OR `page_token` is provided: single page is returned. -# - list_only=False: return upstream JSON as-is (dict or list) -# - list_only=True: return only the list of items (drops metadata/tokens) -# - Else: auto-paginate until all pages are consumed. -# - list_only=False: return dict shaped like first response with aggregated items and no token -# - list_only=True: return aggregated flat list (drops metadata/tokens) -# -# Notes: -# - list_only=True intentionally discards pagination metadata (e.g. nextPageToken). -# If callers need page tokens, they should use list_only=False in single-page mode. -# """ -# single_page_mode = (page_size is not None) or (page_token is not None) -# effective_page_size = DEFAULT_PAGE_SIZE if page_size is None else page_size -# -# aggregated_results: list[Any] = [] -# first_response_dict: dict[str, Any] | None = None -# next_token = page_token -# -# while True: -# params: dict[str, Any] = {"pageSize": effective_page_size} -# if next_token: -# params["pageToken"] = next_token -# if extra_params: -# params.update(dict(extra_params)) -# -# data = chronicle_request( -# client=client, -# method="GET", -# api_version=api_version, -# endpoint_path=path, -# params=params, -# ) -# -# # --- Single-page mode: return immediately --- -# if single_page_mode: -# if not list_only: -# return data -# -# # list_only=True: extract list from dict responses, or pass through lists -# if isinstance(data, list): -# return data -# if isinstance(data, dict): -# page_results = data.get(items_key, []) -# if page_results and not isinstance(page_results, list): -# raise APIError( -# f"Expected '{items_key}' to be a list for {path}, got {type(page_results).__name__}" -# ) -# return page_results -# raise APIError(f"Unexpected response type for {path}: {type(data).__name__}") -# -# # --- Auto-pagination mode --- -# if isinstance(data, list): -# # Top-level list responses can't expose nextPageToken; return as-is -# return data -# -# if not isinstance(data, dict): -# raise APIError(f"Unexpected response type for {path}: {type(data).__name__}") -# -# if first_response_dict is None: -# first_response_dict = data -# -# page_results = data.get(items_key, []) -# if page_results: -# if not isinstance(page_results, list): -# raise APIError( -# f"Expected '{items_key}' to be a list for {path}, got {type(page_results).__name__}" -# ) -# aggregated_results.extend(page_results) -# -# next_token = data.get("nextPageToken") -# if not next_token: -# break -# -# # If list_only=True, return just the aggregated list. -# if list_only: -# return aggregated_results -# -# # No dict pages? Return minimal dict. -# if first_response_dict is None: -# return {items_key: aggregated_results} -# -# output = dict(first_response_dict) -# output[items_key] = aggregated_results -# output.pop("nextPageToken", None) -# return output -# -# -# def chronicle_request( -# client: "ChronicleClient", -# method: str, -# endpoint_path: str, -# *, -# api_version: str = APIVersion.V1, -# params: dict[str, Any] | None = None, -# json: dict[str, Any] | None = None, -# expected_status: int = 200, -# error_message: str | None = None, -# ) -> dict[str, Any]: -# # (unchanged) -# url = f"{client.base_url(api_version)}/{client.instance_id}/{endpoint_path}" -# response = client.session.request(method=method, url=url, params=params, json=json) -# -# try: -# data = response.json() -# except ValueError: -# data = None -# -# if response.status_code != expected_status: -# base_msg = error_message or "API request failed" -# if data is not None: -# raise APIError(f"{base_msg}: status={response.status_code}, response={data}") from None -# -# raise APIError( -# f"{base_msg}: status={response.status_code}, response_text={response.text}" -# ) from None -# -# if data is None: -# raise APIError( -# f"Expected JSON response from {url} but got non-JSON body: {response.text}" -# ) -# -# return data - - def chronicle_paginated_request( client: "ChronicleClient", api_version: str, @@ -236,8 +98,6 @@ def chronicle_paginated_request( params=params, ) - print(as_list) - # If single page mode return immediately if single_page_mode: # Return the upstream JSON as-is if not as_list From 7489e2745a4636a76f6a10c51279a0109064bec9 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 20:24:47 +0000 Subject: [PATCH 14/41] feat: Implement optional list output for watchlists --- src/secops/chronicle/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 942b65c..d110556 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -616,12 +616,15 @@ def list_watchlists( self, page_size: int | None = None, page_token: str | None = None, + as_list: bool = False, ) -> dict[str, Any]: """Get a list of all watchlists. Args: page_size: Maximum number of watchlists to return per page page_token: Token for the next page of results, if available + as_list: If True, return a list of watchlists instead of a dict + with watchlists list and nextPageToken. Returns: Dictionary with list of watchlists @@ -629,7 +632,7 @@ def list_watchlists( Raises: APIError: If the API request fails """ - return _list_watchlists(self, page_size, page_token) + return _list_watchlists(self, page_size, page_token, as_list) def get_watchlist( self, From e7d11e2a4fdb7d3f31c59a445bb0a0ae9cef6f33 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 20:26:32 +0000 Subject: [PATCH 15/41] chore: Update formatting --- src/secops/chronicle/utils/request_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index f0aca15..42483eb 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -22,6 +22,7 @@ DEFAULT_PAGE_SIZE = 1000 + def chronicle_paginated_request( client: "ChronicleClient", api_version: str, From b76b80e14518475754c1dba42969002a226b3fd3 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Tue, 30 Dec 2025 20:29:30 +0000 Subject: [PATCH 16/41] chore: Add variables for as_list --- tests/chronicle/test_watchlist.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/chronicle/test_watchlist.py b/tests/chronicle/test_watchlist.py index 37b7707..481c8cc 100644 --- a/tests/chronicle/test_watchlist.py +++ b/tests/chronicle/test_watchlist.py @@ -95,6 +95,7 @@ def test_list_watchlists_success(chronicle_client): items_key="watchlists", page_size=10, page_token="next-token", + as_list=False, ) @@ -117,6 +118,7 @@ def test_list_watchlists_default_args(chronicle_client): items_key="watchlists", page_size=None, page_token=None, + as_list=False, ) From 8d4afafe05062bedbb9cc65a75a6512f7f1480d6 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 2 Jan 2026 09:56:19 +0000 Subject: [PATCH 17/41] feat: Add error checking and exception handling for HTTP requests --- requirements.txt | 1 + src/secops/chronicle/utils/request_utils.py | 28 +++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index 898d84a..c5fa30d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ protobuf pylint twine python-dotenv +requests \ No newline at end of file diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 42483eb..4e2eeea 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -16,6 +16,9 @@ from typing import Any +import requests +from google.auth.exceptions import GoogleAuthError + from secops.exceptions import APIError from secops.chronicle.models import APIVersion @@ -171,7 +174,7 @@ def chronicle_request( json: dict[str, Any] | None = None, expected_status: int = 200, error_message: str | None = None, -) -> dict[str, Any]: +) -> dict[str, Any] | list[Any]: """Perform an HTTP request and return JSON, raising APIError on failure. Args: @@ -194,10 +197,25 @@ def chronicle_request( APIError: If the request fails, returns a non-JSON body, or status code does not match expected_status. """ - url = f"{client.base_url(api_version)}/{client.instance_id}/{endpoint_path}" - response = client.session.request( - method=method, url=url, params=params, json=json - ) + # If the endpoint path starts with a colon, the leading slash isn't needed + if endpoint_path.startswith(":"): + url = f"{client.base_url(api_version)}/{client.instance_id}{endpoint_path}" + else: + url = f"{client.base_url(api_version)}/{client.instance_id}/{endpoint_path}" + + try: + response = client.session.request( + method=method, url=url, params=params, json=json + ) + except GoogleAuthError as exc: + base_msg = error_message or "Google authentication failed" + raise APIError(f"{base_msg}: authentication_error={exc}") from exc + except requests.RequestException as exc: + base_msg = error_message or "API request failed" + raise APIError( + f"{base_msg}: method={method}, url={url}, " + f"request_error={exc.__class__.__name__}, detail={exc}" + ) from exc # Try to parse JSON even on error, so we can get more details try: From 0181408730a59e73541384bcb9ba6ae6688d3e0c Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 2 Jan 2026 10:00:28 +0000 Subject: [PATCH 18/41] feat: Update building of url based on endpoint --- src/secops/chronicle/utils/request_utils.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 4e2eeea..032d0ba 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -197,11 +197,16 @@ def chronicle_request( APIError: If the request fails, returns a non-JSON body, or status code does not match expected_status. """ - # If the endpoint path starts with a colon, the leading slash isn't needed + # Build URL based on endpoint type: + # - RPC-style methods e.g: ":validateQuery" -> .../{instance_id}:validateQuery + # - Legacy paths e.g: "legacy:..." -> .../{instance_id}/legacy:... + # - normal paths e.g: "curatedRules/..." -> .../{instance_id}/curatedRules/... + base = f"{client.base_url(api_version)}/{client.instance_id}" + if endpoint_path.startswith(":"): - url = f"{client.base_url(api_version)}/{client.instance_id}{endpoint_path}" + url = f"{base}{endpoint_path}" else: - url = f"{client.base_url(api_version)}/{client.instance_id}/{endpoint_path}" + url = f"{base}/{endpoint_path.lstrip('/')}" try: response = client.session.request( From 5793eadc9a03779b0d37ff285c74eefc50b9880c Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 2 Jan 2026 20:00:16 +0000 Subject: [PATCH 19/41] feat: Additional error messages for exceptions --- src/secops/chronicle/utils/request_utils.py | 7 ++--- tests/chronicle/utils/test_request_utils.py | 30 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 032d0ba..39de783 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -232,12 +232,13 @@ def chronicle_request( base_msg = error_message or "API request failed" if data is not None: raise APIError( - f"{base_msg}: status={response.status_code}, response={data}" + f"{base_msg}: method={method}, url={url}, " + f"status={response.status_code}, response={data}" ) from None raise APIError( - f"{base_msg}: status={response.status_code}," - f" response_text={response.text}" + f"{base_msg}: method={method}, url={url}, " + f"status={response.status_code}, response_text={response.text}" ) from None if data is None: diff --git a/tests/chronicle/utils/test_request_utils.py b/tests/chronicle/utils/test_request_utils.py index f1c8915..d7bcdb4 100644 --- a/tests/chronicle/utils/test_request_utils.py +++ b/tests/chronicle/utils/test_request_utils.py @@ -477,3 +477,33 @@ def test_paginated_request_auto_mode_list_only_list_response_returns_list(client assert out == [{"id": 1}, {"id": 2}] assert client.session.request.call_count == 1 + + +def test_chronicle_request_builds_url_for_rpc_colon_prefix(client: Mock) -> None: + resp = _mock_response(status_code=200, json_value={"ok": True}) + client.session.request.return_value = resp + + chronicle_request( + client=client, + method="POST", + endpoint_path=":validateQuery", + api_version=APIVersion.V1ALPHA, + ) + + _, kwargs = client.session.request.call_args + assert kwargs["url"] == "https://example.test/chronicle/instances/instance-1:validateQuery" + + +def test_chronicle_request_builds_url_for_legacy_segment(client: Mock) -> None: + resp = _mock_response(status_code=200, json_value={"ok": True}) + client.session.request.return_value = resp + + chronicle_request( + client=client, + method="GET", + endpoint_path="legacy:legacySearchCuratedDetections", + api_version=APIVersion.V1ALPHA, + ) + + _, kwargs = client.session.request.call_args + assert kwargs["url"] == "https://example.test/chronicle/instances/instance-1/legacy:legacySearchCuratedDetections" From 2113b3be478e12b4702329edd85ac02d9c98a3b7 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 2 Jan 2026 21:27:50 +0000 Subject: [PATCH 20/41] feat: Implement additional options for expected status --- src/secops/chronicle/utils/request_utils.py | 16 +++-- tests/chronicle/utils/test_request_utils.py | 73 ++++++++++++++++++++- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 39de783..b869bda 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -172,7 +172,7 @@ def chronicle_request( api_version: str = APIVersion.V1, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None, - expected_status: int = 200, + expected_status: int | set[int] | tuple[int, ...] = 200, error_message: str | None = None, ) -> dict[str, Any] | list[Any]: """Perform an HTTP request and return JSON, raising APIError on failure. @@ -187,7 +187,9 @@ def chronicle_request( - v1beta (secops.chronicle.models.APIVersion.V1BETA) params: Optional query parameters json: Optional JSON body - expected_status: Expected HTTP status code (default: 200) + expected_status: Expected HTTP status code(s). May be a single int + (e.g. 200) or an iterable of acceptable status codes (e.g. {200, 204}). + If the response status is not acceptable, an APIError is raised. error_message: Optional base error message to include on failure Returns: @@ -195,7 +197,7 @@ def chronicle_request( Raises: APIError: If the request fails, returns a non-JSON body, or status - code does not match expected_status. + code is not in expected_status. """ # Build URL based on endpoint type: # - RPC-style methods e.g: ":validateQuery" -> .../{instance_id}:validateQuery @@ -228,7 +230,13 @@ def chronicle_request( except ValueError: data = None - if response.status_code != expected_status: + # Determine whether the status code is acceptable + if isinstance(expected_status, (set, tuple, list)): + status_ok = response.status_code in expected_status + else: + status_ok = response.status_code == expected_status + + if not status_ok: base_msg = error_message or "API request failed" if data is not None: raise APIError( diff --git a/tests/chronicle/utils/test_request_utils.py b/tests/chronicle/utils/test_request_utils.py index d7bcdb4..48ce3ad 100644 --- a/tests/chronicle/utils/test_request_utils.py +++ b/tests/chronicle/utils/test_request_utils.py @@ -112,7 +112,9 @@ def test_chronicle_request_status_mismatch_with_json_includes_json( with pytest.raises( APIError, - match=r"API request failed: status=400, response=\{'error': 'bad'\}", + match=r"API request failed: method=GET, " + r"url=https://example.test/chronicle/instances/instance-1/curatedRules" + r", status=400, response={'error': 'bad'}", ): chronicle_request( client=client, @@ -130,7 +132,9 @@ def test_chronicle_request_status_mismatch_non_json_includes_text( client.session.request.return_value = response with pytest.raises( - APIError, match=r"API request failed: status=500, response_text=boom" + APIError, match=r"API request failed: method=GET, " + r"url=https://example.test/chronicle/instances/instance-1/curatedRules," + r" status=500, response_text=boom" ): chronicle_request( client=client, @@ -148,7 +152,9 @@ def test_chronicle_request_custom_error_message_used(client: Mock) -> None: client.session.request.return_value = response with pytest.raises( - APIError, match=r"Failed to get curated rule: status=404" + APIError, match=r"Failed to get curated rule: method=GET, " + r"url=https://example.test/chronicle/instances/instance-1/curatedRules/ur_1, " + r"status=404, response={'message': 'not found'" ): chronicle_request( client=client, @@ -507,3 +513,64 @@ def test_chronicle_request_builds_url_for_legacy_segment(client: Mock) -> None: _, kwargs = client.session.request.call_args assert kwargs["url"] == "https://example.test/chronicle/instances/instance-1/legacy:legacySearchCuratedDetections" + + +def test_chronicle_request_accepts_multiple_expected_statuses_set(client: Mock) -> None: + resp = _mock_response(status_code=204, json_value={"ok": True}) + client.session.request.return_value = resp + + out = chronicle_request( + client=client, + method="DELETE", + endpoint_path="curatedRules/ur_1", + api_version=APIVersion.V1ALPHA, + expected_status={200, 204}, + ) + + assert out == {"ok": True} + + +def test_chronicle_request_accepts_multiple_expected_statuses_tuple(client: Mock) -> None: + resp = _mock_response(status_code=201, json_value={"created": True}) + client.session.request.return_value = resp + + out = chronicle_request( + client=client, + method="POST", + endpoint_path="curatedRules", + api_version=APIVersion.V1ALPHA, + expected_status=(200, 201), + json={"x": 1}, + ) + + assert out == {"created": True} + + +def test_chronicle_request_rejects_status_not_in_expected_set(client: Mock) -> None: + resp = _mock_response(status_code=202, json_value={"message": "accepted"}) + client.session.request.return_value = resp + + with pytest.raises(APIError, match=r"API request failed: method=POST, url=.*status=202"): + chronicle_request( + client=client, + method="POST", + endpoint_path="curatedRuleSetDeployments:batchUpdate", + api_version=APIVersion.V1ALPHA, + expected_status={200, 201}, + json={"requests": []}, + ) + + +def test_chronicle_request_single_expected_status_int_still_enforced(client: Mock) -> None: + resp = _mock_response(status_code=201, json_value={"created": True}) + client.session.request.return_value = resp + + with pytest.raises(APIError, match=r"API request failed: method=POST, url=.*status=201"): + chronicle_request( + client=client, + method="POST", + endpoint_path="curatedRules", + api_version=APIVersion.V1ALPHA, + expected_status=200, + json={"x": 1}, + ) From b6075decc945e15bc52758c225a6a9e00de17214 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 2 Jan 2026 21:41:27 +0000 Subject: [PATCH 21/41] feat: Implement safe body preview for error messages --- src/secops/chronicle/utils/request_utils.py | 25 ++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index b869bda..11038ce 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -24,6 +24,16 @@ DEFAULT_PAGE_SIZE = 1000 +MAX_BODY_CHARS = 2000 + + +def _safe_body_preview(text: str | None, limit: int = MAX_BODY_CHARS) -> str: + """Generate a safe, truncated preview of body contents for error messages""" + if not text: + return "" + if len(text) <= limit: + return text + return f"{text[:limit]}… (truncated, {len(text)} chars)" def chronicle_paginated_request( @@ -47,7 +57,7 @@ def chronicle_paginated_request( - Else: auto-paginate responses until all pages are consumed: - If `as_list` is True, return a list of items directly, without the pagination metadata. - - If `as_list` is False, return a dict shaped like the first response with aggregated items and no tokens or metadata. + - If `as_list` is False, return a dict shaped like the first response with aggregated items and no tokens. Notes: - as_list=True intentionally discards pagination metadata (e.g. nextPageToken). @@ -244,15 +254,20 @@ def chronicle_request( f"status={response.status_code}, response={data}" ) from None + preview = _safe_body_preview(getattr(response, "text", ""), limit=MAX_BODY_CHARS) + raise APIError( - f"{base_msg}: method={method}, url={url}, " - f"status={response.status_code}, response_text={response.text}" + f"{base_msg}: method={method}, url={url}, status={response.status_code}, " + f"response_text={preview}" ) from None if data is None: + content_type = response.headers.get("Content-Type", "unknown") + preview = _safe_body_preview(getattr(response, "text", ""), limit=MAX_BODY_CHARS) + raise APIError( - f"Expected JSON response from {url}" - f" but got non-JSON body: {response.text}" + f"Expected JSON response: method={method}, url={url}, status={response.status_code}, " + f"content_type={content_type}, body_preview={preview}" ) return data From 052bd48d011b0560c5885145b7505f32835d9da9 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 2 Jan 2026 21:51:56 +0000 Subject: [PATCH 22/41] feat: Added tests for new features --- tests/chronicle/utils/test_request_utils.py | 79 +++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/chronicle/utils/test_request_utils.py b/tests/chronicle/utils/test_request_utils.py index 48ce3ad..10b6c46 100644 --- a/tests/chronicle/utils/test_request_utils.py +++ b/tests/chronicle/utils/test_request_utils.py @@ -19,6 +19,8 @@ from unittest.mock import Mock import pytest +import requests +from google.auth.exceptions import GoogleAuthError from secops.chronicle.models import APIVersion from secops.chronicle.utils.request_utils import ( @@ -574,3 +576,80 @@ def test_chronicle_request_single_expected_status_int_still_enforced(client: Moc expected_status=200, json={"x": 1}, ) + + +def test_chronicle_request_wraps_requests_exception(client: Mock) -> None: + # Simulate network-level failure (timeout/connection error etc.) + client.session.request.side_effect = requests.RequestException("no route to host") + + with pytest.raises(APIError) as exc_info: + chronicle_request( + client=client, + method="GET", + endpoint_path="curatedRules", + api_version=APIVersion.V1ALPHA, + ) + + msg = str(exc_info.value) + assert "API request failed" in msg + assert "method=GET" in msg + assert "url=https://example.test/chronicle/instances/instance-1/curatedRules" in msg + assert "request_error=RequestException" in msg + + +def test_chronicle_request_wraps_google_auth_error(client: Mock) -> None: + # Simulate auth failure raised during request + client.session.request.side_effect = GoogleAuthError("invalid_grant") + + with pytest.raises(APIError) as exc_info: + chronicle_request( + client=client, + method="GET", + endpoint_path="curatedRules", + api_version=APIVersion.V1ALPHA, + ) + + msg = str(exc_info.value) + assert "Google authentication failed" in msg + assert "authentication_error=" in msg + + +def test_chronicle_request_non_json_success_includes_content_type(client: Mock) -> None: + # Successful status but non-JSON body should include content_type and body_preview + response = _mock_response(status_code=200, json_raises=True, text="not json") + response.headers = {"Content-Type": "text/html"} + client.session.request.return_value = response + + with pytest.raises(APIError) as exc_info: + chronicle_request( + client=client, + method="GET", + endpoint_path="curatedRules", + api_version=APIVersion.V1ALPHA, + ) + + msg = str(exc_info.value) + assert "Expected JSON response" in msg + assert "content_type=text/html" in msg + assert "body_preview=not json" in msg + + +def test_chronicle_request_non_json_error_body_is_truncated(client: Mock) -> None: + # Non-expected status + non-JSON body uses _safe_body_preview truncation + long_text = "x" * 5000 + response = _mock_response(status_code=500, json_raises=True, text=long_text) + response.headers = {"Content-Type": "text/plain"} + client.session.request.return_value = response + + with pytest.raises(APIError) as exc_info: + chronicle_request( + client=client, + method="GET", + endpoint_path="curatedRules", + api_version=APIVersion.V1ALPHA, + ) + + msg = str(exc_info.value) + assert "status=500" in msg + # Should not include the full 5000 chars, should include truncation marker + assert "truncated" in msg From cfba388483f56f8129434dbd978518f5d4302160 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 2 Jan 2026 21:55:31 +0000 Subject: [PATCH 23/41] chore: linting and formatting changes --- src/secops/chronicle/utils/request_utils.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 11038ce..3d024aa 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -36,6 +36,7 @@ def _safe_body_preview(text: str | None, limit: int = MAX_BODY_CHARS) -> str: return f"{text[:limit]}… (truncated, {len(text)} chars)" +# pylint: disable=line-too-long def chronicle_paginated_request( client: "ChronicleClient", api_version: str, @@ -61,7 +62,7 @@ def chronicle_paginated_request( Notes: - as_list=True intentionally discards pagination metadata (e.g. nextPageToken). - If callers need page tokens, they should use list_only=False in single-page mode. + If callers need page tokens, they should use as_list=False in single-page mode. Args: client: ChronicleClient instance @@ -174,6 +175,7 @@ def chronicle_paginated_request( return output +# pylint: disable=line-too-long def chronicle_request( client: "ChronicleClient", method: str, @@ -182,7 +184,7 @@ def chronicle_request( api_version: str = APIVersion.V1, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None, - expected_status: int | set[int] | tuple[int, ...] = 200, + expected_status: int | set[int] | tuple[int, ...] | list[int] = 200, error_message: str | None = None, ) -> dict[str, Any] | list[Any]: """Perform an HTTP request and return JSON, raising APIError on failure. @@ -254,7 +256,9 @@ def chronicle_request( f"status={response.status_code}, response={data}" ) from None - preview = _safe_body_preview(getattr(response, "text", ""), limit=MAX_BODY_CHARS) + preview = _safe_body_preview( + getattr(response, "text", ""), limit=MAX_BODY_CHARS + ) raise APIError( f"{base_msg}: method={method}, url={url}, status={response.status_code}, " @@ -263,7 +267,9 @@ def chronicle_request( if data is None: content_type = response.headers.get("Content-Type", "unknown") - preview = _safe_body_preview(getattr(response, "text", ""), limit=MAX_BODY_CHARS) + preview = _safe_body_preview( + getattr(response, "text", ""), limit=MAX_BODY_CHARS + ) raise APIError( f"Expected JSON response: method={method}, url={url}, status={response.status_code}, " From fa0278ee16df0c3b4b96b4dde9d729e9777d4ec1 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 2 Jan 2026 21:55:36 +0000 Subject: [PATCH 24/41] chore: linting and formatting changes --- src/secops/chronicle/client.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index d110556..0d4f314 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -2350,7 +2350,8 @@ def list_curated_rule_sets( Args: page_size: Number of results to return per page page_token: Token for the page to retrieve - as_list: Whether to return the list of curated rule sets as a list instead of a dict + as_list: Whether to return the list of curated rule sets + as a list instead of a dict Returns: Dictionary containing the list of curated rule sets @@ -2371,7 +2372,8 @@ def list_curated_rule_set_categories( Args: page_size: Number of results to return per page page_token: Token for the page to retrieve - as_list: Whether to return the list of curated rule set categories as a list instead of a dict + as_list: Whether to return the list of curated rule + set categories as a list instead of a dict Returns: Dictionary containing the list of curated rule set categories @@ -2398,7 +2400,8 @@ def list_curated_rule_set_deployments( page_token: Token for the page to retrieve only_enabled: Only return enabled rule set deployments only_alerting: Only return alerting rule set deployments - as_list: Whether to return the list of curated rule set deployments as a list instead of a dict + as_list: Whether to return the list of curated rule + set deployments as a list instead of a dict Returns: Dictionary containing the list of curated rule set deployments @@ -2464,7 +2467,8 @@ def list_curated_rules( Args: page_size: Number of results to return per page page_token: Token for the page to retrieve - as_list: Whether to return the list of curated rules as a list instead of a dict + as_list: Whether to return the list of curated rules as + a list instead of a dict Returns: Dictionary containing the list of curated rules From eb0f6b66751434bc8fd3826eb0581f8520b27a4f Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 2 Jan 2026 22:10:25 +0000 Subject: [PATCH 25/41] feat: refactor to use utils standard request --- src/secops/chronicle/validate.py | 42 ++++++++------------------------ 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/src/secops/chronicle/validate.py b/src/secops/chronicle/validate.py index 64ec828..9a3c13e 100644 --- a/src/secops/chronicle/validate.py +++ b/src/secops/chronicle/validate.py @@ -16,7 +16,8 @@ from typing import Any -from secops.exceptions import APIError +from secops.chronicle.models import APIVersion +from secops.chronicle.utils.request_utils import chronicle_request def validate_query(client, query: str) -> dict[str, Any]: @@ -36,10 +37,8 @@ def validate_query(client, query: str) -> dict[str, Any]: Raises: APIError: If the API request fails """ - url = f"{client.base_url}/{client.instance_id}:validateQuery" - # Replace special characters with Unicode escapes - encoded_query = query.replace("!", "\u0021") + encoded_query = query.replace("!", "\\u0021") params = { "rawQuery": encoded_query, @@ -47,31 +46,10 @@ def validate_query(client, query: str) -> dict[str, Any]: "allowUnreplacedPlaceholders": "false", } - response = client.session.get(url, params=params) - - # Handle successful validation - if response.status_code == 200: - try: - return response.json() - except ValueError: - return {"isValid": True, "queryType": "QUERY_TYPE_UNKNOWN"} - - # If validation failed, return structured error - # For any status code other than 200, return an error structure - if response.status_code == 400: - try: - # Try to parse the error message - error_data = response.json() - validation_message = error_data.get("error", {}).get( - "message", "Invalid query syntax" - ) - return { - "isValid": False, - "queryType": "QUERY_TYPE_UNKNOWN", - "validationMessage": validation_message, - } - except ValueError: - pass - - # For any other status codes, raise an APIError - raise APIError(f"Query validation failed: {response.text}") + return chronicle_request( + client, + method="GET", + endpoint_path=":validateQuery", + api_version=APIVersion.V1ALPHA, + params=params, + ) From 50eee8668e9d595e046968f4b1ebc1ed39f7c8e8 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 2 Jan 2026 22:10:57 +0000 Subject: [PATCH 26/41] chore: move validate testing to own module and update for refactor --- tests/chronicle/test_client.py | 18 ------------ tests/chronicle/test_validate.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 tests/chronicle/test_validate.py diff --git a/tests/chronicle/test_client.py b/tests/chronicle/test_client.py index ba23710..5d7e53c 100644 --- a/tests/chronicle/test_client.py +++ b/tests/chronicle/test_client.py @@ -196,24 +196,6 @@ def test_fetch_udm_search_view_parsing_error(chronicle_client): assert "Failed to parse UDM search response" in str(exc_info.value) -def test_validate_query(chronicle_client): - """Test query validation.""" - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "queryType": "QUERY_TYPE_UDM_QUERY", - "isValid": True, - } - - with patch.object(chronicle_client.session, "get", return_value=mock_response): - result = chronicle_client.validate_query( - 'metadata.event_type = "NETWORK_CONNECTION"' - ) - - assert result.get("isValid") is True - assert result.get("queryType") == "QUERY_TYPE_UDM_QUERY" - - def test_get_stats(chronicle_client): """Test stats search functionality.""" # Mock the search request diff --git a/tests/chronicle/test_validate.py b/tests/chronicle/test_validate.py new file mode 100644 index 0000000..42dab0a --- /dev/null +++ b/tests/chronicle/test_validate.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest + +from secops.chronicle.models import APIVersion +from secops.chronicle.validate import validate_query + + +@pytest.fixture +def chronicle_client() -> Mock: + # validate_query only needs a client object to pass through to chronicle_request + return Mock() + + +def test_validate_query_calls_chronicle_request_with_expected_params(chronicle_client: Mock) -> None: + expected = {"queryType": "QUERY_TYPE_UDM_QUERY", "isValid": True} + + with patch("secops.chronicle.validate.chronicle_request", return_value=expected) as req: + result = validate_query( + chronicle_client, 'metadata.event_type = "NETWORK_CONNECTION"' + ) + + assert result == expected + + req.assert_called_once() + kwargs = req.call_args.kwargs + + assert req.call_args.args[0] is chronicle_client # client passed positionally + assert kwargs["method"] == "GET" + assert kwargs["endpoint_path"] == ":validateQuery" + assert kwargs["api_version"] == APIVersion.V1ALPHA + + # Check params are correct for dialect + placeholders behaviour + assert kwargs["params"]["dialect"] == "DIALECT_UDM_SEARCH" + assert kwargs["params"]["allowUnreplacedPlaceholders"] == "false" + assert kwargs["params"]["rawQuery"] == 'metadata.event_type = "NETWORK_CONNECTION"' + + +def test_validate_query_encodes_exclamation_marks(chronicle_client: Mock) -> None: + expected = {"isValid": True} + + with patch("secops.chronicle.validate.chronicle_request", return_value=expected) as req: + validate_query(chronicle_client, 'field = "a!b"') + + raw_query = req.call_args.kwargs["params"]["rawQuery"] + assert raw_query == 'field = "a\\u0021b"' From 77290d72a93b5b07a6fb1e7918cef15e19a61528 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sat, 3 Jan 2026 20:47:53 +0000 Subject: [PATCH 27/41] feat: add headers to chronicle_request --- src/secops/chronicle/utils/request_utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 3d024aa..326c7eb 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -183,6 +183,7 @@ def chronicle_request( *, api_version: str = APIVersion.V1, params: dict[str, Any] | None = None, + headers: dict[str, Any] | None = None, json: dict[str, Any] | None = None, expected_status: int | set[int] | tuple[int, ...] | list[int] = 200, error_message: str | None = None, @@ -198,6 +199,7 @@ def chronicle_request( - v1alpha (secops.chronicle.models.APIVersion.V1ALPHA) - v1beta (secops.chronicle.models.APIVersion.V1BETA) params: Optional query parameters + headers: Optional headers json: Optional JSON body expected_status: Expected HTTP status code(s). May be a single int (e.g. 200) or an iterable of acceptable status codes (e.g. {200, 204}). @@ -224,7 +226,11 @@ def chronicle_request( try: response = client.session.request( - method=method, url=url, params=params, json=json + method=method, + url=url, + params=params, + json=json, + headers=headers ) except GoogleAuthError as exc: base_msg = error_message or "Google authentication failed" From 8dc0fd10fe3425094a5456b0bfc0aa50b3d9f85f Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sat, 3 Jan 2026 21:18:42 +0000 Subject: [PATCH 28/41] feat: add headers to chronicle_request --- tests/chronicle/utils/test_request_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/chronicle/utils/test_request_utils.py b/tests/chronicle/utils/test_request_utils.py index 10b6c46..4c65620 100644 --- a/tests/chronicle/utils/test_request_utils.py +++ b/tests/chronicle/utils/test_request_utils.py @@ -86,6 +86,7 @@ def test_chronicle_request_success_json(client: Mock) -> None: url="https://example.test/chronicle/instances/instance-1/curatedRules", params={"pageSize": 10}, json=None, + headers=None, ) From a45f65354912e605446f3f56d6122372b5e79047 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sat, 3 Jan 2026 21:18:54 +0000 Subject: [PATCH 29/41] feat: migrate to request helper --- src/secops/chronicle/udm_search.py | 82 ++++++++++++------------------ 1 file changed, 33 insertions(+), 49 deletions(-) diff --git a/src/secops/chronicle/udm_search.py b/src/secops/chronicle/udm_search.py index 0a7e701..ba1a891 100644 --- a/src/secops/chronicle/udm_search.py +++ b/src/secops/chronicle/udm_search.py @@ -17,7 +17,11 @@ from datetime import datetime from typing import Any -from secops.exceptions import APIError, SecOpsError +from secops.exceptions import APIError +from secops.chronicle.models import APIVersion +from secops.chronicle.utils.request_utils import ( + chronicle_request +) def fetch_udm_search_csv( @@ -27,7 +31,7 @@ def fetch_udm_search_csv( end_time: datetime, fields: list[str], case_insensitive: bool = True, -) -> str: +) -> dict[str, Any]: """Fetch UDM search results in CSV format. Args: @@ -44,10 +48,6 @@ def fetch_udm_search_csv( Raises: APIError: If the API request fails """ - url = ( - f"{client.base_url}/{client.instance_id}/legacy:legacyFetchUdmSearchCsv" - ) - search_query = { "baselineQuery": query, "baselineTimeRange": { @@ -58,27 +58,15 @@ def fetch_udm_search_csv( "caseInsensitive": case_insensitive, } - response = client.session.post( - url, json=search_query, headers={"Accept": "*/*"} + return chronicle_request( + client, + method="POST", + endpoint_path="legacy:legacyFetchUdmSearchCsv", + api_version=APIVersion.V1ALPHA, + json=search_query, + headers={"Accept": "*/*"}, ) - if response.status_code != 200: - raise APIError(f"Chronicle API request failed: {response.text}") - - # For testing purposes, try to parse the response as JSON to verify error - # handling - try: - # This is to trigger the ValueError in the test - response.json() - except ValueError as e: - # Only throw an error if the content appears to be JSON but is invalid - if response.text.strip().startswith( - "{" - ) or response.text.strip().startswith("["): - raise APIError(f"Failed to parse CSV response: {str(e)}") from e - - return response.text - def find_udm_field_values( client, query: str, page_size: int | None = None @@ -96,24 +84,17 @@ def find_udm_field_values( Raises: APIError: If the API request fails """ - # Construct the URL for the findUdmFieldValues endpoint - url = f"{client.base_url}/{client.instance_id}:findUdmFieldValues" - - # Prepare query parameters params = {"query": query} if page_size is not None: params["pageSize"] = page_size - # Send the request - response = client.session.get(url, params=params) - - if response.status_code != 200: - raise APIError(f"Chronicle API request failed: {response.text}") - - try: - return response.json() - except ValueError as e: - raise SecOpsError(f"Failed to parse response as JSON: {str(e)}") from e + return chronicle_request( + client, + method="GET", + endpoint_path=":findUdmFieldValues", + api_version=APIVersion.V1ALPHA, + params=params, + ) def fetch_udm_search_view( @@ -188,21 +169,24 @@ def fetch_udm_search_view( if response.status_code != 200: raise APIError(f"Chronicle API request failed: {response.text}") - try: - json_resp = response.json() - except ValueError as e: - raise APIError(f"Failed to parse UDM search response: {str(e)}") from e + json_resp = chronicle_request( + client, + method="POST", + endpoint_path="legacy:legacyFetchUdmSearchView", + api_version=APIVersion.V1ALPHA, + json=search_query, + headers={"Accept": "*/*"}, + ) final_resp: list[dict[str, Any]] = [] - complete: bool = False + complete = False + for resp in json_resp: - if not resp.get("complete", "") and not resp.get("error", ""): + if not resp.get("complete") and not resp.get("error"): continue - if resp.get("error", ""): - raise APIError( - f'Chronicle API request failed: {resp.get("error", "")}' - ) + if resp.get("error"): + raise APIError(f'Chronicle API request failed: {resp.get("error", "")}') final_resp.append(resp) complete = True From e7103f60738103d19e611c70c3769cd88e0698e5 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sat, 3 Jan 2026 21:45:02 +0000 Subject: [PATCH 30/41] chore: update formatting --- src/secops/chronicle/udm_search.py | 20 ++++---------------- src/secops/chronicle/utils/request_utils.py | 6 +----- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/src/secops/chronicle/udm_search.py b/src/secops/chronicle/udm_search.py index ba1a891..5373707 100644 --- a/src/secops/chronicle/udm_search.py +++ b/src/secops/chronicle/udm_search.py @@ -19,9 +19,7 @@ from secops.exceptions import APIError from secops.chronicle.models import APIVersion -from secops.chronicle.utils.request_utils import ( - chronicle_request -) +from secops.chronicle.utils.request_utils import chronicle_request def fetch_udm_search_csv( @@ -133,11 +131,6 @@ def fetch_udm_search_view( Raises: APIError: If the API request fails """ - url = ( - f"{client.base_url}/{client.instance_id}" - "/legacy:legacyFetchUdmSearchView" - ) - search_query = { "baselineQuery": query, "baselineTimeRange": { @@ -162,13 +155,6 @@ def fetch_udm_search_view( "maxReturnedEvents": max_events, } - response = client.session.post( - url, json=search_query, headers={"Accept": "*/*"} - ) - - if response.status_code != 200: - raise APIError(f"Chronicle API request failed: {response.text}") - json_resp = chronicle_request( client, method="POST", @@ -186,7 +172,9 @@ def fetch_udm_search_view( continue if resp.get("error"): - raise APIError(f'Chronicle API request failed: {resp.get("error", "")}') + raise APIError( + f'Chronicle API request failed: {resp.get("error", "")}' + ) final_resp.append(resp) complete = True diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index 326c7eb..c311751 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -226,11 +226,7 @@ def chronicle_request( try: response = client.session.request( - method=method, - url=url, - params=params, - json=json, - headers=headers + method=method, url=url, params=params, json=json, headers=headers ) except GoogleAuthError as exc: base_msg = error_message or "Google authentication failed" From ce8f97e0dc8195c3bee804e8cfccfeeebd8706e9 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sat, 3 Jan 2026 21:45:30 +0000 Subject: [PATCH 31/41] refactor: move udm search tests to their own module and update for helper usage --- tests/chronicle/test_client.py | 213 ----------------------------- tests/chronicle/test_udm_search.py | 142 +++++++++++++++++++ 2 files changed, 142 insertions(+), 213 deletions(-) create mode 100644 tests/chronicle/test_udm_search.py diff --git a/tests/chronicle/test_client.py b/tests/chronicle/test_client.py index 5d7e53c..68cd402 100644 --- a/tests/chronicle/test_client.py +++ b/tests/chronicle/test_client.py @@ -90,112 +90,6 @@ def test_chronicle_client_custom_session_user_agent(): assert client.session.headers.get("User-Agent") == "secops-wrapper-sdk" -def test_fetch_udm_search_csv(chronicle_client, mock_response): - """Test fetching UDM search results.""" - with patch.object(chronicle_client.session, "post", return_value=mock_response): - result = chronicle_client.fetch_udm_search_csv( - query='metadata.event_type = "NETWORK_CONNECTION"', - start_time=datetime(2024, 1, 14, 23, 7, tzinfo=timezone.utc), - end_time=datetime(2024, 1, 15, 0, 7, tzinfo=timezone.utc), - fields=["timestamp", "user", "hostname", "process name"], - ) - - assert "timestamp,user,hostname,process_name" in result - assert "2024-01-15T00:00:00Z,user1,host1,process1" in result - - -def test_fetch_udm_search_csv_error(chronicle_client): - """Test handling of API errors.""" - error_response = Mock() - error_response.status_code = 400 - error_response.text = "Invalid request" - - with patch( - "google.auth.transport.requests.AuthorizedSession.post", - return_value=error_response, - ): - with pytest.raises(APIError) as exc_info: - chronicle_client.fetch_udm_search_csv( - query="invalid query", - start_time=datetime(2024, 1, 14, 23, 7, tzinfo=timezone.utc), - end_time=datetime(2024, 1, 15, 0, 7, tzinfo=timezone.utc), - fields=["timestamp"], - ) - - assert "Chronicle API request failed" in str(exc_info.value) - - -def test_fetch_udm_search_csv_parsing_error(chronicle_client): - """Test handling of parsing errors in CSV response.""" - error_response = Mock() - error_response.status_code = 200 - # Set text to start with { to trigger JSON parsing attempt - error_response.text = '{"invalid": json}' - error_response.json.side_effect = ValueError("Invalid JSON") - - with patch.object(chronicle_client.session, "post", return_value=error_response): - with pytest.raises(APIError) as exc_info: - chronicle_client.fetch_udm_search_csv( - query='metadata.event_type = "NETWORK_CONNECTION"', - start_time=datetime(2024, 1, 14, 23, 7, tzinfo=timezone.utc), - end_time=datetime(2024, 1, 15, 0, 7, tzinfo=timezone.utc), - fields=["timestamp"], - ) - - assert "Failed to parse CSV response" in str(exc_info.value) - - -def test_fetch_udm_search_view(chronicle_client, mock_response): - mock_response.json.return_value = [{"progress": 1, "complete": True, "validBaselineQuery": True, "baselineEventsCount": 50, "validSnapshotQuery": True, "filteredEventsCount": 50}] - """Test fetching UDM search view results.""" - with patch.object(chronicle_client.session, "post", return_value=mock_response): - result = chronicle_client.fetch_udm_search_view( - query='metadata.event_type = "PROCESS_LAUNCH" and target.process.file.full_path = /powershell.exe/ nocase', - start_time=datetime(2024, 1, 14, 23, 7, tzinfo=timezone.utc), - end_time=datetime(2024, 1, 15, 0, 7, tzinfo=timezone.utc), - max_events=1 - ) - - assert result[0]["complete"] is True - - -def test_fetch_udm_search_view_syntax_error(chronicle_client): - """Test handling of API errors""" - error_response = Mock() - error_response.status_code = 200 - error_response.json.return_value = [{"error": {"code": 400, "message": "something went wrong, please try again later", "status": "INVALID_ARGUMENT"}}] - - with patch.object(chronicle_client.session, "post", return_value=error_response): - with pytest.raises(APIError) as exc_info: - chronicle_client.fetch_udm_search_view( - query='metadata.event_types = "PROCESS_LAUNCH"', - start_time=datetime(2024, 1, 14, 23, 7, tzinfo=timezone.utc), - end_time=datetime(2024, 1, 15, 0, 7, tzinfo=timezone.utc), - max_events=1 - ) - - assert "Chronicle API request failed" in str(exc_info.value) - - -def test_fetch_udm_search_view_parsing_error(chronicle_client): - """Test handling of API errors""" - error_response = Mock() - error_response.status_code = 200 - error_response.json.text = '[{invalid: json}]' - error_response.json.side_effect = ValueError("Invalid JSON") - - with patch.object(chronicle_client.session, "post", return_value=error_response): - with pytest.raises(APIError) as exc_info: - chronicle_client.fetch_udm_search_view( - query='metadata.event_type = "PROCESS_LAUNCH"', - start_time=datetime(2024, 1, 14, 23, 7, tzinfo=timezone.utc), - end_time=datetime(2024, 1, 15, 0, 7, tzinfo=timezone.utc), - max_events=1 - ) - - assert "Failed to parse UDM search response" in str(exc_info.value) - - def test_get_stats(chronicle_client): """Test stats search functionality.""" # Mock the search request @@ -897,110 +791,3 @@ def test_fix_json_formatting(chronicle_client): json_without_trailing_commas = '{"a": [1, 2], "b": {"c": 3, "d": 4}}' fixed = chronicle_client._fix_json_formatting(json_without_trailing_commas) assert fixed == json_without_trailing_commas - - -def test_find_udm_field_values_basic(chronicle_client): - """Test basic UDM field values search functionality.""" - # Mock the response - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "valueMatches": [ - {"value": "elevated", "count": 15}, - {"value": "elevation", "count": 8} - ], - "fieldMatches": [ - {"field": "principal.process.file.full_path", "count": 12}, - {"field": "principal.user.attribute.roles", "count": 11} - ], - "fieldMatchRegex": ".*elev.*" - } - - # Configure the mock session - chronicle_client.session.get.return_value = mock_response - - # Call the method - result = chronicle_client.find_udm_field_values(query="elev") - - # Verify the request was made correctly - chronicle_client.session.get.assert_called_once_with( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}:findUdmFieldValues", - params={"query": "elev"} - ) - - # Verify the response was processed correctly - assert len(result["valueMatches"]) == 2 - assert result["valueMatches"][0]["value"] == "elevated" - assert result["valueMatches"][0]["count"] == 15 - assert result["valueMatches"][1]["value"] == "elevation" - assert len(result["fieldMatches"]) == 2 - assert result["fieldMatchRegex"] == ".*elev.*" - - -def test_find_udm_field_values_with_page_size(chronicle_client): - """Test UDM field values search with page size parameter.""" - # Mock the response - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "valueMatches": [ - {"value": "elevated", "count": 15} - ], - "fieldMatches": [ - {"field": "principal.process.file.full_path", "count": 12} - ] - } - - # Configure the mock session - chronicle_client.session.get.return_value = mock_response - - # Call the method with page_size - result = chronicle_client.find_udm_field_values(query="elev", page_size=1) - - # Verify the request was made with correct parameters - chronicle_client.session.get.assert_called_once_with( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}:findUdmFieldValues", - params={"query": "elev", "pageSize": 1} - ) - - # Verify the response - assert len(result["valueMatches"]) == 1 - assert len(result["fieldMatches"]) == 1 - - -def test_find_udm_field_values_error_response(chronicle_client): - """Test error handling for UDM field values search.""" - # Mock an error response - mock_response = Mock() - mock_response.status_code = 400 - mock_response.text = "Bad Request: Invalid query parameter" - - # Configure the mock session - chronicle_client.session.get.return_value = mock_response - - # Verify that APIError is raised - with pytest.raises(APIError) as excinfo: - chronicle_client.find_udm_field_values(query="invalid:query") - - # Verify the error message - assert "Chronicle API request failed" in str(excinfo.value) - assert "Bad Request: Invalid query parameter" in str(excinfo.value) - - -def test_find_udm_field_values_json_error(chronicle_client): - """Test JSON parsing error for UDM field values search.""" - # Mock a response with invalid JSON - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.side_effect = ValueError("Invalid JSON") - - # Configure the mock session - chronicle_client.session.get.return_value = mock_response - - # Verify that SecOpsError is raised - with pytest.raises(SecOpsError) as excinfo: - chronicle_client.find_udm_field_values(query="elev") - - # Verify the error message - assert "Failed to parse response as JSON" in str(excinfo.value) - assert "Invalid JSON" in str(excinfo.value) diff --git a/tests/chronicle/test_udm_search.py b/tests/chronicle/test_udm_search.py new file mode 100644 index 0000000..d1379e2 --- /dev/null +++ b/tests/chronicle/test_udm_search.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest + +from secops.chronicle.models import APIVersion +from secops.exceptions import APIError + +from secops.chronicle.udm_search import ( + fetch_udm_search_csv, + fetch_udm_search_view, + find_udm_field_values, +) + + +@pytest.fixture +def client() -> Mock: + return Mock() + + +def test_fetch_udm_search_csv_calls_chronicle_request_with_expected_payload(client: Mock) -> None: + expected = "timestamp,user\n2024-01-15T00:00:00Z,user1\n" + + start = datetime(2024, 1, 14, 23, 7, tzinfo=timezone.utc) + end = datetime(2024, 1, 15, 0, 7, tzinfo=timezone.utc) + + with patch("secops.chronicle.udm_search.chronicle_request", return_value=expected) as req: + result = fetch_udm_search_csv( + client=client, + query='metadata.event_type = "NETWORK_CONNECTION"', + start_time=start, + end_time=end, + fields=["timestamp", "user"], + ) + + assert result == expected + + req.assert_called_once() + kwargs = req.call_args.kwargs + + # Called with correct Chronicle endpoint + assert req.call_args.args[0] is client + assert kwargs["method"] == "POST" + assert kwargs["endpoint_path"] == "legacy:legacyFetchUdmSearchCsv" + assert kwargs["api_version"] == APIVersion.V1ALPHA + + # Payload correctness + body = kwargs["json"] + assert body["baselineQuery"] == 'metadata.event_type = "NETWORK_CONNECTION"' + assert body["baselineTimeRange"]["startTime"] == start.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + assert body["baselineTimeRange"]["endTime"] == end.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + assert body["fields"]["fields"] == ["timestamp", "user"] + assert body["caseInsensitive"] is True + + +def test_fetch_udm_search_view_filters_to_complete_results(client: Mock) -> None: + # Module behaviour: ignore entries that are neither complete nor error + json_resp = [ + {"complete": False}, + {"complete": False, "progress": 50}, + {"complete": True, "results": [{"id": 1}]}, + ] + + start = datetime(2024, 1, 14, 23, 7, tzinfo=timezone.utc) + end = datetime(2024, 1, 15, 0, 7, tzinfo=timezone.utc) + + with patch("secops.chronicle.udm_search.chronicle_request", return_value=json_resp): + result = fetch_udm_search_view( + client=client, + query='metadata.event_type = "NETWORK_CONNECTION"', + start_time=start, + end_time=end, + max_events=1, + ) + + assert result == [{"complete": True, "results": [{"id": 1}]}] + + +def test_fetch_udm_search_view_returns_raw_when_no_complete_entries(client: Mock) -> None: + # If no "complete" responses are present, module returns json_resp as-is + json_resp = [{"foo": 1}, {"bar": 2}] + + start = datetime(2024, 1, 14, 23, 7, tzinfo=timezone.utc) + end = datetime(2024, 1, 15, 0, 7, tzinfo=timezone.utc) + + with patch("secops.chronicle.udm_search.chronicle_request", return_value=json_resp): + result = fetch_udm_search_view( + client=client, + query="q", + start_time=start, + end_time=end, + ) + + assert result == json_resp + + +def test_fetch_udm_search_view_raises_on_error_entry(client: Mock) -> None: + json_resp = [{"error": "wrong, please try again later"}] + + start = datetime(2024, 1, 14, 23, 7, tzinfo=timezone.utc) + end = datetime(2024, 1, 15, 0, 7, tzinfo=timezone.utc) + + with patch("secops.chronicle.udm_search.chronicle_request", return_value=json_resp): + with pytest.raises(APIError, match=r"Chronicle API request failed: wrong, please try again later"): + fetch_udm_search_view( + client=client, + query="q", + start_time=start, + end_time=end, + ) + + +def test_find_udm_field_values_basic_calls_chronicle_request(client: Mock) -> None: + expected = { + "valueMatches": [{"value": "elevated", "count": 15}], + "fieldMatches": [{"field": "principal.process.file.full_path", "count": 12}], + "fieldMatchRegex": ".*elev.*", + } + + with patch("secops.chronicle.udm_search.chronicle_request", return_value=expected) as req: + result = find_udm_field_values(client=client, query="elev") + + assert result == expected + + kwargs = req.call_args.kwargs + assert req.call_args.args[0] is client + assert kwargs["method"] == "GET" + assert kwargs["endpoint_path"] == ":findUdmFieldValues" + assert kwargs["api_version"] == APIVersion.V1ALPHA + assert kwargs["params"] == {"query": "elev"} + + +def test_find_udm_field_values_with_page_size_passes_page_size(client: Mock) -> None: + expected = {"valueMatches": [], "fieldMatches": [], "fieldMatchRegex": ".*elev.*"} + + with patch("secops.chronicle.udm_search.chronicle_request", return_value=expected) as req: + result = find_udm_field_values(client=client, query="elev", page_size=10) + + assert result == expected + assert req.call_args.kwargs["params"] == {"query": "elev", "pageSize": 10} From ca80becfaf4a9bbeea90146bd79c88bacfde9024 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sun, 4 Jan 2026 07:36:54 +0000 Subject: [PATCH 32/41] refactor: refactor to use request helper --- src/secops/chronicle/udm_mapping.py | 20 ++--- tests/chronicle/test_udm_mapping.py | 135 +++++++++++----------------- 2 files changed, 63 insertions(+), 92 deletions(-) diff --git a/src/secops/chronicle/udm_mapping.py b/src/secops/chronicle/udm_mapping.py index cea5eeb..1321ab9 100644 --- a/src/secops/chronicle/udm_mapping.py +++ b/src/secops/chronicle/udm_mapping.py @@ -18,7 +18,8 @@ import sys from typing import Any -from secops.exceptions import APIError +from secops.chronicle.models import APIVersion +from secops.chronicle.utils.request_utils import chronicle_request # Use built-in StrEnum if Python 3.11+, otherwise create a compatible version if sys.version_info >= (3, 11): @@ -67,9 +68,6 @@ def generate_udm_key_value_mappings( Raises: APIError: If the API request fails """ - # Endpoint for UDM key-value mappings - url = f"{client.base_url}/{client.instance_id}:generateUdmKeyValueMappings" - encoded_log = None try: decoded = base64.b64decode(log) @@ -86,10 +84,10 @@ def generate_udm_key_value_mappings( if compress_array_fields is not None: payload["compress_array_fields"] = compress_array_fields - response = client.session.post(url, json=payload) - - if response.status_code != 200: - raise APIError(f"Failed to generate key/value mapping: {response.text}") - - # Return field mappings from parsed response - return response.json().get("fieldMappings") + return chronicle_request( + client, + "POST", + endpoint_path=":generateUdmKeyValueMappings", + api_version=APIVersion.V1ALPHA, + json=payload, + ) diff --git a/tests/chronicle/test_udm_mapping.py b/tests/chronicle/test_udm_mapping.py index cb6b1a7..449db45 100644 --- a/tests/chronicle/test_udm_mapping.py +++ b/tests/chronicle/test_udm_mapping.py @@ -14,121 +14,94 @@ # """Tests for the UDM Key/Value Mapping module.""" +from __future__ import annotations + import base64 from unittest.mock import Mock, patch import pytest -from secops.chronicle.client import ChronicleClient -from secops.chronicle.udm_mapping import ( - RowLogFormat, - generate_udm_key_value_mappings, -) +from secops.chronicle.models import APIVersion +from secops.chronicle.udm_mapping import RowLogFormat, generate_udm_key_value_mappings from secops.exceptions import APIError @pytest.fixture -def chronicle_client(): - """Create a mock Chronicle client for testing.""" - with patch("secops.auth.SecOpsAuth") as mock_auth: - mock_session = Mock() - mock_session.headers = {} - mock_auth.return_value.session = mock_session - return ChronicleClient( - customer_id="test-customer", project_id="test-project" - ) - - -@pytest.fixture -def response_mock(): - """Create a mock API response object.""" - mock = Mock() - mock.status_code = 200 - mock.json.return_value = {"testKey": "testValue"} - return mock +def client() -> Mock: + # This module only passes client through to chronicle_request + return Mock() def test_row_log_format_enum() -> None: - """Test RowLogFormat enum values and string representation.""" assert str(RowLogFormat.JSON) == "JSON" assert str(RowLogFormat.CSV) == "CSV" assert str(RowLogFormat.XML) == "XML" assert str(RowLogFormat.LOG_FORMAT_UNSPECIFIED) == "LOG_FORMAT_UNSPECIFIED" -def test_generate_udm_key_value_mappings_success( - chronicle_client, response_mock -): - """Test generate_udm_key_value_mappings with success response.""" - - response_mock.json.return_value = { +def test_generate_udm_key_value_mappings_success(client: Mock) -> None: + expected = { "fieldMappings": { "event.id": "123", "event.user": "test_user", "event.action": "allowed", } } - chronicle_client.session.post.return_value = response_mock - # Test input test_log = '{"event":{"id":"123","user":"test_user","action":"allowed"}}' - result = generate_udm_key_value_mappings( - chronicle_client, - RowLogFormat.JSON, - test_log, - use_array_bracket_notation=True, - compress_array_fields=False, - ) - - # Verify API call - expected_url = ( - f"{chronicle_client.base_url}/{chronicle_client.instance_id}" - ":generateUdmKeyValueMappings" - ) - chronicle_client.session.post.assert_called_once() - args, kwargs = chronicle_client.session.post.call_args - - # Check URL and payload structure - assert args[0] == expected_url - # Verify result - assert result == { - "event.id": "123", - "event.user": "test_user", - "event.action": "allowed", - } + with patch("secops.chronicle.udm_mapping.chronicle_request", return_value=expected) as req: + result = generate_udm_key_value_mappings( + client, + RowLogFormat.JSON, + test_log, + use_array_bracket_notation=True, + compress_array_fields=False, + ) + # Your function returns the full chronicle_request output (dict) + assert result == expected -def test_generate_udm_key_value_mappings_already_encoded( - chronicle_client, response_mock -): - """Test UDM mapping with already base64 encoded log.""" - response_mock.json.return_value = { - "fieldMappings": {"test.field": "test_value"} - } - chronicle_client.session.post.return_value = response_mock + # Verify helper invocation (module behaviour) + req.assert_called_once() + args, kwargs = req.call_args + + assert args[0] is client + assert args[1] == "POST" + assert kwargs["endpoint_path"] == ":generateUdmKeyValueMappings" + assert kwargs["api_version"] == APIVersion.V1ALPHA + + payload = kwargs["json"] + assert payload["log_format"] == RowLogFormat.JSON + assert payload["use_array_bracket_notation"] is True + assert payload["compress_array_fields"] is False + + # Ensure the log was base64 encoded + decoded = base64.b64decode(payload["log"]).decode("utf-8") + assert decoded == test_log + + +def test_generate_udm_key_value_mappings_already_encoded(client: Mock) -> None: + expected = {"fieldMappings": {"test.field": "test_value"}} - # Create a base64 encoded log raw_log = '{"test":{"field":"test_value"}}' encoded_log = base64.b64encode(raw_log.encode("utf-8")).decode("utf-8") - result = generate_udm_key_value_mappings( - chronicle_client, RowLogFormat.JSON, encoded_log - ) + with patch("secops.chronicle.udm_mapping.chronicle_request", return_value=expected) as req: + result = generate_udm_key_value_mappings(client, RowLogFormat.JSON, encoded_log) - # Assert log wasn't double-encoded - _, kwargs = chronicle_client.session.post.call_args - assert kwargs["json"]["log"] == encoded_log - assert result == {"test.field": "test_value"} + assert result == expected + payload = req.call_args.kwargs["json"] + # Should not double-encode + assert payload["log"] == encoded_log + assert payload["log_format"] == RowLogFormat.JSON -def test_generate_udm_key_value_mappings_error(chronicle_client, response_mock): - """Test generate_udm_key_value_mappings function with error response.""" - response_mock.status_code = 400 - response_mock.text = "Bad Request" - chronicle_client.session.post.return_value = response_mock - with pytest.raises(APIError, match="Failed to generate key/value mapping"): - generate_udm_key_value_mappings( - chronicle_client, RowLogFormat.JSON, "test" - ) +def test_generate_udm_key_value_mappings_propagates_api_error(client: Mock) -> None: + with patch( + "secops.chronicle.udm_mapping.chronicle_request", + side_effect=APIError("boom"), + ): + with pytest.raises(APIError, match="boom"): + generate_udm_key_value_mappings(client, RowLogFormat.JSON, "test") From 343c7835323c894633be9e59669588218f3ad97a Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sun, 4 Jan 2026 20:39:54 +0000 Subject: [PATCH 33/41] refactor: refactor to include as_list option --- src/secops/chronicle/client.py | 5 ++++- src/secops/chronicle/featured_content_rules.py | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index a8c01ca..6e4e758 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -2568,6 +2568,7 @@ def list_featured_content_rules( page_size: int | None = None, page_token: str | None = None, filter_expression: str | None = None, + as_list: bool = False, ) -> list[dict[str, Any]] | dict[str, Any]: """List featured content rules from Chronicle Content Hub. @@ -2584,6 +2585,8 @@ def list_featured_content_rules( - rule_precision:"" (Precise or Broad) - search_rule_name_or_description=~"" Multiple filters can be combined with AND operator. + as_list: If True, return a list of watchlists instead of a dict + with watchlists list and nextPageToken. Returns: If page_size is not provided: A dictionary containing a list of all @@ -2596,7 +2599,7 @@ def list_featured_content_rules( APIError: If the API request fails """ return _list_featured_content_rules( - self, page_size, page_token, filter_expression + self, page_size, page_token, filter_expression, as_list ) def search_curated_detections( diff --git a/src/secops/chronicle/featured_content_rules.py b/src/secops/chronicle/featured_content_rules.py index 05885ad..c51ef64 100644 --- a/src/secops/chronicle/featured_content_rules.py +++ b/src/secops/chronicle/featured_content_rules.py @@ -16,6 +16,7 @@ from typing import Any +from secops.chronicle.models import APIVersion from secops.chronicle.utils.request_utils import ( chronicle_paginated_request, ) @@ -26,6 +27,7 @@ def list_featured_content_rules( page_size: int | None = None, page_token: str | None = None, filter_expression: str | None = None, + as_list: bool = False, ) -> list[dict[str, Any]] | dict[str, Any]: """List featured content rules from Chronicle Content Hub. @@ -43,6 +45,9 @@ def list_featured_content_rules( - rule_precision:"" (Precise or Broad) - search_rule_name_or_description=~"" Multiple filters can be combined with AND operator. + as_list: If True, return a list of curated rule set deployments + instead of a dict with curatedRuleSetDeployments list + and nextPageToken. Returns: If page_size is not provided: A dictionary containing a list of all @@ -60,10 +65,11 @@ def list_featured_content_rules( return chronicle_paginated_request( client, - base_url=client.base_url, + api_version=APIVersion.V1ALPHA, path="contentHub/featuredContentRules", items_key="featuredContentRules", page_size=page_size, page_token=page_token, extra_params=extra_params if extra_params else None, + as_list=as_list, ) From 07aa48b4510876f39daebad8a39deec38a1aa5f5 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sun, 4 Jan 2026 20:40:08 +0000 Subject: [PATCH 34/41] chore: linting --- src/secops/chronicle/rule_set.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/secops/chronicle/rule_set.py b/src/secops/chronicle/rule_set.py index ec648de..e4be717 100644 --- a/src/secops/chronicle/rule_set.py +++ b/src/secops/chronicle/rule_set.py @@ -96,7 +96,8 @@ def list_curated_rule_set_categories( page_size: Number of results to return per page. page_token: Token for the page to retrieve as_list: If True, return a list of curated rule set categories - instead of a dict with curatedRuleSetCategories list and nextPageToken. + instead of a dict with curatedRuleSetCategories list + and nextPageToken. Returns: If page_size is None: List of all categories. @@ -244,7 +245,8 @@ def list_curated_rule_set_deployments( only_enabled: Only return enabled rule set deployments only_alerting: Only return alerting rule set deployments as_list: If True, return a list of curated rule set deployments - instead of a dict with curatedRuleSetDeployments list and nextPageToken. + instead of a dict with curatedRuleSetDeployments list + and nextPageToken. Returns: If page_size is None: List of all deployments. From bf9e060ff28af8949c072e231dca3c55d56b1f17 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sun, 4 Jan 2026 20:42:43 +0000 Subject: [PATCH 35/41] chore: update docstrings --- src/secops/chronicle/client.py | 5 +++-- src/secops/chronicle/featured_content_rules.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 6e4e758..05e4beb 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -2585,8 +2585,9 @@ def list_featured_content_rules( - rule_precision:"" (Precise or Broad) - search_rule_name_or_description=~"" Multiple filters can be combined with AND operator. - as_list: If True, return a list of watchlists instead of a dict - with watchlists list and nextPageToken. + as_list: If True, return a list of featured content rules + instead of a dict with curatedRuleSetDeployments list + and nextPageToken. Returns: If page_size is not provided: A dictionary containing a list of all diff --git a/src/secops/chronicle/featured_content_rules.py b/src/secops/chronicle/featured_content_rules.py index c51ef64..fed5892 100644 --- a/src/secops/chronicle/featured_content_rules.py +++ b/src/secops/chronicle/featured_content_rules.py @@ -45,7 +45,7 @@ def list_featured_content_rules( - rule_precision:"" (Precise or Broad) - search_rule_name_or_description=~"" Multiple filters can be combined with AND operator. - as_list: If True, return a list of curated rule set deployments + as_list: If True, return a list of featured content rules instead of a dict with curatedRuleSetDeployments list and nextPageToken. From 0c1e7f7ff7d92f2e9ecf380490c78dd271535f71 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Sun, 4 Jan 2026 20:55:54 +0000 Subject: [PATCH 36/41] refactor: update tests with new request helper variables --- .../chronicle/test_featured_content_rules.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/chronicle/test_featured_content_rules.py b/tests/chronicle/test_featured_content_rules.py index 84044cd..4cb930e 100644 --- a/tests/chronicle/test_featured_content_rules.py +++ b/tests/chronicle/test_featured_content_rules.py @@ -13,13 +13,14 @@ # limitations under the License. # """Tests for Chronicle featured content rules functions.""" - +from sys import api_version from typing import Any, Dict from unittest.mock import Mock, patch import pytest from secops.chronicle.client import ChronicleClient +from secops.chronicle.models import APIVersion from secops.chronicle.featured_content_rules import ( list_featured_content_rules, ) @@ -96,12 +97,13 @@ def test_list_featured_content_rules_success_without_params( mock_paginated.assert_called_once_with( chronicle_client, - base_url=chronicle_client.base_url, + api_version=APIVersion.V1ALPHA, path="contentHub/featuredContentRules", items_key="featuredContentRules", page_size=None, page_token=None, extra_params=None, + as_list=False ) @@ -130,12 +132,13 @@ def test_list_featured_content_rules_with_page_size(chronicle_client): mock_paginated.assert_called_once_with( chronicle_client, - base_url=chronicle_client.base_url, + api_version=APIVersion.V1ALPHA, path="contentHub/featuredContentRules", items_key="featuredContentRules", page_size=10, page_token=None, extra_params=None, + as_list=False ) @@ -164,12 +167,13 @@ def test_list_featured_content_rules_with_page_token(chronicle_client): mock_paginated.assert_called_once_with( chronicle_client, - base_url=chronicle_client.base_url, + api_version=APIVersion.V1ALPHA, path="contentHub/featuredContentRules", items_key="featuredContentRules", page_size=None, page_token="token-xyz-789", extra_params=None, + as_list=False ) @@ -203,12 +207,13 @@ def test_list_featured_content_rules_with_filter_expression( mock_paginated.assert_called_once_with( chronicle_client, - base_url=chronicle_client.base_url, + api_version=APIVersion.V1ALPHA, path="contentHub/featuredContentRules", items_key="featuredContentRules", page_size=None, page_token=None, extra_params={"filter": filter_expr}, + as_list=False ) @@ -241,18 +246,20 @@ def test_list_featured_content_rules_with_all_parameters( page_size=5, page_token="current-token", filter_expression=filter_expr, + as_list=False ) assert result == expected mock_paginated.assert_called_once_with( chronicle_client, - base_url=chronicle_client.base_url, + api_version=APIVersion.V1ALPHA, path="contentHub/featuredContentRules", items_key="featuredContentRules", page_size=5, page_token="current-token", extra_params={"filter": filter_expr}, + as_list=False ) @@ -329,10 +336,11 @@ def test_list_featured_content_rules_max_page_size(chronicle_client): mock_paginated.assert_called_once_with( chronicle_client, - base_url=chronicle_client.base_url, + api_version=APIVersion.V1ALPHA, path="contentHub/featuredContentRules", items_key="featuredContentRules", page_size=1000, page_token=None, extra_params=None, + as_list=False ) From ee638899b94a6adf47ee1b648457eccf15fea3fd Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:49:24 +0530 Subject: [PATCH 37/41] chore: improved type hints and documentation for return types and function parameters. --- src/secops/chronicle/client.py | 36 +++++++++++++-------- src/secops/chronicle/dashboard.py | 35 ++++++++++++-------- src/secops/chronicle/log_types.py | 2 +- src/secops/chronicle/udm_mapping.py | 7 ++-- src/secops/chronicle/udm_search.py | 15 ++++++--- src/secops/chronicle/utils/request_utils.py | 17 ++++++++-- src/secops/chronicle/validate.py | 7 ++-- src/secops/chronicle/watchlist.py | 5 +-- 8 files changed, 82 insertions(+), 42 deletions(-) diff --git a/src/secops/chronicle/client.py b/src/secops/chronicle/client.py index 9818edc..b32b6ae 100644 --- a/src/secops/chronicle/client.py +++ b/src/secops/chronicle/client.py @@ -633,7 +633,7 @@ def list_watchlists( page_size: int | None = None, page_token: str | None = None, as_list: bool = False, - ) -> dict[str, Any]: + ) -> dict[str, Any] | list[dict[str, Any]]: """Get a list of all watchlists. Args: @@ -643,7 +643,9 @@ def list_watchlists( with watchlists list and nextPageToken. Returns: - Dictionary with list of watchlists + If as_list is True: List of watchlists. + If as_list is False: Dict with watchlists list and + nextPageToken. Raises: APIError: If the API request fails @@ -2469,7 +2471,9 @@ def list_curated_rule_sets( as a list instead of a dict Returns: - Dictionary containing the list of curated rule sets + If as_list is True: List of curated rule sets. + If as_list is False: Dict with curatedRuleSets list and + nextPageToken. Raises: APIError: If the API request fails @@ -2491,7 +2495,9 @@ def list_curated_rule_set_categories( set categories as a list instead of a dict Returns: - Dictionary containing the list of curated rule set categories + If as_list is True: List of curated rule set categories. + If as_list is False: Dict with curatedRuleSetCategories list + and nextPageToken. Raises: APIError: If the API request fails @@ -2507,7 +2513,7 @@ def list_curated_rule_set_deployments( only_enabled: bool | None = False, only_alerting: bool | None = False, as_list: bool = False, - ) -> dict[str, Any]: + ) -> dict[str, Any] | list[dict[str, Any]]: """Get a list of all curated rule set deployments. Args: @@ -2519,7 +2525,9 @@ def list_curated_rule_set_deployments( set deployments as a list instead of a dict Returns: - Dictionary containing the list of curated rule set deployments + If as_list is True: List of curated rule set deployments. + If as_list is False: Dict with curatedRuleSetDeployments list + and nextPageToken. Raises: APIError: If the API request fails @@ -2576,7 +2584,7 @@ def list_curated_rules( page_size: int | None = None, page_token: str | None = None, as_list: bool = False, - ) -> dict[str, Any]: + ) -> dict[str, Any] | list[dict[str, Any]]: """Get a list of all curated rules. Args: @@ -2586,7 +2594,9 @@ def list_curated_rules( a list instead of a dict Returns: - Dictionary containing the list of curated rules + If as_list is True: List of curated rules. + If as_list is False: Dict with curatedRules list and + nextPageToken. Raises: APIError: If the API request fails @@ -2698,15 +2708,13 @@ def list_featured_content_rules( - search_rule_name_or_description=~"" Multiple filters can be combined with AND operator. as_list: If True, return a list of featured content rules - instead of a dict with curatedRuleSetDeployments list + instead of a dict with featuredContentRules list and nextPageToken. Returns: - If page_size is not provided: A dictionary containing a list of all - featured content rules. - If page_size is provided: A dictionary containing a list of - featuredContentRules and a nextPageToken if more results are - available. + If as_list is True: List of featured content rules. + If as_list is False: Dict with featuredContentRules list and + nextPageToken if more results are available. Raises: APIError: If the API request fails diff --git a/src/secops/chronicle/dashboard.py b/src/secops/chronicle/dashboard.py index 29fd5c1..4e12947 100644 --- a/src/secops/chronicle/dashboard.py +++ b/src/secops/chronicle/dashboard.py @@ -19,7 +19,7 @@ import json import sys -from typing import Any +from typing import TYPE_CHECKING, Any from secops.chronicle.models import ( DashboardChart, @@ -29,6 +29,9 @@ ) from secops.exceptions import APIError, SecOpsError +if TYPE_CHECKING: + from secops.chronicle.client import ChronicleClient + # Use built-in StrEnum if Python 3.11+, otherwise create a compatible version if sys.version_info >= (3, 11): from enum import StrEnum @@ -57,7 +60,7 @@ class DashboardView(StrEnum): def create_dashboard( - client, + client: "ChronicleClient", display_name: str, access_type: DashboardAccessType, description: str | None = None, @@ -125,7 +128,9 @@ def create_dashboard( return response.json() -def import_dashboard(client, dashboard: dict[str, Any]) -> dict[str, Any]: +def import_dashboard( + client: "ChronicleClient", dashboard: dict[str, Any] +) -> dict[str, Any]: """Import a native dashboard. Args: @@ -162,7 +167,9 @@ def import_dashboard(client, dashboard: dict[str, Any]) -> dict[str, Any]: return response.json() -def export_dashboard(client, dashboard_names: list[str]) -> dict[str, Any]: +def export_dashboard( + client: "ChronicleClient", dashboard_names: list[str] +) -> dict[str, Any]: """Export native dashboards. It supports single dashboard export operation only. @@ -199,7 +206,7 @@ def export_dashboard(client, dashboard_names: list[str]) -> dict[str, Any]: def list_dashboards( - client, + client: "ChronicleClient", page_size: int | None = None, page_token: str | None = None, ) -> dict[str, Any]: @@ -232,7 +239,7 @@ def list_dashboards( def get_dashboard( - client, + client: "ChronicleClient", dashboard_id: str, view: DashboardView | None = None, ) -> dict[str, Any]: @@ -271,7 +278,7 @@ def get_dashboard( # Updated update_dashboard function def update_dashboard( - client, + client: "ChronicleClient", dashboard_id: str, display_name: str | None = None, description: str | None = None, @@ -347,7 +354,9 @@ def update_dashboard( return response.json() -def delete_dashboard(client, dashboard_id: str) -> dict[str, Any]: +def delete_dashboard( + client: "ChronicleClient", dashboard_id: str +) -> dict[str, Any]: """Delete a dashboard. Args: @@ -378,7 +387,7 @@ def delete_dashboard(client, dashboard_id: str) -> dict[str, Any]: def duplicate_dashboard( - client, + client: "ChronicleClient", dashboard_id: str, display_name: str, access_type: DashboardAccessType, @@ -428,7 +437,7 @@ def duplicate_dashboard( def add_chart( - client, + client: "ChronicleClient", dashboard_id: str, display_name: str, chart_layout: dict[str, Any] | str, @@ -534,7 +543,7 @@ def add_chart( return response.json() -def get_chart(client, chart_id: str) -> dict[str, Any]: +def get_chart(client: "ChronicleClient", chart_id: str) -> dict[str, Any]: """Get detail for dashboard chart. Args: @@ -559,7 +568,7 @@ def get_chart(client, chart_id: str) -> dict[str, Any]: def remove_chart( - client, + client: "ChronicleClient", dashboard_id: str, chart_id: str, ) -> dict[str, Any]: @@ -601,7 +610,7 @@ def remove_chart( def edit_chart( - client, + client: "ChronicleClient", dashboard_id: str, dashboard_query: None | (dict[str, Any] | DashboardQuery | str) = None, dashboard_chart: None | (dict[str, Any] | DashboardChart | str) = None, diff --git a/src/secops/chronicle/log_types.py b/src/secops/chronicle/log_types.py index 798d6a8..f2acb48 100644 --- a/src/secops/chronicle/log_types.py +++ b/src/secops/chronicle/log_types.py @@ -49,7 +49,7 @@ def _fetch_log_types_from_api( List of log types. Raises: - Exception: If request fails. + APIError: If the API request fails. """ url = f"{client.base_url}/{client.instance_id}/logTypes" all_log_types: list[dict[str, Any]] = [] diff --git a/src/secops/chronicle/udm_mapping.py b/src/secops/chronicle/udm_mapping.py index 1321ab9..c6e3253 100644 --- a/src/secops/chronicle/udm_mapping.py +++ b/src/secops/chronicle/udm_mapping.py @@ -16,11 +16,14 @@ import base64 import sys -from typing import Any +from typing import TYPE_CHECKING, Any from secops.chronicle.models import APIVersion from secops.chronicle.utils.request_utils import chronicle_request +if TYPE_CHECKING: + from secops.chronicle.client import ChronicleClient + # Use built-in StrEnum if Python 3.11+, otherwise create a compatible version if sys.version_info >= (3, 11): from enum import StrEnum @@ -44,7 +47,7 @@ class RowLogFormat(StrEnum): def generate_udm_key_value_mappings( - client, + client: "ChronicleClient", log_format: RowLogFormat, log: str, use_array_bracket_notation: bool | None = None, diff --git a/src/secops/chronicle/udm_search.py b/src/secops/chronicle/udm_search.py index 5373707..03aac29 100644 --- a/src/secops/chronicle/udm_search.py +++ b/src/secops/chronicle/udm_search.py @@ -15,15 +15,18 @@ """UDM search functionality for Chronicle.""" from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any from secops.exceptions import APIError from secops.chronicle.models import APIVersion from secops.chronicle.utils.request_utils import chronicle_request +if TYPE_CHECKING: + from secops.chronicle.client import ChronicleClient + def fetch_udm_search_csv( - client, + client: "ChronicleClient", query: str, start_time: datetime, end_time: datetime, @@ -41,7 +44,7 @@ def fetch_udm_search_csv( case_insensitive: Whether to perform case-insensitive search Returns: - CSV formatted string of results + Dictionary containing the CSV formatted results Raises: APIError: If the API request fails @@ -67,7 +70,9 @@ def fetch_udm_search_csv( def find_udm_field_values( - client, query: str, page_size: int | None = None + client: "ChronicleClient", + query: str, + page_size: int | None = None, ) -> dict[str, Any]: """Fetch UDM field values that match a query. @@ -96,7 +101,7 @@ def find_udm_field_values( def fetch_udm_search_view( - client, + client: "ChronicleClient", query: str, start_time: datetime, end_time: datetime, diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index c311751..caf8d63 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -14,7 +14,7 @@ # """Helper functions for Chronicle.""" -from typing import Any +from typing import TYPE_CHECKING, Any import requests from google.auth.exceptions import GoogleAuthError @@ -22,13 +22,24 @@ from secops.exceptions import APIError from secops.chronicle.models import APIVersion +if TYPE_CHECKING: + from secops.chronicle.client import ChronicleClient + DEFAULT_PAGE_SIZE = 1000 MAX_BODY_CHARS = 2000 def _safe_body_preview(text: str | None, limit: int = MAX_BODY_CHARS) -> str: - """Generate a safe, truncated preview of body contents for error messages""" + """Generate a safe, truncated preview of body contents for error messages. + + Args: + text: The text to preview + limit: The maximum number of characters to include in the preview + + Returns: + str: The preview of the text + """ if not text: return "" if len(text) <= limit: @@ -222,7 +233,7 @@ def chronicle_request( if endpoint_path.startswith(":"): url = f"{base}{endpoint_path}" else: - url = f"{base}/{endpoint_path.lstrip('/')}" + url = f'{base}/{endpoint_path.lstrip("/")}' try: response = client.session.request( diff --git a/src/secops/chronicle/validate.py b/src/secops/chronicle/validate.py index 9a3c13e..8f8f92a 100644 --- a/src/secops/chronicle/validate.py +++ b/src/secops/chronicle/validate.py @@ -14,13 +14,16 @@ # """Query validation functionality for Chronicle.""" -from typing import Any +from typing import TYPE_CHECKING, Any from secops.chronicle.models import APIVersion from secops.chronicle.utils.request_utils import chronicle_request +if TYPE_CHECKING: + from secops.chronicle.client import ChronicleClient -def validate_query(client, query: str) -> dict[str, Any]: + +def validate_query(client: "ChronicleClient", query: str) -> dict[str, Any]: """Validate a UDM query against the Chronicle API. Args: diff --git a/src/secops/chronicle/watchlist.py b/src/secops/chronicle/watchlist.py index dc5ce75..951e60a 100644 --- a/src/secops/chronicle/watchlist.py +++ b/src/secops/chronicle/watchlist.py @@ -28,7 +28,7 @@ def list_watchlists( page_size: int | None = None, page_token: str | None = None, as_list: bool = False, -) -> dict[str, Any]: +) -> dict[str, Any] | list[dict[str, Any]]: """Get a list of watchlists. Args: @@ -39,7 +39,8 @@ def list_watchlists( with watchlists list and nextPageToken. Returns: - List of watchlists + If as_list is True: List of watchlists. + If as_list is False: Dict with watchlists list and nextPageToken. Raises: APIError: If the API request fails From 205d93fca37826ac72af2fb51ef0ff69e2b7509c Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:07:36 +0530 Subject: [PATCH 38/41] feat: add --as-list flag to CLI commands for list-only output without pagination metadata. chore: updated integration tests for new list handling. --- src/secops/chronicle/rule_set.py | 10 ++++-- src/secops/chronicle/udm_search.py | 7 +++- src/secops/cli/commands/curated_rule.py | 9 +++++ .../cli/commands/featured_content_rules.py | 7 +++- src/secops/cli/commands/watchlist.py | 3 ++ src/secops/cli/utils/common_args.py | 18 ++++++++++ .../test_curated_rule_integration.py | 36 +++++++++++-------- tests/chronicle/test_integration.py | 11 +++--- .../cli/test_curated_rule_cli_integration.py | 27 +++++++------- ...test_featured_content_rules_integration.py | 4 +-- tests/cli/test_integration.py | 13 +++++-- 11 files changed, 104 insertions(+), 41 deletions(-) diff --git a/src/secops/chronicle/rule_set.py b/src/secops/chronicle/rule_set.py index e4be717..c7885bf 100644 --- a/src/secops/chronicle/rule_set.py +++ b/src/secops/chronicle/rule_set.py @@ -268,8 +268,10 @@ def list_curated_rule_set_deployments( ) # Extract deployments from response - rule_set_deployments = result.get("curatedRuleSetDeployments", []) - + if isinstance(result, list): + rule_set_deployments = result + else: + rule_set_deployments = result.get("curatedRuleSetDeployments", []) # Enrich the deployment data with the rule set displayName all_rule_sets = list_curated_rule_sets(client) @@ -296,9 +298,11 @@ def list_curated_rule_set_deployments( if deployment.get("alerting", False) ] + if as_list: + return rule_set_deployments + # Update result with filtered deployments result["curatedRuleSetDeployments"] = rule_set_deployments - return result diff --git a/src/secops/chronicle/udm_search.py b/src/secops/chronicle/udm_search.py index 03aac29..f825d01 100644 --- a/src/secops/chronicle/udm_search.py +++ b/src/secops/chronicle/udm_search.py @@ -59,7 +59,7 @@ def fetch_udm_search_csv( "caseInsensitive": case_insensitive, } - return chronicle_request( + result = chronicle_request( client, method="POST", endpoint_path="legacy:legacyFetchUdmSearchCsv", @@ -68,6 +68,11 @@ def fetch_udm_search_csv( headers={"Accept": "*/*"}, ) + if isinstance(result, list): + return result[0] + + return result + def find_udm_field_values( client: "ChronicleClient", diff --git a/src/secops/cli/commands/curated_rule.py b/src/secops/cli/commands/curated_rule.py index f2ebd57..a03add0 100644 --- a/src/secops/cli/commands/curated_rule.py +++ b/src/secops/cli/commands/curated_rule.py @@ -19,6 +19,7 @@ from secops.cli.utils.common_args import ( add_pagination_args, add_time_range_args, + add_as_list_arg, ) from secops.cli.utils.formatters import output_formatter from secops.cli.utils.time_utils import get_time_range @@ -39,6 +40,7 @@ def setup_curated_rules_command(subparsers): rules_list = rules_sp.add_parser("list", help="List curated rules") add_pagination_args(rules_list) + add_as_list_arg(rules_list) rules_list.set_defaults(func=handle_curated_rules_rules_list_command) rules_get = rules_sp.add_parser("get", help="Get a curated rule") @@ -105,6 +107,7 @@ def setup_curated_rules_command(subparsers): "list", help="List curated rule sets" ) add_pagination_args(rule_set_list) + add_as_list_arg(rule_set_list) rule_set_list.set_defaults(func=handle_curated_rules_rule_set_list_command) rule_set_get = rule_set_subparser.add_parser( @@ -126,6 +129,7 @@ def setup_curated_rules_command(subparsers): "list", help="List curated rule set categories" ) add_pagination_args(rule_set_cat_list) + add_as_list_arg(rule_set_cat_list) rule_set_cat_list.set_defaults( func=handle_curated_rules_rule_set_category_list_command ) @@ -159,6 +163,7 @@ def setup_curated_rules_command(subparsers): "--only-alerting", dest="only_alerting", action="store_true" ) add_pagination_args(rule_set_deployment_list) + add_as_list_arg(rule_set_deployment_list) rule_set_deployment_list.set_defaults( func=handle_curated_rules_rule_set_deployment_list_command ) @@ -209,6 +214,7 @@ def handle_curated_rules_rules_list_command(args, chronicle): out = chronicle.list_curated_rules( page_size=getattr(args, "page_size", None), page_token=getattr(args, "page_token", None), + as_list=getattr(args, "as_list", False), ) output_formatter(out, getattr(args, "output", "json")) except Exception as e: # pylint: disable=broad-exception-caught @@ -260,6 +266,7 @@ def handle_curated_rules_rule_set_list_command(args, chronicle): out = chronicle.list_curated_rule_sets( page_size=getattr(args, "page_size", None), page_token=getattr(args, "page_token", None), + as_list=getattr(args, "as_list", False), ) output_formatter(out, getattr(args, "output", "json")) except Exception as e: # pylint: disable=broad-exception-caught @@ -283,6 +290,7 @@ def handle_curated_rules_rule_set_category_list_command(args, chronicle): out = chronicle.list_curated_rule_set_categories( page_size=getattr(args, "page_size", None), page_token=getattr(args, "page_token", None), + as_list=getattr(args, "as_list", False), ) output_formatter(out, getattr(args, "output", "json")) except Exception as e: # pylint: disable=broad-exception-caught @@ -309,6 +317,7 @@ def handle_curated_rules_rule_set_deployment_list_command(args, chronicle): only_alerting=bool(args.only_alerting), page_size=getattr(args, "page_size", None), page_token=getattr(args, "page_token", None), + as_list=getattr(args, "as_list", False), ) output_formatter(out, getattr(args, "output", "json")) except Exception as e: # pylint: disable=broad-exception-caught diff --git a/src/secops/cli/commands/featured_content_rules.py b/src/secops/cli/commands/featured_content_rules.py index 704927e..c4351fc 100644 --- a/src/secops/cli/commands/featured_content_rules.py +++ b/src/secops/cli/commands/featured_content_rules.py @@ -16,7 +16,10 @@ import sys -from secops.cli.utils.common_args import add_pagination_args +from secops.cli.utils.common_args import ( + add_pagination_args, + add_as_list_arg, +) from secops.cli.utils.formatters import output_formatter @@ -33,6 +36,7 @@ def setup_featured_content_rules_command(subparsers): "list", help="List featured content rules" ) add_pagination_args(list_parser) + add_as_list_arg(list_parser) list_parser.add_argument( "--filter", "--filter-expression", @@ -53,6 +57,7 @@ def handle_featured_content_rules_list_command(args, chronicle): page_size=getattr(args, "page_size", None), page_token=getattr(args, "page_token", None), filter_expression=getattr(args, "filter_expression", None), + as_list=getattr(args, "as_list", False), ) output_formatter(out, getattr(args, "output", "json")) except Exception as e: # pylint: disable=broad-exception-caught diff --git a/src/secops/cli/commands/watchlist.py b/src/secops/cli/commands/watchlist.py index 303baf8..781f8a9 100644 --- a/src/secops/cli/commands/watchlist.py +++ b/src/secops/cli/commands/watchlist.py @@ -20,6 +20,7 @@ from secops.cli.utils.common_args import ( add_time_range_args, add_pagination_args, + add_as_list_arg, ) from secops.cli.utils.input_utils import load_json_or_file @@ -38,6 +39,7 @@ def setup_watchlist_command(subparsers): list_parser = lvl1.add_parser("list", help="List watchlists") add_time_range_args(list_parser) add_pagination_args(list_parser) + add_as_list_arg(list_parser) list_parser.set_defaults(func=handle_watchlist_list_command) # get command @@ -156,6 +158,7 @@ def handle_watchlist_list_command(args, chronicle): out = chronicle.list_watchlists( page_size=getattr(args, "page_size", None), page_token=getattr(args, "page_token", None), + as_list=getattr(args, "as_list", False), ) output_formatter(out, getattr(args, "output", "json")) except Exception as e: # pylint: disable=broad-exception-caught diff --git a/src/secops/cli/utils/common_args.py b/src/secops/cli/utils/common_args.py index 76014ef..5c09f84 100644 --- a/src/secops/cli/utils/common_args.py +++ b/src/secops/cli/utils/common_args.py @@ -133,3 +133,21 @@ def add_pagination_args(parser: argparse.ArgumentParser) -> None: dest="page_token", help="A page token, received from a previous `list` call.", ) + + +def add_as_list_arg(parser: argparse.ArgumentParser) -> None: + """Add as_list argument to a parser. + + Args: + parser: Parser to add arguments to + """ + parser.add_argument( + "--as-list", + "--as_list", + dest="as_list", + action="store_true", + help=( + "Return results as a list instead of a dict with pagination " + "metadata." + ), + ) diff --git a/tests/chronicle/test_curated_rule_integration.py b/tests/chronicle/test_curated_rule_integration.py index e1a1f9a..991c567 100644 --- a/tests/chronicle/test_curated_rule_integration.py +++ b/tests/chronicle/test_curated_rule_integration.py @@ -34,7 +34,7 @@ def chronicle(): def test_curated_rule_sets(chronicle): """Test listing and retrieving curated rule sets.""" # Test basic listing - rule_sets = chronicle.list_curated_rule_sets() + rule_sets = chronicle.list_curated_rule_sets(as_list=True) assert isinstance(rule_sets, list) assert len(rule_sets) > 0, "Expected at least one curated rule set to exist" @@ -66,7 +66,7 @@ def test_curated_rule_sets(chronicle): def test_curated_rule_set_categories(chronicle): """Test listing and retrieving curated rule set categories.""" # Test basic listing - categories = chronicle.list_curated_rule_set_categories() + categories = chronicle.list_curated_rule_set_categories(as_list=True) assert isinstance(categories, list) assert len(categories) > 0, "Expected at least one category to exist" @@ -100,7 +100,7 @@ def test_curated_rule_set_categories(chronicle): def test_curated_rules(chronicle): """Test listing and retrieving curated rules.""" # Test basic listing - rules = chronicle.list_curated_rules() + rules = chronicle.list_curated_rules(as_list=True) assert isinstance(rules, list) assert len(rules) > 0, "Expected at least one curated rule to exist" @@ -141,22 +141,29 @@ def test_curated_rule_set_deployments(chronicle): # Part 1: Test listing deployments print("\nTesting list_curated_rule_set_deployments") deployments = chronicle.list_curated_rule_set_deployments() - assert isinstance(deployments, list) + assert deployments.get("curatedRuleSetDeployments") + assert isinstance(deployments.get("curatedRuleSetDeployments"), list) if not deployments: pytest.skip("No rule set deployments found to test with") # Test with filters - enabled_deployments = chronicle.list_curated_rule_set_deployments( + list_enabled_rules_result = chronicle.list_curated_rule_set_deployments( only_enabled=True ) + enabled_deployments = list_enabled_rules_result.get( + "curatedRuleSetDeployments" + ) assert isinstance(enabled_deployments, list) for deployment in enabled_deployments: assert deployment.get("enabled") is True - alerting_deployments = chronicle.list_curated_rule_set_deployments( + list_alert_rules_result = chronicle.list_curated_rule_set_deployments( only_alerting=True ) + alerting_deployments = list_alert_rules_result.get( + "curatedRuleSetDeployments" + ) assert isinstance(alerting_deployments, list) for deployment in alerting_deployments: assert deployment.get("alerting") is True @@ -170,13 +177,14 @@ def test_curated_rule_set_deployments(chronicle): assert len(deployments_paged.get("curatedRuleSetDeployments")) <= page_size # Keep first deployment for reference - first_deployment = deployments[0] + first_deployment = deployments.get("curatedRuleSetDeployments")[0] assert "name" in first_deployment assert "displayName" in first_deployment # Part 2: Test getting deployment by rule set ID and precision print("\nTesting get_curated_rule_set_deployment") - rule_sets = chronicle.list_curated_rule_sets() + rule_sets_result = chronicle.list_curated_rule_sets() + rule_sets = rule_sets_result.get("curatedRuleSets") assert rule_sets, "No rule sets found to test with" # Get the first rule set's ID @@ -248,10 +256,10 @@ def test_update_curated_rule_set_deployment(chronicle): # 1. Find valid rule set and category IDs rule_sets = chronicle.list_curated_rule_sets() - assert rule_sets, "No rule sets found to test with" + assert rule_sets.get("curatedRuleSets"), "No rule sets found to test with" # Get a rule set ID - first_rule_set = rule_sets[0] + first_rule_set = rule_sets["curatedRuleSets"][0] rule_set_name = first_rule_set["name"] rule_set_id = rule_set_name.split("/")[-1] @@ -352,8 +360,7 @@ def test_update_curated_rule_set_deployment(chronicle): @pytest.mark.integration def test_search_curated_detections(chronicle): - """Test searching for detections generated by curated rules. - """ + """Test searching for detections generated by curated rules.""" from datetime import datetime, timedelta, timezone from secops.chronicle.models import AlertState, ListBasis @@ -361,9 +368,8 @@ def test_search_curated_detections(chronicle): # Get a valid rule ID first rules = chronicle.list_curated_rules() - assert rules, "No curated rules found to test with" - - first_rule = rules[0] + assert rules.get("curatedRules"), "No curated rules found to test with" + first_rule = rules["curatedRules"][0] rule_id = first_rule["name"].split("/")[-1] rule_name = first_rule["displayName"] print(f"Testing with rule: {rule_name} (ID: {rule_id})") diff --git a/tests/chronicle/test_integration.py b/tests/chronicle/test_integration.py index fa2e51a..5bf404d 100644 --- a/tests/chronicle/test_integration.py +++ b/tests/chronicle/test_integration.py @@ -45,9 +45,10 @@ def test_chronicle_search(): end_time=end_time, fields=["timestamp", "user", "hostname", "process name"], ) - - assert isinstance(result, str) - assert "timestamp" in result # Basic validation of CSV header + rows = result.get("csv",{}).get("row") + assert rows + assert isinstance(rows, list) + assert any("timestamp" in row for row in rows) # Basic validation of CSV header @pytest.mark.integration @@ -1887,12 +1888,14 @@ def test_chronicle_generate_udm_key_value_mapping(): try: print("\n>>> Testing Generating UDM key/value mapping") - json_mappings = chronicle.generate_udm_key_value_mappings( + response = chronicle.generate_udm_key_value_mappings( log_format="JSON", log=json_log, use_array_bracket_notation=True, compress_array_fields=False, ) + + json_mappings = response.get("fieldMappings", []) print(f"JSON mappings retrieved: {len(json_mappings)} fields") # Verify we got expected mapping fields diff --git a/tests/cli/test_curated_rule_cli_integration.py b/tests/cli/test_curated_rule_cli_integration.py index f5cef5e..ac19e4c 100644 --- a/tests/cli/test_curated_rule_cli_integration.py +++ b/tests/cli/test_curated_rule_cli_integration.py @@ -33,14 +33,14 @@ def test_cli_curated_rule_sets(cli_env, common_args): """ print("\nTesting rule-set list and get commands") - # Test list command + # Test list command with --as-list flag print("1. Listing curated rule sets") list_cmd = ( [ "secops", ] + common_args - + ["curated-rule", "rule-set", "list"] + + ["curated-rule", "rule-set", "list", "--as-list"] ) list_result = subprocess.run( @@ -111,14 +111,14 @@ def test_cli_curated_rule_set_categories(cli_env, common_args): """ print("\nTesting rule-set categories commands") - # Test list categories command + # Test list categories command with --as-list flag print("1. Listing curated rule set categories") list_cmd = ( [ "secops", ] + common_args - + ["curated-rule", "rule-set-category", "list"] + + ["curated-rule", "rule-set-category", "list", "--as-list"] ) list_result = subprocess.run( @@ -189,14 +189,14 @@ def test_cli_curated_rules(cli_env, common_args): """ print("\nTesting curated rules commands") - # List curated rules + # List curated rules with --as-list flag print("1. Listing curated rules") list_cmd = ( [ "secops", ] + common_args - + ["curated-rule", "rule", "list"] + + ["curated-rule", "rule", "list", "--as-list"] ) list_result = subprocess.run( @@ -297,14 +297,14 @@ def test_cli_curated_rule_set_deployments(cli_env, common_args): """ print("\nTesting rule-set deployment commands") - # Part 1: List deployments + # Part 1: List deployments with --as-list flag print("1. Listing curated rule set deployments") list_cmd = ( [ "secops", ] + common_args - + ["curated-rule", "rule-set-deployment", "list"] + + ["curated-rule", "rule-set-deployment", "list", "--as-list"] ) list_result = subprocess.run( @@ -318,14 +318,14 @@ def test_cli_curated_rule_set_deployments(cli_env, common_args): deployments = json.loads(list_result.stdout) assert isinstance(deployments, list), "Expected a list of deployments" - # Part 2: Get rule set for testing + # Part 2: Get rule set for testing with --as-list flag print("\n2. First list rule sets to get a valid ID") list_rs_cmd = ( [ "secops", ] + common_args - + ["curated-rule", "rule-set", "list"] + + ["curated-rule", "rule-set", "list", "--as-list"] ) list_rs_result = subprocess.run( @@ -541,14 +541,15 @@ def test_cli_curated_rule_set_deployments(cli_env, common_args): @pytest.mark.integration def test_cli_search_curated_detections(cli_env, common_args): - """Test CLI command for searching curated detections. - """ + """Test CLI command for searching curated detections.""" print("\nTesting curated-rule rule search-detections command") # Step 1: Get a valid rule ID first print("1. Getting a valid rule ID for testing") - list_rules_cmd = ["secops"] + common_args + ["curated-rule", "rule", "list"] + list_rules_cmd = ( + ["secops"] + common_args + ["curated-rule", "rule", "list", "--as-list"] + ) list_result = subprocess.run( list_rules_cmd, env=cli_env, capture_output=True, text=True diff --git a/tests/cli/test_featured_content_rules_integration.py b/tests/cli/test_featured_content_rules_integration.py index e9ee150..a93e8cb 100644 --- a/tests/cli/test_featured_content_rules_integration.py +++ b/tests/cli/test_featured_content_rules_integration.py @@ -42,7 +42,7 @@ def test_cli_featured_content_rules_list_with_filter(cli_env, common_args): try: output = json.loads(result.stdout) - assert isinstance(output, dict) + assert isinstance(output, dict), "Expected dict response" assert "featuredContentRules" in output assert isinstance(output["featuredContentRules"], list) @@ -77,7 +77,7 @@ def test_cli_featured_content_rules_list_with_page_size(cli_env, common_args): try: output = json.loads(result.stdout) - assert isinstance(output, dict) + assert isinstance(output, dict), "Expected dict response" assert "featuredContentRules" in output assert len(output["featuredContentRules"]) <= 5 diff --git a/tests/cli/test_integration.py b/tests/cli/test_integration.py index d3d2ac8..ae5fcfc 100644 --- a/tests/cli/test_integration.py +++ b/tests/cli/test_integration.py @@ -2917,6 +2917,14 @@ def test_cli_generate_udm_key_value_mapping(cli_env, common_args): mapping_data = json.loads(gum_result.stdout) + # Response should have fieldMappings key + assert ( + "fieldMappings" in mapping_data + ), "Response should contain fieldMappings" + + field_mappings = mapping_data["fieldMappings"] + + # Check for expected fields in fieldMappings expected_fields = [ "events.0.id", "events.0.user", @@ -2924,8 +2932,9 @@ def test_cli_generate_udm_key_value_mapping(cli_env, common_args): "events.1.user", ] - # Check for expected fields - assert all(field in mapping_data for field in expected_fields) + assert all( + field in field_mappings for field in expected_fields + ), f"Missing expected fields. Got: {list(field_mappings.keys())}" except Exception as e: print(f"Error during generate-udm-mapping CLI test: {str(e)}") From 9da478f8a67c643bfe056f960e008813c7a55d77 Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:07:53 +0530 Subject: [PATCH 39/41] chore: updated doc --- CLI.md | 38 ++++++++++++++++++++++++++++++++++++- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/CLI.md b/CLI.md index cea768f..eeb21a0 100644 --- a/CLI.md +++ b/CLI.md @@ -676,9 +676,12 @@ secops parser-extension delete --log-type OKTA --id "1234567890" List watchlists: ```bash -# List all watchlists +# List all watchlists (returns dict with pagination metadata) secops watchlist list +# List watchlists as a direct list (fetches all pages automatically) +secops watchlist list --as-list + # List watchlist with pagination secops watchlist list --page-size 50 ``` @@ -829,10 +832,17 @@ The `rule test` command outputs UDM events as pure JSON objects that can be pipe ### Curated Rule Set Management List all curated rules: + ```bash +# List all curated rules (returns dict with pagination metadata) secops curated-rule rule list + +# List curated rules as a direct list +secops curated-rule rule list --as-list ``` + Get curated rules: + ```bash # Get rule by UUID secops curated-rule rule get --id "ur_ttp_GCP_ServiceAPIDisable" @@ -842,6 +852,7 @@ secops curated-rule rule get --name "GCP Service API Disable" ``` Search for curated rule detections: + ```bash secops curated-rule search-detections \ --rule-id "ur_ttp_GCP_MassSecretDeletion" \ @@ -861,33 +872,54 @@ secops curated-rule search-detections \ ``` List all curated rule sets: + ```bash +# List all curated rule sets (returns dict with pagination metadata) secops curated-rule rule-set list + +# List curated rule sets as a direct list +secops curated-rule rule-set list --as-list ``` Get specific curated rule set details: + ```bash # Get curated rule set by UUID secops curated-rule rule-set get --id "f5533b66-9327-9880-93e6-75a738ac2345" + +# Get curated rule set by name +secops curated-rule rule-set get --name "Active Breach Priority Host Indicators" ``` List all curated rule set categories: + ```bash +# List all curated rule set categories (returns dict with pagination metadata) secops curated-rule rule-set-category list + +# List curated rule set categories as a direct list +secops curated-rule rule-set-category list --as-list ``` Get specific curated rule set category details: + ```bash # Get curated rule set category by UUID secops curated-rule rule-set-category get --id "db1114d4-569b-5f5d-0fb4-f65aaa766c92" ``` List all curated rule set deployments: + ```bash +# List all curated rule set deployments (returns dict with pagination metadata) secops curated-rule rule-set-deployment list + +# List curated rule set deployments as a direct list +secops curated-rule rule-set-deployment list --as-list ``` Get specific curated rule set deployment details: + ```bash # Get curated rule set deployment by UUID secops curated-rule rule-set-deployment get --id "f5533b66-9327-9880-93e6-75a738ac2345" @@ -1308,7 +1340,11 @@ Featured content rules are pre-built detection rules available in the Chronicle #### List all featured content rules: ```bash +# List all featured content rules (returns dict with pagination metadata) secops featured-content-rules list + +# List featured content rules as a direct list +secops featured-content-rules list --as-list ``` #### List with pagination: diff --git a/README.md b/README.md index e7243df..903728a 100644 --- a/README.md +++ b/README.md @@ -1854,7 +1854,15 @@ watchlist = chronicle.get_watchlist("acb-123-def") List all watchlists: ```python +# List watchlists (returns dict with pagination metadata) watchlists = chronicle.list_watchlists() +for watchlist in watchlists.get("watchlists", []): + print(f"Watchlist: {watchlist.get('displayName')}") + +# List watchlists as a direct list (automatically fetches all pages) +watchlists = chronicle.list_watchlists(as_list=True) +for watchlist in watchlists: + print(f"Watchlist: {watchlist.get('displayName')}") ``` ## Rule Management @@ -2164,14 +2172,21 @@ If `tooManyAlerts` is True in the response, consider narrowing your search crite Query curated rules: ```python -# List all curated rules -rules = chronicle.list_curated_rules() -for rule in rules: +# List all curated rules (returns dict with pagination metadata) +result = chronicle.list_curated_rules() +for rule in result.get("curatedRules", []): rule_id = rule.get("name", "").split("/")[-1] display_name = rule.get("description") description = rule.get("description") print(f"Rule: {display_name}, Description: {description}") +# List all curated rules as a direct list +rules = chronicle.list_curated_rules(as_list=True) +for rule in rules: + rule_id = rule.get("name", "").split("/")[-1] + display_name = rule.get("description") + print(f"Rule: {display_name}") + # Get a curated rule rule = chronicle.get_curated_rule("ur_ttp_lol_Atbroker") @@ -2218,8 +2233,15 @@ if "nextPageToken" in result: Query curated rule sets: ```python -# List all curated rule sets -rule_sets = chronicle.list_curated_rule_sets() +# List all curated rule sets (returns dict with pagination metadata) +result = chronicle.list_curated_rule_sets() +for rule_set in result.get("curatedRuleSets", []): + rule_set_id = rule_set.get("name", "").split("/")[-1] + display_name = rule_set.get("displayName") + print(f"Rule Set: {display_name}, ID: {rule_set_id}") + +# List all curated rule sets as a direct list +rule_sets = chronicle.list_curated_rule_sets(as_list=True) for rule_set in rule_sets: rule_set_id = rule_set.get("name", "").split("/")[-1] display_name = rule_set.get("displayName") @@ -2232,8 +2254,15 @@ rule_set = chronicle.get_curated_rule_set("00ad672e-ebb3-0dd1-2a4d-99bd7c5e5f93" Query curated rule set categories: ```python -# List all curated rule set categories -rule_set_categories = chronicle.list_curated_rule_set_categories() +# List all curated rule set categories (returns dict with pagination metadata) +result = chronicle.list_curated_rule_set_categories() +for rule_set_category in result.get("curatedRuleSetCategories", []): + rule_set_category_id = rule_set_category.get("name", "").split("/")[-1] + display_name = rule_set_category.get("displayName") + print(f"Rule Set Category: {display_name}, ID: {rule_set_category_id}") + +# List all curated rule set categories as a direct list +rule_set_categories = chronicle.list_curated_rule_set_categories(as_list=True) for rule_set_category in rule_set_categories: rule_set_category_id = rule_set_category.get("name", "").split("/")[-1] display_name = rule_set_category.get("displayName") @@ -2246,9 +2275,9 @@ rule_set_category = chronicle.get_curated_rule_set_category("110fa43d-7165-2355- Manage curated rule set deployments (turn alerting on or off (either precise or broad) for curated rule sets): ```python -# List all curated rule set deployments -rule_set_deployments = chronicle.list_curated_rule_set_deployments() -for rs_deployment in rule_set_deployments: +# List all curated rule set deployments (returns dict with pagination metadata) +result = chronicle.list_curated_rule_set_deployments() +for rs_deployment in result.get("curatedRuleSetDeployments", []): rule_set_id = rs_deployment.get("name", "").split("/")[-3] category_id = rs_deployment.get("name", "").split("/")[-5] deployment_status = rs_deployment.get("name", "").split("/")[-1] @@ -2262,6 +2291,13 @@ for rs_deployment in rule_set_deployments: f"Alerting: {alerting}", ) +# List all curated rule set deployments as a direct list +rule_set_deployments = chronicle.list_curated_rule_set_deployments(as_list=True) +for rs_deployment in rule_set_deployments: + rule_set_id = rs_deployment.get("name", "").split("/")[-3] + display_name = rs_deployment.get("displayName") + print(f"Rule Set: {display_name}, ID: {rule_set_id}") + # Get curated rule set deployment by ID rule_set_deployment = chronicle.get_curated_rule_set_deployment("00ad672e-ebb3-0dd1-2a4d-99bd7c5e5f93") From fa5a425f1ff2fba9206b816654fe2c0ca5508b12 Mon Sep 17 00:00:00 2001 From: PaperMtn Date: Fri, 9 Jan 2026 13:04:29 +0000 Subject: [PATCH 40/41] chore: version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7f7825a..328ea1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "secops" -version = "0.33.0" +version = "0.34.0" description = "Python SDK for wrapping the Google SecOps API for common use cases" readme = "README.md" requires-python = ">=3.10" From 71754d91098c0100cef28d69c940806c0fa12d2a Mon Sep 17 00:00:00 2001 From: Mihir Vala <179564180+mihirvala-crestdata@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:20:46 +0530 Subject: [PATCH 41/41] chore: added changelog --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d9b015..16b812a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.34.0] - 2026-01-12 +### Added +- `as_list` parameter for paginated list methods to streamline API requests and automatically fetch all pages + - Supported methods: `list_watchlists`, `list_curated_rules`, `list_curated_rule_sets`, `list_curated_rule_set_categories`, `list_curated_rule_set_deployments`, `list_featured_content_rules` +- CLI `--as-list` flag for corresponding list commands + +### Updated +- Refactored modules to use centralized `chronicle_request` helper function for improved code consistency and maintainability + - Watchlist (`watchlist.py`) + - Curated rule set (`rule_set.py`) + - Investigation (`investigations.py`) + - UDM mapping (`udm_mapping.py`) + - UDM search (`udm_search.py`) + - Validation (`validate.py`) + ## [0.33.0] - 2026-01-07 ### Added - Support for following investigation methods: