Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
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.44.0] - 2026-04-29
### Added
- Automatic `x-goog-api-client` header on all API requests for client telemetry and tracing
- Support for pre-scoped credentials (e.g. `google.oauth2.credentials.Credentials`) in `SecOpsAuth`
- Credentials without a `with_scopes` method are now accepted directly, enabling bearer token authentication

## [0.43.0] - 2026-04-22
### Changed
- Standardised case management return types to return `dict` instead of typed objects, consistent with the rest of the SDK
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "secops"
version = "0.43.0"
version = "0.44.0"
description = "Python SDK for wrapping the Google SecOps API for common use cases"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
4 changes: 3 additions & 1 deletion src/secops/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 42 additions & 3 deletions src/secops/chronicle/utils/request_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -251,7 +286,7 @@ def chronicle_request(
url=url,
params=params,
json=json,
headers=headers,
headers=merged_headers,
timeout=timeout,
)
except GoogleAuthError as exc:
Expand Down Expand Up @@ -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,
)
Expand Down
14 changes: 7 additions & 7 deletions tests/chronicle/test_dashboard_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -102,7 +102,7 @@ def test_execute_query_success(
url=url,
params=None,
json=payload,
headers=None,
headers=ANY,
timeout=None,
)

Expand Down Expand Up @@ -138,7 +138,7 @@ def test_execute_query_with_filters(
url=url,
params=None,
json=payload,
headers=None,
headers=ANY,
timeout=None,
)

Expand Down Expand Up @@ -172,7 +172,7 @@ def test_execute_query_with_clear_cache(
url=url,
params=None,
json=payload,
headers=None,
headers=ANY,
timeout=None,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -254,7 +254,7 @@ def test_get_execute_query_success(
url=url,
params=None,
json=None,
headers=None,
headers=ANY,
timeout=None,
)

Expand Down Expand Up @@ -291,7 +291,7 @@ def test_get_execute_query_with_full_id(
url=url,
params=None,
json=None,
headers=None,
headers=ANY,
timeout=None,
)

Expand Down
31 changes: 16 additions & 15 deletions tests/chronicle/test_data_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest
from unittest.mock import (
ANY,
Mock,
patch,
call,
Expand Down Expand Up @@ -89,7 +90,7 @@ def test_create_data_table_success(
}
],
},
headers=None,
headers=ANY,
timeout=None,
)

Expand Down Expand Up @@ -195,7 +196,7 @@ def test_create_data_table_with_entity_mapping(
}
],
},
headers=None,
headers=ANY,
timeout=None,
)

Expand Down Expand Up @@ -271,7 +272,7 @@ def test_create_data_table_with_column_options(
},
],
},
headers=None,
headers=ANY,
timeout=None,
)

Expand All @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -666,7 +667,7 @@ def test_update_reference_list_success(
{"value": "new_entryY"},
],
},
headers=None,
headers=ANY,
timeout=None,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down
Loading