diff --git a/src/secops/__init__.py b/src/secops/__init__.py index bfd8d6b..80ee44c 100644 --- a/src/secops/__init__.py +++ b/src/secops/__init__.py @@ -14,7 +14,9 @@ # """Google SecOps SDK for Python.""" -__version__ = "0.1.2" +from importlib.metadata import version as _metadata_version + +__version__ = _metadata_version("secops") from secops.auth import SecOpsAuth from secops.client import SecOpsClient diff --git a/src/secops/chronicle/utils/request_utils.py b/src/secops/chronicle/utils/request_utils.py index b3f09b0..1db377b 100644 --- a/src/secops/chronicle/utils/request_utils.py +++ b/src/secops/chronicle/utils/request_utils.py @@ -14,13 +14,17 @@ # """Helper functions for Chronicle.""" +import platform +from importlib.metadata import version as _metadata_version from typing import TYPE_CHECKING, Any, Optional import requests from google.auth.exceptions import GoogleAuthError -from secops.exceptions import APIError from secops.chronicle.models import APIVersion +from secops.exceptions import APIError + +_LIBRARY_VERSION = _metadata_version("secops") if TYPE_CHECKING: from secops.chronicle.client import ChronicleClient @@ -30,6 +34,31 @@ MAX_BODY_CHARS = 2000 +def _build_api_client_header(endpoint_path: str) -> str: + """Build the x-goog-api-client header value for a request. + + Constructs a space-separated token string following the Google API client + header convention. A leading ':' is stripped from RPC-style endpoint paths + (e.g. ':udmSearch' becomes 'udmSearch'). + + Args: + endpoint_path: The API endpoint path passed to the request function, + e.g. 'rules/rule123:copy' or ':udmSearch'. + + Returns: + Header value string in the format: + 'gl-python/{version} rest/requests@{version} secops-wrapper/{version} + api/{endpoint}'. + """ + endpoint = endpoint_path.lstrip(":") + return ( + f"gl-python/{platform.python_version()}" + f" rest/requests@{requests.__version__}" + f" secops-wrapper/{_LIBRARY_VERSION}" + f" api/{endpoint}" + ) + + def _safe_body_preview(text: str | None, limit: int = MAX_BODY_CHARS) -> str: """Generate a safe, truncated preview of body contents for error messages. @@ -242,6 +271,12 @@ def chronicle_request( else: url = f'{base}/{endpoint_path.lstrip("/")}' + # Merge x-goog-api-client with any caller-supplied headers. + # Caller-supplied values take precedence. + merged_headers = {"x-goog-api-client": _build_api_client_header(endpoint_path)} + if headers: + merged_headers.update(headers) + # init request response response = None @@ -251,7 +286,7 @@ def chronicle_request( url=url, params=params, json=json, - headers=headers, + headers=merged_headers, timeout=timeout, ) except GoogleAuthError as exc: @@ -357,12 +392,16 @@ def chronicle_request_bytes( else: url = f'{base}/{endpoint_path.lstrip("/")}' + merged_headers = {"x-goog-api-client": _build_api_client_header(endpoint_path)} + if headers: + merged_headers.update(headers) + try: response = client.session.request( method=method, url=url, params=params, - headers=headers, + headers=merged_headers, timeout=timeout, stream=True, ) diff --git a/tests/chronicle/test_dashboard_query.py b/tests/chronicle/test_dashboard_query.py index 9e0cccd..f36388b 100644 --- a/tests/chronicle/test_dashboard_query.py +++ b/tests/chronicle/test_dashboard_query.py @@ -14,7 +14,7 @@ # """Tests for the Dashboard query module.""" import json -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import pytest @@ -102,7 +102,7 @@ def test_execute_query_success( url=url, params=None, json=payload, - headers=None, + headers=ANY, timeout=None, ) @@ -138,7 +138,7 @@ def test_execute_query_with_filters( url=url, params=None, json=payload, - headers=None, + headers=ANY, timeout=None, ) @@ -172,7 +172,7 @@ def test_execute_query_with_clear_cache( url=url, params=None, json=payload, - headers=None, + headers=ANY, timeout=None, ) @@ -201,7 +201,7 @@ def test_execute_query_with_string_json( ), params=None, json={"query": {"query": query, "input": json.loads(interval_str)}}, - headers=None, + headers=ANY, timeout=None, ) @@ -254,7 +254,7 @@ def test_get_execute_query_success( url=url, params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -291,7 +291,7 @@ def test_get_execute_query_with_full_id( url=url, params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) diff --git a/tests/chronicle/test_data_tables.py b/tests/chronicle/test_data_tables.py index e050f4d..985c814 100644 --- a/tests/chronicle/test_data_tables.py +++ b/tests/chronicle/test_data_tables.py @@ -2,6 +2,7 @@ import pytest from unittest.mock import ( + ANY, Mock, patch, call, @@ -89,7 +90,7 @@ def test_create_data_table_success( } ], }, - headers=None, + headers=ANY, timeout=None, ) @@ -195,7 +196,7 @@ def test_create_data_table_with_entity_mapping( } ], }, - headers=None, + headers=ANY, timeout=None, ) @@ -271,7 +272,7 @@ def test_create_data_table_with_column_options( }, ], }, - headers=None, + headers=ANY, timeout=None, ) @@ -295,7 +296,7 @@ def test_get_data_table_success(self, mock_chronicle_client: Mock) -> None: url=f"{mock_chronicle_client.base_url}/{mock_chronicle_client.instance_id}/dataTables/{dt_name}", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -325,7 +326,7 @@ def test_list_data_tables_success( url=f"{mock_chronicle_client.base_url}/{mock_chronicle_client.instance_id}/dataTables", params={"pageSize": 1000, "orderBy": "createTime asc"}, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -365,7 +366,7 @@ def test_delete_data_table_success( url=f"{mock_chronicle_client.base_url}/{mock_chronicle_client.instance_id}/dataTables/{dt_name}", params={"force": "true"}, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -427,7 +428,7 @@ def test_list_data_table_rows_success( url=f"{mock_chronicle_client.base_url}/{mock_chronicle_client.instance_id}/dataTables/{dt_name}/dataTableRows", params={"pageSize": 1000, "orderBy": "createTime asc"}, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -508,7 +509,7 @@ def test_create_reference_list_success( "entries": [{"value": "entryA"}, {"value": "entryB"}], "syntaxType": syntax_type.value, }, - headers=None, + headers=ANY, timeout=None, ) @@ -575,7 +576,7 @@ def test_get_reference_list_full_view_success( url=f"{mock_chronicle_client.base_url(APIVersion.V1)}/{mock_chronicle_client.instance_id}/referenceLists/{rl_name}", params={"view": ReferenceListView.FULL.value}, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -610,7 +611,7 @@ def test_list_reference_lists_basic_view_success( url=f"{mock_chronicle_client.base_url(APIVersion.V1)}/{mock_chronicle_client.instance_id}/referenceLists", params={"pageSize": 1000, "view": ReferenceListView.BASIC.value}, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -666,7 +667,7 @@ def test_update_reference_list_success( {"value": "new_entryY"}, ], }, - headers=None, + headers=ANY, timeout=None, ) @@ -724,7 +725,7 @@ def test_update_data_table_success_both_params( "description": new_description, "row_time_to_live": new_row_ttl, }, - headers=None, + headers=ANY, timeout=None, ) @@ -763,7 +764,7 @@ def test_update_data_table_description_only( url=f"{mock_chronicle_client.base_url}/{mock_chronicle_client.instance_id}/dataTables/{dt_name}", params=None, json={"description": new_description}, - headers=None, + headers=ANY, timeout=None, ) @@ -802,7 +803,7 @@ def test_update_data_table_row_ttl_only( url=f"{mock_chronicle_client.base_url}/{mock_chronicle_client.instance_id}/dataTables/{dt_name}", params=None, json={"row_time_to_live": new_row_ttl}, - headers=None, + headers=ANY, timeout=None, ) @@ -851,7 +852,7 @@ def test_update_data_table_with_update_mask( "description": new_description, "row_time_to_live": new_row_ttl, }, - headers=None, + headers=ANY, timeout=None, ) diff --git a/tests/chronicle/test_feed.py b/tests/chronicle/test_feed.py index 605aa24..a5926bd 100644 --- a/tests/chronicle/test_feed.py +++ b/tests/chronicle/test_feed.py @@ -15,7 +15,7 @@ """Tests for Chronicle feed functions.""" import pytest -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch from secops.chronicle.client import ChronicleClient from secops.chronicle.feeds import ( create_feed, @@ -85,7 +85,7 @@ def test_create_feed(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/feeds", params=None, json=feed_config.to_dict(), - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -115,7 +115,7 @@ def test_create_feed_with_json_string(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/feeds", params=None, json=expected_json, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -154,7 +154,7 @@ def test_get_feed(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/feeds/{feed_id}", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -193,7 +193,7 @@ def test_list_feeds(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/feeds", params={"pageSize": 100}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value["feeds"] @@ -290,7 +290,7 @@ def test_update_feed(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/feeds/{feed_id}", params={"updateMask": "display_name,details"}, json=feed_config.to_dict(), - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -317,7 +317,7 @@ def test_update_feed_with_custom_mask(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/feeds/{feed_id}", params={"updateMask": "display_name"}, json=feed_config.to_dict(), - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -355,7 +355,7 @@ def test_delete_feed(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/feeds/{feed_id}", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result is None @@ -391,7 +391,7 @@ def test_enable_feed(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/feeds/{feed_id}:enable", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) assert feed_id in f"{result}" @@ -427,7 +427,7 @@ def test_disable_feed(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/feeds/{feed_id}:disable", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) assert feed_id in f"{result}" @@ -465,7 +465,7 @@ def test_generate_secret(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/feeds/{feed_id}:generateSecret", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value diff --git a/tests/chronicle/test_log_processing_pipeline.py b/tests/chronicle/test_log_processing_pipeline.py index 2ed9b44..6ec44c5 100644 --- a/tests/chronicle/test_log_processing_pipeline.py +++ b/tests/chronicle/test_log_processing_pipeline.py @@ -15,7 +15,7 @@ """Tests for Chronicle log processing pipeline functions.""" import pytest -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch from secops.chronicle.client import ChronicleClient from secops.chronicle.log_processing_pipelines import ( @@ -87,7 +87,7 @@ def test_list_log_processing_pipelines(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines", params={"pageSize": 1000}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -121,7 +121,7 @@ def test_list_log_processing_pipelines_with_params( "filter": 'displayName="Test"', }, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -154,7 +154,7 @@ def test_get_log_processing_pipeline(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines/{pipeline_id}", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -195,7 +195,7 @@ def test_create_log_processing_pipeline(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines", params=None, json=pipeline_config, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -223,7 +223,7 @@ def test_create_log_processing_pipeline_with_id( url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines", params={"logProcessingPipelineId": pipeline_id}, json=pipeline_config, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -265,7 +265,7 @@ def test_update_log_processing_pipeline(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines/{pipeline_id}", params=None, json=pipeline_config, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -297,7 +297,7 @@ def test_update_log_processing_pipeline_with_update_mask( url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines/{pipeline_id}", params={"updateMask": update_mask}, json=pipeline_config, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -325,7 +325,7 @@ def test_update_log_processing_pipeline_with_full_name( url=f"{chronicle_client.base_url()}/{full_name}", params=None, json=pipeline_config, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -364,7 +364,7 @@ def test_delete_log_processing_pipeline(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines/{pipeline_id}", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -390,7 +390,7 @@ def test_delete_log_processing_pipeline_with_etag( url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines/{pipeline_id}", params={"etag": etag}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -427,7 +427,7 @@ def test_associate_streams(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines/{pipeline_id}:associateStreams", params=None, json={"streams": streams}, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -463,7 +463,7 @@ def test_associate_streams_empty_list(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines/{pipeline_id}:associateStreams", params=None, json={"streams": []}, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -485,7 +485,7 @@ def test_dissociate_streams(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines/{pipeline_id}:dissociateStreams", params=None, json={"streams": streams}, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -521,7 +521,7 @@ def test_fetch_associated_pipeline_with_log_type( url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines:fetchAssociatedPipeline", params={"stream.logType": "WINEVTLOG"}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -543,7 +543,7 @@ def test_fetch_associated_pipeline_with_feed_id( url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines:fetchAssociatedPipeline", params={"stream.feedId": "feed_123"}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -597,7 +597,7 @@ def test_fetch_sample_logs_by_streams(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines:fetchSampleLogsByStreams", params=None, json={"streams": streams}, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -623,7 +623,7 @@ def test_fetch_sample_logs_by_streams_with_count( url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines:fetchSampleLogsByStreams", params=None, json={"streams": streams, "sampleLogsCount": sample_logs_count}, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -661,7 +661,7 @@ def test_fetch_sample_logs_by_streams_empty_streams( url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/logProcessingPipelines:fetchSampleLogsByStreams", params=None, json={"streams": []}, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -694,7 +694,7 @@ def test_test_pipeline(chronicle_client, mock_response): "logProcessingPipeline": pipeline_config, "inputLogs": input_logs, }, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -740,7 +740,7 @@ def test_test_pipeline_empty_logs(chronicle_client, mock_response): "logProcessingPipeline": pipeline_config, "inputLogs": [], }, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value diff --git a/tests/chronicle/test_parser.py b/tests/chronicle/test_parser.py index 7051b00..a74e32f 100644 --- a/tests/chronicle/test_parser.py +++ b/tests/chronicle/test_parser.py @@ -15,7 +15,7 @@ """Tests for Chronicle parser functions.""" import base64 -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import pytest @@ -91,7 +91,7 @@ def test_activate_parser_success(chronicle_client, mock_response): url=expected_url, params=None, json={}, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -132,7 +132,7 @@ def test_activate_release_candidate_parser_success( url=expected_url, params=None, json={}, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -179,7 +179,7 @@ def test_fetch_parser_candidates_success(chronicle_client, mock_response): url=expected_url, params={"parserAction": parser_action}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == expected_parsers @@ -207,7 +207,7 @@ def test_fetch_parser_candidates_empty(chronicle_client, mock_response): url=expected_url, params={"parserAction": parser_action}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == [] @@ -249,7 +249,7 @@ def test_fetch_parser_candidates_with_enum(chronicle_client, mock_response): url=expected_url, params={"parserAction": parser_action}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == expected_parsers @@ -287,7 +287,7 @@ def test_copy_parser_success(chronicle_client, mock_response): url=expected_url, params=None, json={}, - headers=None, + headers=ANY, timeout=None, ) assert result == expected_parser @@ -336,7 +336,7 @@ def test_create_parser_success_default_validation( ), "validated_on_empty_logs": True, }, - headers=None, + headers=ANY, timeout=None, ) assert result == expected_parser_info @@ -376,7 +376,7 @@ def test_create_parser_success_with_validation_false( ), "validated_on_empty_logs": False, }, - headers=None, + headers=ANY, timeout=None, ) assert result == expected_parser_info @@ -413,7 +413,7 @@ def test_deactivate_parser_success(chronicle_client, mock_response): url=expected_url, params=None, json={}, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -450,7 +450,7 @@ def test_delete_parser_success_no_force(chronicle_client, mock_response): url=expected_url, params={"force": False}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -475,7 +475,7 @@ def test_delete_parser_success_with_force(chronicle_client, mock_response): url=expected_url, params={"force": True}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -516,7 +516,7 @@ def test_get_parser_success(chronicle_client, mock_response): url=expected_url, params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == expected_parser @@ -556,7 +556,7 @@ def test_list_parsers_single_page_success(chronicle_client, mock_response): url=expected_url, params={"pageSize": 1000}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == expected_parsers @@ -580,7 +580,7 @@ def test_list_parsers_no_parsers_success(chronicle_client, mock_response): url=expected_url, params={"pageSize": 1000}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == [] @@ -638,7 +638,7 @@ def test_list_parsers_with_page_size_returns_raw_response( "filter": filter_query, }, json=None, - headers=None, + headers=ANY, timeout=None, ) # With page_size provided, returns raw response dict @@ -746,7 +746,7 @@ def test_list_parsers_manual_pagination_single_page( url=expected_url, params={"pageSize": page_size}, json=None, - headers=None, + headers=ANY, timeout=None, ) # Returns raw response dict, not just the parsers list diff --git a/tests/chronicle/test_rule.py b/tests/chronicle/test_rule.py index 5362a82..ad58cf6 100644 --- a/tests/chronicle/test_rule.py +++ b/tests/chronicle/test_rule.py @@ -15,7 +15,7 @@ """Tests for Chronicle rule functions.""" import pytest -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch from secops.chronicle.client import ChronicleClient from secops.chronicle.models import APIVersion from secops.chronicle.rule import ( @@ -102,7 +102,7 @@ def test_create_rule(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules", params=None, json={"text": "rule test {}"}, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -137,7 +137,7 @@ def test_get_rule(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules/{rule_id}", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -176,7 +176,7 @@ def test_list_rules(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules", params={"pageSize": 1000, "view": "FULL"}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -200,7 +200,7 @@ def test_list_rules_empty(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules", params={"pageSize": 1000, "view": "FULL"}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == {"rules": []} @@ -284,7 +284,7 @@ def test_update_rule(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules/{rule_id}", params={"update_mask": "text"}, json={"text": rule_text}, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -324,7 +324,7 @@ def test_delete_rule(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules/{rule_id}", params={}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -363,7 +363,7 @@ def test_delete_rule_force(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules/{rule_id}", params={"force": "true"}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == {} @@ -386,7 +386,7 @@ def test_enable_rule(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules/{rule_id}/deployment", params={"update_mask": "enabled"}, json={"enabled": True}, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -409,7 +409,7 @@ def test_disable_rule(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules/{rule_id}/deployment", params={"update_mask": "enabled"}, json={"enabled": False}, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -449,7 +449,7 @@ def test_search_rules(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules", params={"pageSize": 1000, "view": "FULL"}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -509,7 +509,7 @@ def test_run_rule_test(chronicle_client, mock_streaming_response): "maxResults": 100, "scope": "", }, - headers=None, + headers=ANY, timeout=300, ) @@ -615,7 +615,7 @@ def test_get_rule_deployment(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules/{rule_id}/deployment", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == mock_response.json.return_value @@ -653,7 +653,7 @@ def test_list_rule_deployments_single_page(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules/-/deployments", params={"pageSize": 1000}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == { @@ -732,7 +732,7 @@ def test_list_rule_deployments_empty(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules/-/deployments", params={"pageSize": 1000}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == {"ruleDeployments": []} @@ -763,7 +763,7 @@ def test_list_rule_deployments_with_filter(chronicle_client, mock_response): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/rules/-/deployments", params={"pageSize": 1000, "filter": filter_query}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == { diff --git a/tests/chronicle/test_rule_deployment.py b/tests/chronicle/test_rule_deployment.py index d274c16..79fcebe 100644 --- a/tests/chronicle/test_rule_deployment.py +++ b/tests/chronicle/test_rule_deployment.py @@ -15,7 +15,7 @@ """Unit tests for rule deployment updates.""" import pytest -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch from secops.chronicle.rule import ( update_rule_deployment, @@ -64,7 +64,7 @@ def test_update_rule_deployment_enabled(chronicle_client, response_mock): url=_deployment_url(chronicle_client, rule_id), params={"update_mask": "enabled"}, json={"enabled": True}, - headers=None, + headers=ANY, timeout=None, ) assert res == {"ok": True} @@ -90,7 +90,7 @@ def test_update_rule_deployment_multiple_fields( url=_deployment_url(chronicle_client, rule_id), params={"update_mask": "enabled,alerting,runFrequency"}, json={"enabled": True, "alerting": False, "runFrequency": "LIVE"}, - headers=None, + headers=ANY, timeout=None, ) assert res == {"ok": True} @@ -108,7 +108,7 @@ def test_update_rule_deployment_archived_only(chronicle_client, response_mock): url=_deployment_url(chronicle_client, rule_id), params={"update_mask": "archived"}, json={"archived": True}, - headers=None, + headers=ANY, timeout=None, ) assert res == {"ok": True} @@ -144,7 +144,7 @@ def test_enable_rule_wrapper(chronicle_client, response_mock): url=_deployment_url(chronicle_client, rule_id), params={"update_mask": "enabled"}, json={"enabled": False}, - headers=None, + headers=ANY, timeout=None ) assert res == {"ok": True} @@ -162,7 +162,7 @@ def test_set_rule_alerting_wrapper(chronicle_client, response_mock): url=_deployment_url(chronicle_client, rule_id), params={"update_mask": "alerting"}, json={"alerting": True}, - headers=None, + headers=ANY, timeout=None, ) assert res == {"ok": True} diff --git a/tests/chronicle/test_rule_detection.py b/tests/chronicle/test_rule_detection.py index 79b1c57..ad26ac3 100644 --- a/tests/chronicle/test_rule_detection.py +++ b/tests/chronicle/test_rule_detection.py @@ -16,7 +16,7 @@ import pytest from datetime import datetime -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch from secops.chronicle import rule_detection from secops.chronicle.client import ChronicleClient @@ -90,7 +90,7 @@ def test_list_detections_minimal(chronicle_client, response_mock): "listBasis": "LIST_BASIS_UNSPECIFIED", }, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == response_mock.json() @@ -127,7 +127,7 @@ def test_list_detections_all_params(chronicle_client, response_mock): "pageToken": "next-1", }, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == response_mock.json() @@ -180,7 +180,7 @@ def test_list_errors_minimal(chronicle_client, response_mock): url=f"{chronicle_client.base_url()}/{chronicle_client.instance_id}/ruleExecutionErrors", params={"filter": expected_filter}, json=None, - headers=None, + headers=ANY, timeout=None, ) assert result == response_mock.json() diff --git a/tests/chronicle/test_rule_exclusion.py b/tests/chronicle/test_rule_exclusion.py index 62620f5..0060b86 100644 --- a/tests/chronicle/test_rule_exclusion.py +++ b/tests/chronicle/test_rule_exclusion.py @@ -16,7 +16,7 @@ import pytest from datetime import datetime -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch from secops.chronicle import rule_exclusion from secops.chronicle.client import ChronicleClient @@ -60,7 +60,7 @@ def test_list_rule_exclusions(chronicle_client, response_mock): url=f"{chronicle_client.base_url}/{chronicle_client.instance_id}/findingsRefinements", params={"pageSize": 50, "pageToken": "test-token"}, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -91,7 +91,7 @@ def test_get_rule_exclusion_with_id(chronicle_client, response_mock): url=f"{chronicle_client.base_url}/{chronicle_client.instance_id}/findingsRefinements/exclusion-id", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -114,7 +114,7 @@ def test_get_rule_exclusion_with_full_resource_name( url=f"{chronicle_client.base_url}/{full_name}", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -156,7 +156,7 @@ def test_create_rule_exclusion(chronicle_client, response_mock): url=f"{chronicle_client.base_url}/{chronicle_client.instance_id}/findingsRefinements", params=None, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -198,7 +198,7 @@ def test_create_rule_exclusion_with_display_name( url=f"{chronicle_client.base_url}/{chronicle_client.instance_id}/findingsRefinements", params=None, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -235,7 +235,7 @@ def test_create_rule_exclusion_with_complex_query( url=f"{chronicle_client.base_url}/{chronicle_client.instance_id}/findingsRefinements", params=None, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -285,7 +285,7 @@ def test_patch_rule_exclusion(chronicle_client, response_mock): url=f"{chronicle_client.base_url}/{chronicle_client.instance_id}/findingsRefinements/exclusion-id", params={"updateMask": "display_name,type,query"}, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -313,7 +313,7 @@ def test_patch_rule_exclusion_with_partial_update( url=f"{chronicle_client.base_url}/{chronicle_client.instance_id}/findingsRefinements/exclusion-id", params={"updateMask": "display_name"}, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -344,7 +344,7 @@ def test_patch_rule_exclusion_with_full_resource_name( url=f"{chronicle_client.base_url}/{full_name}", params={"updateMask": "query"}, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -371,7 +371,7 @@ def test_patch_rule_exclusion_with_no_update_mask( url=f"{chronicle_client.base_url}/{chronicle_client.instance_id}/findingsRefinements/exclusion-id", params={}, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -424,7 +424,7 @@ def test_compute_rule_exclusion_activity_specific( ":computeFindingsRefinementActivity", params=None, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -448,7 +448,7 @@ def test_get_rule_exclusion_deployment(chronicle_client, response_mock): "findingsRefinements/exclusion-id/deployment", params=None, json=None, - headers=None, + headers=ANY, timeout=None, ) @@ -490,7 +490,7 @@ def test_update_rule_exclusion_deployment(chronicle_client, response_mock): "updateMask": "enabled,archived,detection_exclusion_application" }, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -524,7 +524,7 @@ def test_update_rule_exclusion_deployment_disable( url=f"{chronicle_client.base_url}/{chronicle_client.instance_id}/findingsRefinements/exclusion-id/deployment", params={"updateMask": "enabled"}, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -557,7 +557,7 @@ def test_update_rule_exclusion_deployment_with_archived( url=f"{chronicle_client.base_url}/{chronicle_client.instance_id}/findingsRefinements/exclusion-id/deployment", params={"updateMask": "archived"}, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) @@ -597,7 +597,7 @@ def test_update_rule_exclusion_deployment_with_full_resource_name( url=f"{chronicle_client.base_url}/{full_name}/deployment", params={"updateMask": "enabled"}, json=expected_body, - headers=None, + headers=ANY, timeout=None, ) diff --git a/tests/chronicle/utils/test_request_utils.py b/tests/chronicle/utils/test_request_utils.py index cbf1ac3..47fcac7 100644 --- a/tests/chronicle/utils/test_request_utils.py +++ b/tests/chronicle/utils/test_request_utils.py @@ -16,7 +16,7 @@ from __future__ import annotations from typing import Any -from unittest.mock import Mock +from unittest.mock import ANY, Mock import pytest import requests @@ -25,9 +25,10 @@ from secops.chronicle.models import APIVersion from secops.chronicle.utils.request_utils import ( DEFAULT_PAGE_SIZE, + _build_api_client_header, + chronicle_paginated_request, chronicle_request, chronicle_request_bytes, - chronicle_paginated_request, ) from secops.exceptions import APIError @@ -87,7 +88,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, + headers={"x-goog-api-client": ANY}, timeout=None, ) @@ -683,7 +684,7 @@ def test_chronicle_request_bytes_success_returns_content_and_stream_true(client: method="GET", url="https://example.test/chronicle/instances/instance-1/integrations/foo:export", params={"alt": "media"}, - headers={"Accept": "application/zip"}, + headers={"x-goog-api-client": ANY, "Accept": "application/zip"}, timeout=None, stream=True, ) @@ -833,4 +834,88 @@ def test_chronicle_request_bytes_non_json_error_body_is_truncated(client: Mock) msg = str(exc_info.value) assert "status=500" in msg - assert "truncated" in msg \ No newline at end of file + assert "truncated" in msg + + +# --------------------------------------------------------------------------- +# x-goog-api-client header injection tests +# --------------------------------------------------------------------------- + + +def test_chronicle_request_injects_api_client_header(client: Mock) -> None: + response = _mock_response(status_code=200, json_value={"ok": True}) + client.session.request.return_value = response + + chronicle_request( + client=client, + method="GET", + endpoint_path="rules/rule123:copy", + api_version=APIVersion.V1, + ) + + _, kwargs = client.session.request.call_args + header = kwargs["headers"]["x-goog-api-client"] + assert "gl-python/" in header + assert f"rest/requests@{requests.__version__}" in header + assert "secops-wrapper/" in header + assert "api/rules/rule123:copy" in header + + +def test_chronicle_request_caller_headers_merged(client: Mock) -> None: + response = _mock_response(status_code=200, json_value={"ok": True}) + client.session.request.return_value = response + + chronicle_request( + client=client, + method="GET", + endpoint_path="rules", + api_version=APIVersion.V1, + headers={"X-Custom": "value"}, + ) + + _, kwargs = client.session.request.call_args + assert "x-goog-api-client" in kwargs["headers"] + assert kwargs["headers"]["X-Custom"] == "value" + + +def test_chronicle_request_bytes_injects_api_client_header(client: Mock) -> None: + resp = _mock_response(status_code=200) + resp.content = b"bytes" + client.session.request.return_value = resp + + chronicle_request_bytes( + client=client, + method="GET", + endpoint_path=":exportSomething", + api_version=APIVersion.V1, + ) + + _, kwargs = client.session.request.call_args + header = kwargs["headers"]["x-goog-api-client"] + assert "gl-python/" in header + assert "secops-wrapper/" in header + assert "api/exportSomething" in header + + +@pytest.mark.parametrize( + "endpoint_path, expected_api_token", + [ + (":udmSearch", "api/udmSearch"), + (":validateQuery", "api/validateQuery"), + ("rules", "api/rules"), + ("rules/rule123", "api/rules/rule123"), + ("rules/rule123:copy", "api/rules/rule123:copy"), + ("cases:merge", "api/cases:merge"), + ("legacy:legacyListCases", "api/legacy:legacyListCases"), + ("logTypes/SYSLOG/parsers/pid:activate", "api/logTypes/SYSLOG/parsers/pid:activate"), + ], +) +def test_build_api_client_header_endpoint_token( + endpoint_path: str, expected_api_token: str +) -> None: + header = _build_api_client_header(endpoint_path) + parts = header.split(" ") + assert parts[0].startswith("gl-python/") + assert parts[1].startswith("rest/requests@") + assert parts[2].startswith("secops-wrapper/") + assert parts[3] == expected_api_token \ No newline at end of file