From aad033ceb6b0fd8f8251bd7b368876b8f68e9051 Mon Sep 17 00:00:00 2001 From: Efe Dilmac Date: Wed, 22 Jul 2026 12:24:24 +0000 Subject: [PATCH] fix(behaviour): fix for the os parameter --- CHANGELOG.md | 9 +++++++ README.md | 4 +-- docs/TYPES.md | 4 +-- pyproject.toml | 2 +- src/threatzone/__init__.py | 2 +- src/threatzone/_async_client.py | 13 +++------ src/threatzone/_constants.py | 2 +- src/threatzone/_sync_client.py | 13 +++------ src/threatzone/testing/_responses.py | 27 ++++++++++++++----- src/threatzone/testing/fake_api.py | 20 ++------------ src/threatzone/types/behaviours.py | 8 +++--- tests/conftest.py | 4 ++- .../flows/test_flow_dynamic_lifecycle.py | 17 ++++++++++++ tests/test_async_client.py | 22 ++++++--------- tests/test_sync_client.py | 21 ++++++--------- tests/test_types.py | 4 ++- uv.lock | 2 +- 17 files changed, 89 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0288b4f..35a8328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to the Threat.Zone Python SDK are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.1.1 + +### Fixed + +- `get_behaviours` no longer requires or sends the obsolete `os` query parameter. +- `BehavioursResponse` now matches the Public API pagination envelope with `page`, + `limit`, and `total_pages` instead of the obsolete `os` field. +- The SDK version and `User-Agent` header are now `1.1.1`. + ## 1.1.0 API-sync release: brings the SDK into exact agreement with the current diff --git a/README.md b/README.md index 71e53aa..1128d73 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,8 @@ submission completes. `submission.is_complete()` / `submission.has_errors()`. Every report carries its own `status`, `level`, `score`. - **Observation streams** — the dynamic report produces `indicators` (rule hits), - `iocs` (concrete IOC values), `behaviours` (OS-level operations — the `os` query param - is required), and `syscalls` (raw log lines). + `iocs` (concrete IOC values), `behaviours` (OS-level operations), and `syscalls` + (raw log lines). - **Artifacts** — files produced or extracted during analysis; keyed by `hashes.sha256`. - **Access control** — public submissions are visible to every API key; private submissions are workspace-scoped. Cross-workspace reads of private submissions raise diff --git a/docs/TYPES.md b/docs/TYPES.md index 9d53348..cfb8ab8 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -104,8 +104,8 @@ report. Every endpoint is guarded by `ReportStatusGuard('dynamic')`; read the co | `ProcessTreeNode` | Recursive spawn-tree node. Does **not** carry network/events — only `pid`, `ppid`, `tid`, `name`, `cmd`, `children`. | `GET /submissions/:uuid/processes/tree` | | `ProcessTreeResponse` | `{nodes: List[ProcessTreeNode]}`. | same | | `BehaviourEvent` | OS-specific behaviour event with `type`, `pid`, `ppid`, `process_name`, `operation`, `event_id`, `event_count`, `syscall_line_number`, `timestamp`, `details`. | `GET /submissions/:uuid/behaviours` | -| `BehavioursResponse` | `{items, total, os}`. | same | -| `BehaviourOs` | `Literal["windows", "linux", "android", "macos"]`. **Required** query parameter. | same | +| `BehavioursResponse` | `{items, total, page, limit, total_pages}`. | same | +| `BehaviourOs` | Deprecated compatibility alias; the endpoint derives the OS from the submission. | same | | `SyscallsResponse` | `{items: List[str], total}`. `items` are raw unparsed log lines. | `GET /submissions/:uuid/syscalls` | | `NetworkSummary` | `{dns_count, http_count, tcp_count, udp_count, threat_count, pcap_available}`. | `GET /submissions/:uuid/network/summary` | | `DnsQuery` | `{id, host, type, status, records, timeshift}`. | `GET /submissions/:uuid/network/dns` | diff --git a/pyproject.toml b/pyproject.toml index 0d2526e..dbbaf7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "threatzone" -version = "1.1.0" +version = "1.1.1" description = "Python SDK for the Threat.Zone malware analysis platform" readme = "README.md" license = { text = "MIT" } diff --git a/src/threatzone/__init__.py b/src/threatzone/__init__.py index b0d7762..f6dce49 100644 --- a/src/threatzone/__init__.py +++ b/src/threatzone/__init__.py @@ -89,7 +89,7 @@ YaraRulesResponse, ) -__version__ = "1.1.0" +__version__ = "1.1.1" __all__ = [ # Clients diff --git a/src/threatzone/_async_client.py b/src/threatzone/_async_client.py index 1edf617..b9563f1 100644 --- a/src/threatzone/_async_client.py +++ b/src/threatzone/_async_client.py @@ -17,7 +17,6 @@ from ._streaming import AsyncDownloadResponse from .types import ( ArtifactsResponse, - BehaviourOs, BehavioursResponse, CdrResponse, Connection, @@ -825,7 +824,6 @@ async def get_behaviours( self, uuid: str, *, - os: BehaviourOs, type: str | None = None, pid: int | None = None, operation: str | None = None, @@ -834,11 +832,10 @@ async def get_behaviours( limit: int | None = None, ) -> BehavioursResponse: """ - Get OS-specific behaviour events captured during dynamic analysis. + Get behaviour events captured during dynamic analysis. Args: uuid: Submission UUID. - os: REQUIRED. Target operating system (windows/linux/android/macos). type: Filter by event type (e.g. registry, file, network, process, mutex). pid: Filter by process ID. operation: Filter by operation name. @@ -847,16 +844,12 @@ async def get_behaviours( limit: Maximum items per page (1-500). Returns: - Paginated behaviour events tagged with their source OS. + Paginated behaviour events. Raises: - ValueError: When ``os`` is None or empty. ReportUnavailableError: When the dynamic report is not yet available. """ - if not os: - raise ValueError("get_behaviours() requires the 'os' keyword argument") - - params: dict[str, Any] = {"os": os} + params: dict[str, Any] = {} if type is not None: params["type"] = type if pid is not None: diff --git a/src/threatzone/_constants.py b/src/threatzone/_constants.py index 028651b..3291514 100644 --- a/src/threatzone/_constants.py +++ b/src/threatzone/_constants.py @@ -11,6 +11,6 @@ API_KEY_ENV_VAR: Final[str] = "THREATZONE_API_KEY" API_KEY_HEADER: Final[str] = "Authorization" -USER_AGENT: Final[str] = "threatzone-python-sdk/1.1.0" +USER_AGENT: Final[str] = "threatzone-python-sdk/1.1.1" CHUNK_SIZE: Final[int] = 8192 diff --git a/src/threatzone/_sync_client.py b/src/threatzone/_sync_client.py index ad73604..a31d0e3 100644 --- a/src/threatzone/_sync_client.py +++ b/src/threatzone/_sync_client.py @@ -16,7 +16,6 @@ from ._streaming import DownloadResponse from .types import ( ArtifactsResponse, - BehaviourOs, BehavioursResponse, CdrResponse, Connection, @@ -822,7 +821,6 @@ def get_behaviours( self, uuid: str, *, - os: BehaviourOs, type: str | None = None, pid: int | None = None, operation: str | None = None, @@ -831,11 +829,10 @@ def get_behaviours( limit: int | None = None, ) -> BehavioursResponse: """ - Get OS-specific behaviour events captured during dynamic analysis. + Get behaviour events captured during dynamic analysis. Args: uuid: Submission UUID. - os: REQUIRED. Target operating system (windows/linux/android/macos). type: Filter by event type (e.g. registry, file, network, process, mutex). pid: Filter by process ID. operation: Filter by operation name. @@ -844,16 +841,12 @@ def get_behaviours( limit: Maximum items per page (1-500). Returns: - Paginated behaviour events tagged with their source OS. + Paginated behaviour events. Raises: - ValueError: When ``os`` is None or empty. ReportUnavailableError: When the dynamic report is not yet available. """ - if not os: - raise ValueError("get_behaviours() requires the 'os' keyword argument") - - params: dict[str, Any] = {"os": os} + params: dict[str, Any] = {} if type is not None: params["type"] = type if pid is not None: diff --git a/src/threatzone/testing/_responses.py b/src/threatzone/testing/_responses.py index 403a36e..c71b9e1 100644 --- a/src/threatzone/testing/_responses.py +++ b/src/threatzone/testing/_responses.py @@ -47,7 +47,6 @@ UserInfo, YaraRulesResponse, ) -from threatzone.types.behaviours import BehaviourOs from threatzone.types.indicators import OverviewSummary from threatzone.types.network import ThreatAppProto, ThreatSeverity @@ -517,13 +516,14 @@ def build_process_tree_response(state: SubmissionState) -> ProcessTreeResponse: def build_behaviours_response( state: SubmissionState, *, - os_name: BehaviourOs, + event_type: str | None, pid: int | None, operation: str | None, + process_name: str | None, page: int | None, limit: int | None, ) -> BehavioursResponse: - """Build the OS-specific behaviour events response.""" + """Build the paginated behaviour events response.""" events: list[dict[str, Any]] = [] if state.has_dynamic_report: events.append( @@ -544,11 +544,24 @@ def build_behaviours_response( events = [e for e in events if e["pid"] == pid] if operation: events = [e for e in events if e["operation"] == operation] + if event_type: + events = [e for e in events if e["type"] == event_type] + if process_name: + events = [e for e in events if e["processName"] == process_name] total = len(events) - if page is not None and limit is not None: - start = (page - 1) * limit - events = events[start : start + limit] - return BehavioursResponse.model_validate({"items": events, "total": total, "os": os_name}) + page_number = page if page is not None else 1 + page_limit = limit if limit is not None else 100 + start = (page_number - 1) * page_limit + events = events[start : start + page_limit] + return BehavioursResponse.model_validate( + { + "items": events, + "total": total, + "page": page_number, + "limit": page_limit, + "totalPages": (total + page_limit - 1) // page_limit, + } + ) def build_syscalls_response( diff --git a/src/threatzone/testing/fake_api.py b/src/threatzone/testing/fake_api.py index 3d832f7..450fdaf 100644 --- a/src/threatzone/testing/fake_api.py +++ b/src/threatzone/testing/fake_api.py @@ -983,23 +983,6 @@ def _handle_get_behaviours(self, request: httpx.Request, match: RouteMatch) -> h return guard try: query = self._query(request) - os_value = self._first(query, "os") - if not os_value: - return _error( - 400, - "Bad Request", - "Missing required query parameter: os", - "INVALID_QUERY_PARAM", - {"param": "os"}, - ) - if os_value not in {"windows", "linux", "android", "macos"}: - return _error( - 400, - "Bad Request", - f"Invalid os value: {os_value}", - "INVALID_QUERY_PARAM", - {"param": "os"}, - ) page = self._int(query, "page") limit = self._int(query, "limit") pid = self._int(query, "pid") @@ -1022,9 +1005,10 @@ def _handle_get_behaviours(self, request: httpx.Request, match: RouteMatch) -> h return _json( _responses.build_behaviours_response( state, - os_name=os_value, # type: ignore[arg-type] + event_type=self._first(query, "type"), pid=pid, operation=self._first(query, "operation"), + process_name=self._first(query, "processName"), page=page, limit=limit, ) diff --git a/src/threatzone/types/behaviours.py b/src/threatzone/types/behaviours.py index ab14c78..c5a6e2e 100644 --- a/src/threatzone/types/behaviours.py +++ b/src/threatzone/types/behaviours.py @@ -26,15 +26,17 @@ class BehaviourEvent(BaseModel): event_count: int = Field(alias="eventCount") syscall_line_number: int | None = Field(default=None, alias="syscallLineNumber") timestamp: int - # OS-specific structured payload. Shape depends on `os` and event `type`. + # OS-specific structured payload. Shape depends on the submission OS and event type. details: dict[str, Any] class BehavioursResponse(BaseModel): - """Paginated behaviour event list, OS-tagged.""" + """Paginated behaviour event list.""" model_config = ConfigDict(populate_by_name=True) items: list[BehaviourEvent] total: int - os: BehaviourOs + page: int + limit: int + total_pages: int = Field(alias="totalPages") diff --git a/tests/conftest.py b/tests/conftest.py index fa75d61..c94cf7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -680,7 +680,9 @@ def sample_behaviours_response( return { "items": [sample_behaviour_event], "total": 1, - "os": "windows", + "page": 1, + "limit": 100, + "totalPages": 1, } diff --git a/tests/integration/flows/test_flow_dynamic_lifecycle.py b/tests/integration/flows/test_flow_dynamic_lifecycle.py index 928d410..7c65cc0 100644 --- a/tests/integration/flows/test_flow_dynamic_lifecycle.py +++ b/tests/integration/flows/test_flow_dynamic_lifecycle.py @@ -6,6 +6,7 @@ from threatzone.testing import FakeThreatZoneAPI, scenarios from threatzone.types import ( ArtifactsResponse, + BehavioursResponse, IndicatorsResponse, IoCsResponse, NetworkSummary, @@ -37,6 +38,22 @@ def test_processes_and_tree(fake_api: FakeThreatZoneAPI, sync_client: ThreatZone assert isinstance(tree, ProcessTreeResponse) +def test_behaviours_without_os_parameter( + fake_api: FakeThreatZoneAPI, sync_client: ThreatZone +) -> None: + uuid = _submit(fake_api, sync_client) + behaviours = sync_client.get_behaviours( + uuid, + type="file", + process_name="malware.exe", + ) + assert isinstance(behaviours, BehavioursResponse) + assert behaviours.total == 1 + assert behaviours.page == 1 + assert behaviours.limit == 100 + assert behaviours.total_pages == 1 + + def test_indicators_iocs_yara_artifacts( fake_api: FakeThreatZoneAPI, sync_client: ThreatZone ) -> None: diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 338c32c..7231fd2 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -1109,11 +1109,11 @@ async def test_get_behaviours_happy( ctx, mock = _patched_async_client(_make_response(200, sample_behaviours_response)) with ctx: async with AsyncThreatZone(api_key=api_key) as client: - behaviours = await client.get_behaviours("sub-789", os="windows") + behaviours = await client.get_behaviours("sub-789") assert isinstance(behaviours, BehavioursResponse) assert _call_url(mock).endswith("/submissions/sub-789/behaviours") params = _call_params(mock) - assert params["os"] == "windows" + assert "os" not in params async def test_get_behaviours_with_filters( self, api_key: str, sample_behaviours_response: dict[str, Any] @@ -1123,14 +1123,13 @@ async def test_get_behaviours_with_filters( async with AsyncThreatZone(api_key=api_key) as client: await client.get_behaviours( "sub-789", - os="linux", pid=1234, operation="create", page=2, limit=50, ) params = _call_params(mock) - assert params["os"] == "linux" + assert "os" not in params assert params["pid"] == 1234 assert params["operation"] == "create" assert params["page"] == 2 @@ -1144,12 +1143,11 @@ async def test_get_behaviours_sends_type_and_process_name( async with AsyncThreatZone(api_key=api_key) as client: await client.get_behaviours( "sub-789", - os="windows", type="registry", process_name="cmd.exe", ) params = _call_params(mock) - assert params["os"] == "windows" + assert "os" not in params assert params["type"] == "registry" assert params["processName"] == "cmd.exe" @@ -1159,29 +1157,25 @@ async def test_get_behaviours_omits_type_and_process_name_when_none( ctx, mock = _patched_async_client(_make_response(200, sample_behaviours_response)) with ctx: async with AsyncThreatZone(api_key=api_key) as client: - await client.get_behaviours("sub-789", os="windows") + await client.get_behaviours("sub-789") params = _call_params(mock) + assert "os" not in params assert "type" not in params assert "processName" not in params - async def test_get_behaviours_without_os_raises(self, api_key: str) -> None: - async with AsyncThreatZone(api_key=api_key) as client: - with pytest.raises(ValueError, match="os"): - await client.get_behaviours("sub-789", os="") # type: ignore[arg-type] - async def test_get_behaviours_409(self, api_key: str) -> None: ctx, _ = _patched_async_client(_make_response(409, _409_body())) with ctx: async with AsyncThreatZone(api_key=api_key) as client: with pytest.raises(ReportUnavailableError): - await client.get_behaviours("sub-789", os="windows") + await client.get_behaviours("sub-789") async def test_get_behaviours_404(self, api_key: str) -> None: ctx, _ = _patched_async_client(_make_response(404, {"message": "no"})) with ctx: async with AsyncThreatZone(api_key=api_key) as client: with pytest.raises(NotFoundError): - await client.get_behaviours("ghost", os="windows") + await client.get_behaviours("ghost") async def test_get_syscalls_happy( self, api_key: str, sample_syscalls_response: dict[str, Any] diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py index c62ed4e..45a6762 100644 --- a/tests/test_sync_client.py +++ b/tests/test_sync_client.py @@ -1014,11 +1014,11 @@ def test_get_behaviours_happy( ) -> None: ctx, mock = _patched_client(_make_response(200, sample_behaviours_response)) with ctx, ThreatZone(api_key=api_key) as client: - behaviours = client.get_behaviours("sub-789", os="windows") + behaviours = client.get_behaviours("sub-789") assert isinstance(behaviours, BehavioursResponse) assert _call_url(mock).endswith("/submissions/sub-789/behaviours") params = _call_params(mock) - assert params["os"] == "windows" + assert "os" not in params def test_get_behaviours_with_filters( self, api_key: str, sample_behaviours_response: dict[str, Any] @@ -1027,14 +1027,13 @@ def test_get_behaviours_with_filters( with ctx, ThreatZone(api_key=api_key) as client: client.get_behaviours( "sub-789", - os="linux", pid=1234, operation="create", page=2, limit=50, ) params = _call_params(mock) - assert params["os"] == "linux" + assert "os" not in params assert params["pid"] == 1234 assert params["operation"] == "create" assert params["page"] == 2 @@ -1047,12 +1046,11 @@ def test_get_behaviours_sends_type_and_process_name( with ctx, ThreatZone(api_key=api_key) as client: client.get_behaviours( "sub-789", - os="windows", type="registry", process_name="cmd.exe", ) params = _call_params(mock) - assert params["os"] == "windows" + assert "os" not in params assert params["type"] == "registry" assert params["processName"] == "cmd.exe" @@ -1061,25 +1059,22 @@ def test_get_behaviours_omits_type_and_process_name_when_none( ) -> None: ctx, mock = _patched_client(_make_response(200, sample_behaviours_response)) with ctx, ThreatZone(api_key=api_key) as client: - client.get_behaviours("sub-789", os="windows") + client.get_behaviours("sub-789") params = _call_params(mock) + assert "os" not in params assert "type" not in params assert "processName" not in params - def test_get_behaviours_without_os_raises(self, api_key: str) -> None: - with ThreatZone(api_key=api_key) as client, pytest.raises(ValueError, match="os"): - client.get_behaviours("sub-789", os="") # type: ignore[arg-type] - def test_get_behaviours_409(self, api_key: str) -> None: body = _409_body() ctx, _ = _patched_client(_make_response(409, body)) with ctx, ThreatZone(api_key=api_key) as client, pytest.raises(ReportUnavailableError): - client.get_behaviours("sub-789", os="windows") + client.get_behaviours("sub-789") def test_get_behaviours_404(self, api_key: str) -> None: ctx, _ = _patched_client(_make_response(404, {"message": "no"})) with ctx, ThreatZone(api_key=api_key) as client, pytest.raises(NotFoundError): - client.get_behaviours("ghost", os="windows") + client.get_behaviours("ghost") def test_get_syscalls_happy( self, api_key: str, sample_syscalls_response: dict[str, Any] diff --git a/tests/test_types.py b/tests/test_types.py index 0f84dd1..3b8ec06 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -543,8 +543,10 @@ def test_behaviour_event(self, sample_behaviour_event: dict[str, Any]) -> None: def test_behaviours_response(self, sample_behaviours_response: dict[str, Any]) -> None: resp = BehavioursResponse.model_validate(sample_behaviours_response) - assert resp.os == "windows" assert resp.total == 1 + assert resp.page == 1 + assert resp.limit == 100 + assert resp.total_pages == 1 assert isinstance(resp.items[0], BehaviourEvent) def test_behaviour_event_round_trip(self, sample_behaviour_event: dict[str, Any]) -> None: diff --git a/uv.lock b/uv.lock index 3b2baf6..9c55919 100644 --- a/uv.lock +++ b/uv.lock @@ -607,7 +607,7 @@ wheels = [ [[package]] name = "threatzone" -version = "1.0.3" +version = "1.1.1" source = { editable = "." } dependencies = [ { name = "httpx" },