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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/TYPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion src/threatzone/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
YaraRulesResponse,
)

__version__ = "1.1.0"
__version__ = "1.1.1"

__all__ = [
# Clients
Expand Down
13 changes: 3 additions & 10 deletions src/threatzone/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from ._streaming import AsyncDownloadResponse
from .types import (
ArtifactsResponse,
BehaviourOs,
BehavioursResponse,
CdrResponse,
Connection,
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/threatzone/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 3 additions & 10 deletions src/threatzone/_sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from ._streaming import DownloadResponse
from .types import (
ArtifactsResponse,
BehaviourOs,
BehavioursResponse,
CdrResponse,
Connection,
Expand Down Expand Up @@ -822,7 +821,6 @@ def get_behaviours(
self,
uuid: str,
*,
os: BehaviourOs,
type: str | None = None,
pid: int | None = None,
operation: str | None = None,
Expand All @@ -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.
Expand All @@ -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:
Expand Down
27 changes: 20 additions & 7 deletions src/threatzone/testing/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
20 changes: 2 additions & 18 deletions src/threatzone/testing/fake_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
)
Expand Down
8 changes: 5 additions & 3 deletions src/threatzone/types/behaviours.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
4 changes: 3 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,9 @@ def sample_behaviours_response(
return {
"items": [sample_behaviour_event],
"total": 1,
"os": "windows",
"page": 1,
"limit": 100,
"totalPages": 1,
}


Expand Down
17 changes: 17 additions & 0 deletions tests/integration/flows/test_flow_dynamic_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from threatzone.testing import FakeThreatZoneAPI, scenarios
from threatzone.types import (
ArtifactsResponse,
BehavioursResponse,
IndicatorsResponse,
IoCsResponse,
NetworkSummary,
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 8 additions & 14 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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"

Expand All @@ -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]
Expand Down
Loading
Loading