From 0c9cfee5631ca7c0c6483c65592f538ad23f71b0 Mon Sep 17 00:00:00 2001 From: batuhanisildak-malwation Date: Tue, 23 Jun 2026 11:05:31 +0000 Subject: [PATCH 1/2] feat: sync to public-api v1.1.0 (jobs reshape, score/family/duration, category list, config flags, CDR & static-scan singletons, static-scan strings, request params, network-configs) --- CHANGELOG.md | 53 +++++++ pyproject.toml | 2 +- src/threatzone/__init__.py | 21 ++- src/threatzone/_async_client.py | 63 +++++++- src/threatzone/_constants.py | 2 +- src/threatzone/_sync_client.py | 63 +++++++- src/threatzone/testing/_responses.py | 134 ++++++++++++++---- src/threatzone/testing/fake_api.py | 21 +++ src/threatzone/testing/routes.py | 5 + src/threatzone/types/__init__.py | 20 ++- src/threatzone/types/cdr.py | 38 ++--- src/threatzone/types/common.py | 1 + src/threatzone/types/config.py | 5 + src/threatzone/types/indicators.py | 4 +- src/threatzone/types/network_configs.py | 29 ++++ src/threatzone/types/static_scan.py | 67 +++++++-- src/threatzone/types/submissions.py | 7 +- tests/conftest.py | 98 ++++++++++--- .../test_recipe_03_malicious_indicators.py | 2 +- tests/test_async_client.py | 126 +++++++++++++++- tests/test_sync_client.py | 113 ++++++++++++++- tests/test_types.py | 120 +++++++++++++--- 22 files changed, 865 insertions(+), 129 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/threatzone/types/network_configs.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..db1c2a0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +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.0 + +API-sync release: brings the SDK into exact agreement with the current +`public-api` HTTP contract. Includes three breaking response-shape changes +(jobs, CDR, static-scan) alongside additive fields and endpoints. + +### Breaking + +- **Submission overview `jobs` reshape:** `SubmissionOverviewJobs` no longer + exposes `completed`. It now carries `total`, `succeeded`, `failed`, + `skipped`, and `finished`. +- **CDR response is a per-submission singleton:** `CdrResponse` was rewritten + from an items envelope to a single object + (`submission`, `status`, `sanitized`, `removed`, `sanitizedFileInfo`, + `elapsedTime`, `metafields`). The old `CdrResult` envelope is gone. +- **Static-scan response is a per-format singleton:** `StaticScanResponse` was + rewritten from an items envelope to a single object discriminated by + `reportFormat` (`submission`, `status`, `reportFormat`, `level`, `score`, + `fileInfo`, `strings`, `dieResults`, `analysisTime`, `metafields`). + Format-specific inlined keys are preserved as model extras + (`extra="allow"`). The old `StaticScanResult` envelope is gone. + +### Added + +- `Submission.score` (`float | None`) and `Submission.family` (`str | None`). +- `ReportStatus.duration` (`float | None`). +- `OverviewSummary.score` (`float | None`) and `OverviewSummary.family` + (`str | None`). +- Config `active` / `accessible` flags on metafield options, metafield option + values, and environment options. +- `get_static_scan_strings(uuid)` — streams the raw extracted-strings JSON + for a static report. +- `list_network_configs()` — lists the workspace's network configurations via + `GET /v1/network-configs`; new `NetworkConfigListItem` / + `NetworkConfigListResponse` models. +- Request parameters: `safe_browsing` on `create_url_submission`; + `sort` / `order` on `list_submissions`; `type` / `process_name` on + `get_behaviours`. + +### Changed + +- `Indicator.category` is now `list[str]` (was a scalar `str`). +- `User-Agent` header bumped to `threatzone-python-sdk/1.1.0`. +- Fixed the `configurations` docstring key on `create_sandbox_submission` / + `create_open_in_browser_submission` to `networkConfig` (the only key the + API accepts). diff --git a/pyproject.toml b/pyproject.toml index a433487..0d2526e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "threatzone" -version = "1.0.3" +version = "1.1.0" 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 11f285a..b0d7762 100644 --- a/src/threatzone/__init__.py +++ b/src/threatzone/__init__.py @@ -36,7 +36,8 @@ BehaviourOs, BehavioursResponse, CdrResponse, - CdrResult, + CdrSanitizedFileInfo, + CdrSanitizedFileInfoDetail, Connection, DnsQuery, EmlAnalysis, @@ -57,6 +58,8 @@ Metafields, MitreResponse, ModuleInfo, + NetworkConfigListItem, + NetworkConfigListResponse, NetworkSummary, NetworkThreat, OverviewSummary, @@ -68,8 +71,10 @@ ReportStatus, SignatureCheckResponse, SignatureCheckResult, + StaticScanDieResult, + StaticScanFileInfo, StaticScanResponse, - StaticScanResult, + StaticScanStrings, Submission, SubmissionCreated, SubmissionLimits, @@ -84,7 +89,7 @@ YaraRulesResponse, ) -__version__ = "1.0.3" +__version__ = "1.1.0" __all__ = [ # Clients @@ -150,6 +155,9 @@ "HttpRequest", "Connection", "NetworkThreat", + # Types - Network configs + "NetworkConfigListItem", + "NetworkConfigListResponse", # Types - Processes "Process", "ProcessesResponse", @@ -162,9 +170,12 @@ "SyscallsResponse", # Types - Static scan / CDR / Signature check "StaticScanResponse", - "StaticScanResult", + "StaticScanFileInfo", + "StaticScanStrings", + "StaticScanDieResult", "CdrResponse", - "CdrResult", + "CdrSanitizedFileInfo", + "CdrSanitizedFileInfoDetail", "SignatureCheckResponse", "SignatureCheckResult", # Types - URL analysis diff --git a/src/threatzone/_async_client.py b/src/threatzone/_async_client.py index d29f9f5..1edf617 100644 --- a/src/threatzone/_async_client.py +++ b/src/threatzone/_async_client.py @@ -34,6 +34,7 @@ MetafieldOption, Metafields, MitreResponse, + NetworkConfigListItem, NetworkSummary, NetworkThreat, OverviewSummary, @@ -178,6 +179,15 @@ async def get_environments(self) -> list[EnvironmentOption]: response = await self._http.get("/config/environments") return [EnvironmentOption.model_validate(item) for item in response.json()] + async def list_network_configs(self) -> list[NetworkConfigListItem]: + """List the workspace's available network configurations (proxy/openvpn/wireguard). + + Secrets (passwords, VPN file contents) are never returned — only `has_config_file`. + """ + response = await self._http.get("/v1/network-configs") + data = response.json() + return [NetworkConfigListItem.model_validate(item) for item in data.get("items", [])] + # ========================================================================= # Submissions - Create # ========================================================================= @@ -214,7 +224,7 @@ async def create_sandbox_submission( configurations: Advanced execution configuration options including: - preScript: Script to run before analysis - startArguments: Command line arguments for the sample - - network_config: Network configuration ID (MongoDB ObjectId) + - networkConfig: Network configuration ID (MongoDB ObjectId) Returns: SubmissionCreated with the new submission UUID. @@ -311,6 +321,7 @@ async def create_url_submission( url: str, *, private: bool = False, + safe_browsing: bool = False, ) -> SubmissionCreated: """ Create a new URL analysis submission. @@ -318,13 +329,16 @@ async def create_url_submission( Args: url: URL to analyze. private: If True, submission is private to your workspace. + safe_browsing: If True, spawn an isolated safe-browsing session + (a sandboxed Chromium pod accessible over noVNC) alongside + URL analysis. Default: False. Returns: SubmissionCreated with the new submission UUID. """ response = await self._http.post( "/submissions/url_analysis", - json={"url": url, "private": private}, + json={"url": url, "private": private, "safeBrowsing": safe_browsing}, ) return SubmissionCreated.model_validate(response.json()) @@ -392,6 +406,8 @@ async def list_submissions( end_date: datetime | str | None = None, private: bool | None = None, tags: list[str] | None = None, + sort: str | None = None, + order: str | None = None, ) -> PaginatedSubmissions: """ List submissions with optional filters. @@ -407,6 +423,8 @@ async def list_submissions( end_date: Filter submissions created before this date. private: Filter by privacy status. tags: Filter by tags. + sort: Sort field (e.g. "createdAt"). + order: Sort order ("asc" or "desc"). Returns: PaginatedSubmissions with items and pagination info. @@ -430,6 +448,10 @@ async def list_submissions( params["private"] = private if tags: params["tags"] = tags + if sort is not None: + params["sort"] = sort + if order is not None: + params["order"] = order response = await self._http.get("/submissions", params=params) return PaginatedSubmissions.model_validate(response.json()) @@ -693,13 +715,18 @@ async def get_mitre_techniques(self, uuid: str) -> MitreResponse: async def get_static_scan_results(self, uuid: str) -> StaticScanResponse: """ - Get static scan results (per artifact) for a submission. + Get static analysis results for a submission. + + The response is a per-submission static analysis report, discriminated + by ``reportFormat``. Format-specific keys (e.g. ``peExeSections``, + ``apkInfo``) are inlined at the top level and preserved on + ``.model_extra``. Args: uuid: Submission UUID. Returns: - Static scan envelope with per-artifact analyzer output. + Static analysis report singleton. Raises: ReportUnavailableError: When the static report is not yet available. @@ -707,18 +734,34 @@ async def get_static_scan_results(self, uuid: str) -> StaticScanResponse: response = await self._http.get(f"/submissions/{uuid}/static-scan") return StaticScanResponse.model_validate(response.json()) + async def get_static_scan_strings(self, uuid: str) -> AsyncDownloadResponse: + """Stream the raw extracted-strings JSON (``{uuid}_strings.json``) for a static report. + + The response is a streamed application/json file (can be multi-MB). Use + ``.read()``, ``.iter_bytes()``, or ``.save(path)``. Raises + ReportUnavailableError (409) when the static report is not complete, + NotFoundError (404) when no strings file exists. + + Args: + uuid: Submission UUID. + + Returns: + AsyncDownloadResponse for streaming or saving the strings JSON file. + """ + return await self._http.get_stream(f"/submissions/{uuid}/static-scan/strings") + async def get_cdr_results(self, uuid: str) -> CdrResponse: """ Get Content Disarm and Reconstruction (CDR) results for a submission. - This returns the structured per-artifact CDR engine output. Use + This returns the per-submission CDR transformation result. Use ``download_cdr_result()`` to download the sanitized file itself. Args: uuid: Submission UUID. Returns: - CDR results envelope. + CDR transformation result. Raises: ReportUnavailableError: When the CDR report is not yet available. @@ -783,8 +826,10 @@ async def get_behaviours( uuid: str, *, os: BehaviourOs, + type: str | None = None, pid: int | None = None, operation: str | None = None, + process_name: str | None = None, page: int | None = None, limit: int | None = None, ) -> BehavioursResponse: @@ -794,8 +839,10 @@ async def get_behaviours( 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. + process_name: Filter by process name (exact match). page: 1-indexed page number. limit: Maximum items per page (1-500). @@ -810,10 +857,14 @@ async def get_behaviours( raise ValueError("get_behaviours() requires the 'os' keyword argument") params: dict[str, Any] = {"os": os} + if type is not None: + params["type"] = type if pid is not None: params["pid"] = pid if operation is not None: params["operation"] = operation + if process_name is not None: + params["processName"] = process_name if page is not None: params["page"] = page if limit is not None: diff --git a/src/threatzone/_constants.py b/src/threatzone/_constants.py index 81215e8..028651b 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.0.0" +USER_AGENT: Final[str] = "threatzone-python-sdk/1.1.0" CHUNK_SIZE: Final[int] = 8192 diff --git a/src/threatzone/_sync_client.py b/src/threatzone/_sync_client.py index 03cedde..ad73604 100644 --- a/src/threatzone/_sync_client.py +++ b/src/threatzone/_sync_client.py @@ -33,6 +33,7 @@ MetafieldOption, Metafields, MitreResponse, + NetworkConfigListItem, NetworkSummary, NetworkThreat, OverviewSummary, @@ -175,6 +176,15 @@ def get_environments(self) -> list[EnvironmentOption]: response = self._http.get("/config/environments") return [EnvironmentOption.model_validate(item) for item in response.json()] + def list_network_configs(self) -> list[NetworkConfigListItem]: + """List the workspace's available network configurations (proxy/openvpn/wireguard). + + Secrets (passwords, VPN file contents) are never returned — only `has_config_file`. + """ + response = self._http.get("/v1/network-configs") + data = response.json() + return [NetworkConfigListItem.model_validate(item) for item in data.get("items", [])] + # ========================================================================= # Submissions - Create # ========================================================================= @@ -211,7 +221,7 @@ def create_sandbox_submission( configurations: Advanced execution configuration options including: - preScript: Script to run before analysis - startArguments: Command line arguments for the sample - - network_config: Network configuration ID (MongoDB ObjectId) + - networkConfig: Network configuration ID (MongoDB ObjectId) Returns: SubmissionCreated with the new submission UUID. @@ -308,6 +318,7 @@ def create_url_submission( url: str, *, private: bool = False, + safe_browsing: bool = False, ) -> SubmissionCreated: """ Create a new URL analysis submission. @@ -315,13 +326,16 @@ def create_url_submission( Args: url: URL to analyze. private: If True, submission is private to your workspace. + safe_browsing: If True, spawn an isolated safe-browsing session + (a sandboxed Chromium pod accessible over noVNC) alongside + URL analysis. Default: False. Returns: SubmissionCreated with the new submission UUID. """ response = self._http.post( "/submissions/url_analysis", - json={"url": url, "private": private}, + json={"url": url, "private": private, "safeBrowsing": safe_browsing}, ) return SubmissionCreated.model_validate(response.json()) @@ -389,6 +403,8 @@ def list_submissions( end_date: datetime | str | None = None, private: bool | None = None, tags: list[str] | None = None, + sort: str | None = None, + order: str | None = None, ) -> PaginatedSubmissions: """ List submissions with optional filters. @@ -404,6 +420,8 @@ def list_submissions( end_date: Filter submissions created before this date. private: Filter by privacy status. tags: Filter by tags. + sort: Sort field (e.g. "createdAt"). + order: Sort order ("asc" or "desc"). Returns: PaginatedSubmissions with items and pagination info. @@ -427,6 +445,10 @@ def list_submissions( params["private"] = private if tags: params["tags"] = tags + if sort is not None: + params["sort"] = sort + if order is not None: + params["order"] = order response = self._http.get("/submissions", params=params) return PaginatedSubmissions.model_validate(response.json()) @@ -690,13 +712,18 @@ def get_mitre_techniques(self, uuid: str) -> MitreResponse: def get_static_scan_results(self, uuid: str) -> StaticScanResponse: """ - Get static scan results (per artifact) for a submission. + Get static analysis results for a submission. + + The response is a per-submission static analysis report, discriminated + by ``reportFormat``. Format-specific keys (e.g. ``peExeSections``, + ``apkInfo``) are inlined at the top level and preserved on + ``.model_extra``. Args: uuid: Submission UUID. Returns: - Static scan envelope with per-artifact analyzer output. + Static analysis report singleton. Raises: ReportUnavailableError: When the static report is not yet available. @@ -704,18 +731,34 @@ def get_static_scan_results(self, uuid: str) -> StaticScanResponse: response = self._http.get(f"/submissions/{uuid}/static-scan") return StaticScanResponse.model_validate(response.json()) + def get_static_scan_strings(self, uuid: str) -> DownloadResponse: + """Stream the raw extracted-strings JSON (``{uuid}_strings.json``) for a static report. + + The response is a streamed application/json file (can be multi-MB). Use + ``.read()``, ``.iter_bytes()``, or ``.save(path)``. Raises + ReportUnavailableError (409) when the static report is not complete, + NotFoundError (404) when no strings file exists. + + Args: + uuid: Submission UUID. + + Returns: + DownloadResponse for streaming or saving the strings JSON file. + """ + return self._http.get_stream(f"/submissions/{uuid}/static-scan/strings") + def get_cdr_results(self, uuid: str) -> CdrResponse: """ Get Content Disarm and Reconstruction (CDR) results for a submission. - This returns the structured per-artifact CDR engine output. Use + This returns the per-submission CDR transformation result. Use ``download_cdr_result()`` to download the sanitized file itself. Args: uuid: Submission UUID. Returns: - CDR results envelope. + CDR transformation result. Raises: ReportUnavailableError: When the CDR report is not yet available. @@ -780,8 +823,10 @@ def get_behaviours( uuid: str, *, os: BehaviourOs, + type: str | None = None, pid: int | None = None, operation: str | None = None, + process_name: str | None = None, page: int | None = None, limit: int | None = None, ) -> BehavioursResponse: @@ -791,8 +836,10 @@ def get_behaviours( 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. + process_name: Filter by process name (exact match). page: 1-indexed page number. limit: Maximum items per page (1-500). @@ -807,10 +854,14 @@ def get_behaviours( raise ValueError("get_behaviours() requires the 'os' keyword argument") params: dict[str, Any] = {"os": os} + if type is not None: + params["type"] = type if pid is not None: params["pid"] = pid if operation is not None: params["operation"] = operation + if process_name is not None: + params["processName"] = process_name if page is not None: params["page"] = page if limit is not None: diff --git a/src/threatzone/testing/_responses.py b/src/threatzone/testing/_responses.py index 609ead8..403a36e 100644 --- a/src/threatzone/testing/_responses.py +++ b/src/threatzone/testing/_responses.py @@ -31,6 +31,7 @@ MetafieldOption, Metafields, MitreResponse, + NetworkConfigListResponse, NetworkSummary, NetworkThreat, PaginatedSubmissions, @@ -108,11 +109,17 @@ def _indicator_rollup(state: SubmissionState) -> dict[str, Any]: def _submission_overview(state: SubmissionState) -> dict[str, Any]: status = state.current_status() report_types = state.report_types() - completed = len(report_types) if status == "completed" else 0 total = max(len(report_types), 1) + finished = total if status == "completed" else 0 return { "status": status, - "jobs": {"completed": completed, "total": total}, + "jobs": { + "total": total, + "succeeded": finished, + "failed": 0, + "skipped": 0, + "finished": finished, + }, } @@ -251,7 +258,7 @@ def build_indicators_response( "id": f"ind-{idx}", "name": f"Indicator {seed.attack_code}", "description": f"Triggered MITRE technique {seed.attack_code}", - "category": "default", + "category": ["default"], "level": seed.level, "score": 80 if seed.level == "malicious" else 50, "pids": list(seed.pids), @@ -388,33 +395,63 @@ def build_mitre_response(state: SubmissionState) -> MitreResponse: def build_static_scan_response(state: SubmissionState) -> StaticScanResponse: - """Build the static scan results envelope.""" - artifact = state.artifact_ids[0] if state.artifact_ids else "artifact-0" - items: list[dict[str, Any]] = [ + """Build the per-format static analysis report singleton.""" + return StaticScanResponse.model_validate( { - "artifact": artifact, + "submission": state.uuid, "status": "completed", - "data": {"verdict": state.level}, - "lastErrorMessage": None, - "engineVersion": "1.0.0", + "reportFormat": "exe", + "level": state.level, + "score": 92.0, + "fileInfo": { + "md5": "0" * 32, + "sha1": "0" * 40, + "sha256": state.sha256, + "ssdeep": "3:aaaa:aaaa", + "mime_type": "application/x-dosexec", + "file_type": "PE32", + "entropy": 7.1, + "filesize": "1.00 MB", + }, + "strings": {"totalCount": 1234}, + "dieResults": [{"name": "PE", "string": "PE32", "type": "exe", "version": None}], + "analysisTime": "00:00:02", + "metafields": None, + "imports": [{"library": "kernel32.dll", "functions": ["VirtualAlloc"]}], + "peExeSections": [{"name": ".text", "entropy": 6.5}], } - ] - return StaticScanResponse.model_validate({"items": items, "total": len(items)}) + ) + + +def build_static_scan_strings(state: SubmissionState) -> dict[str, Any]: + """Build the canned extracted-strings JSON payload streamed by the fake.""" + return { + "submission": state.uuid, + "totalCount": 2, + "strings": ["VirtualAlloc", "kernel32.dll"], + } def build_cdr_response(state: SubmissionState) -> CdrResponse: - """Build the CDR results envelope.""" - artifact = state.artifact_ids[0] if state.artifact_ids else "artifact-0" - items: list[dict[str, Any]] = [ + """Build the per-submission CDR transformation result.""" + return CdrResponse.model_validate( { - "artifact": artifact, + "submission": state.uuid, "status": "completed", - "data": {"sanitized": True}, - "lastErrorMessage": None, - "engineVersion": "1.0.0", + "sanitized": ["doc.pdf"], + "removed": ["macro1"], + "sanitizedFileInfo": { + "description": "Sanitized", + "fileType": "PDF", + "fileSize": 1024, + "sha256": "ab" * 32, + "name": "doc.pdf", + "details": [{"name": "macro", "action": "removed", "data": None}], + }, + "elapsedTime": "00:00:43", + "metafields": None, } - ] - return CdrResponse.model_validate({"items": items, "total": len(items)}) + ) def build_signature_check_response(state: SubmissionState) -> SignatureCheckResponse: @@ -778,6 +815,8 @@ def build_metafields() -> Metafields: "description": "Analysis timeout in seconds", "type": "number", "default": 120, + "active": True, + "accessible": True, "options": None, }, { @@ -786,6 +825,8 @@ def build_metafields() -> Metafields: "description": "Allow internet connection", "type": "boolean", "default": True, + "active": True, + "accessible": True, "options": None, }, ] @@ -796,6 +837,8 @@ def build_metafields() -> Metafields: "description": "Enable deep static scan", "type": "boolean", "default": False, + "active": True, + "accessible": True, "options": None, } ] @@ -826,8 +869,51 @@ def build_metafields_for(scan_type: str) -> list[MetafieldOption]: def build_environments() -> list[EnvironmentOption]: """Build the available sandbox environments.""" items: list[dict[str, Any]] = [ - {"key": "w10_64", "name": "Windows 10 x64", "platform": "windows", "default": True}, - {"key": "w11_64", "name": "Windows 11 x64", "platform": "windows", "default": False}, - {"key": "ub22", "name": "Ubuntu 22.04", "platform": "linux", "default": False}, + { + "key": "w10_64", + "name": "Windows 10 x64", + "platform": "windows", + "default": True, + "active": True, + "accessible": True, + }, + { + "key": "w11_64", + "name": "Windows 11 x64", + "platform": "windows", + "default": False, + "active": True, + "accessible": True, + }, + { + "key": "ub22", + "name": "Ubuntu 22.04", + "platform": "linux", + "default": False, + "active": True, + "accessible": True, + }, ] return [EnvironmentOption.model_validate(item) for item in items] + + +def build_network_configs() -> NetworkConfigListResponse: + """Build the workspace's available network configurations.""" + return NetworkConfigListResponse.model_validate( + { + "items": [ + { + "id": "cfg1", + "name": "Corp Proxy", + "type": "proxy", + "protocol": "socks5", + "host": "1.2.3.4", + "port": 1080, + "username": "u", + "hasConfigFile": False, + "createdAt": "2026-06-01T00:00:00.000Z", + "updatedAt": "2026-06-01T00:00:00.000Z", + } + ] + } + ) diff --git a/src/threatzone/testing/fake_api.py b/src/threatzone/testing/fake_api.py index afc8247..3d832f7 100644 --- a/src/threatzone/testing/fake_api.py +++ b/src/threatzone/testing/fake_api.py @@ -427,6 +427,7 @@ def _handlers(self) -> dict[str, Callable[[httpx.Request, RouteMatch], httpx.Res "get_metafields_all": self._handle_metafields_all, "get_metafields_by_type": self._handle_metafields_by_type, "get_environments": self._handle_environments, + "list_network_configs": self._handle_list_network_configs, "list_submissions": self._handle_list_submissions, "get_submission": self._handle_get_submission, "search_by_sha256": self._handle_search_by_sha256, @@ -444,6 +445,7 @@ def _handlers(self) -> dict[str, Callable[[httpx.Request, RouteMatch], httpx.Res "get_eml_analysis": self._handle_get_eml, "get_mitre": self._handle_get_mitre, "get_static_scan": self._handle_get_static_scan, + "get_static_scan_strings": self._handle_get_static_scan_strings, "get_cdr": self._handle_get_cdr_report, "get_signature_check": self._handle_get_signature_check, "get_processes": self._handle_get_processes, @@ -607,6 +609,12 @@ def _handle_environments(self, request: httpx.Request, match: RouteMatch) -> htt del request, match return _json_list(list(_responses.build_environments())) + def _handle_list_network_configs( + self, request: httpx.Request, match: RouteMatch + ) -> httpx.Response: + del request, match + return _json(_responses.build_network_configs()) + def _handle_list_submissions(self, request: httpx.Request, match: RouteMatch) -> httpx.Response: del match try: @@ -908,6 +916,19 @@ def _handle_get_static_scan(self, _request: httpx.Request, match: RouteMatch) -> return guard return _json(_responses.build_static_scan_response(state)) + def _handle_get_static_scan_strings( + self, _request: httpx.Request, match: RouteMatch + ) -> httpx.Response: + state, err = self._require_state(match.params["uuid"]) + if err is not None or state is None: + assert err is not None + return err + guard = self._require_static(state) + if guard is not None: + return guard + payload = _responses.build_static_scan_strings(state) + return _binary(json.dumps(payload).encode("utf-8"), "application/json") + def _handle_get_cdr_report(self, _request: httpx.Request, match: RouteMatch) -> httpx.Response: state, err = self._require_state(match.params["uuid"]) if err is not None or state is None: diff --git a/src/threatzone/testing/routes.py b/src/threatzone/testing/routes.py index a3f9da2..1fff742 100644 --- a/src/threatzone/testing/routes.py +++ b/src/threatzone/testing/routes.py @@ -31,6 +31,7 @@ class RouteMatch: re.compile(r"^/config/metafields/(?P[A-Za-z_]+)$"), ), ("get_environments", re.compile(r"^/config/environments$")), + ("list_network_configs", re.compile(r"^/v1/network-configs$")), ("list_submissions", re.compile(r"^/submissions$")), ( "search_by_sha256", @@ -47,6 +48,10 @@ class RouteMatch: ("get_artifacts", re.compile(rf"^/submissions/(?P{_UUID})/artifacts$")), ("get_eml_analysis", re.compile(rf"^/submissions/(?P{_UUID})/eml-analysis$")), ("get_mitre", re.compile(rf"^/submissions/(?P{_UUID})/mitre$")), + ( + "get_static_scan_strings", + re.compile(rf"^/submissions/(?P{_UUID})/static-scan/strings$"), + ), ("get_static_scan", re.compile(rf"^/submissions/(?P{_UUID})/static-scan$")), ("get_cdr", re.compile(rf"^/submissions/(?P{_UUID})/cdr$")), ( diff --git a/src/threatzone/types/__init__.py b/src/threatzone/types/__init__.py index ab76faa..f5f1d77 100644 --- a/src/threatzone/types/__init__.py +++ b/src/threatzone/types/__init__.py @@ -1,7 +1,7 @@ """Type definitions for the Threat.Zone Python SDK.""" from .behaviours import BehaviourEvent, BehaviourOs, BehavioursResponse -from .cdr import CdrResponse, CdrResult +from .cdr import CdrResponse, CdrSanitizedFileInfo, CdrSanitizedFileInfoDetail from .common import ( FileEntrypoint, FileInfo, @@ -77,6 +77,7 @@ ThreatProtocol, ThreatSeverity, ) +from .network_configs import NetworkConfigListItem, NetworkConfigListResponse from .processes import ( Process, ProcessesResponse, @@ -88,7 +89,12 @@ ProcessTreeResponse, ) from .signature_check import SignatureCheckResponse, SignatureCheckResult -from .static_scan import StaticScanResponse, StaticScanResult +from .static_scan import ( + StaticScanDieResult, + StaticScanFileInfo, + StaticScanResponse, + StaticScanStrings, +) from .submissions import ( PaginatedSubmissions, Submission, @@ -192,6 +198,9 @@ "ThreatAppProto", "ThreatProtocol", "ThreatSeverity", + # Network configs + "NetworkConfigListItem", + "NetworkConfigListResponse", # Processes "Process", "ProcessesResponse", @@ -209,10 +218,13 @@ "SyscallsResponse", # Static scan "StaticScanResponse", - "StaticScanResult", + "StaticScanFileInfo", + "StaticScanStrings", + "StaticScanDieResult", # CDR "CdrResponse", - "CdrResult", + "CdrSanitizedFileInfo", + "CdrSanitizedFileInfoDetail", # Signature check "SignatureCheckResponse", "SignatureCheckResult", diff --git a/src/threatzone/types/cdr.py b/src/threatzone/types/cdr.py index 54b7381..5c23b69 100644 --- a/src/threatzone/types/cdr.py +++ b/src/threatzone/types/cdr.py @@ -5,30 +5,34 @@ from __future__ import annotations -from typing import Any - from pydantic import BaseModel, ConfigDict, Field -from .common import ReportStatusValue +class CdrSanitizedFileInfoDetail(BaseModel): + model_config = ConfigDict(populate_by_name=True) + name: str | None = None + action: str | None = None + data: dict[str, object] | None = None -class CdrResult(BaseModel): - """A single CDR transformation result for an artifact.""" +class CdrSanitizedFileInfo(BaseModel): model_config = ConfigDict(populate_by_name=True) - - artifact: str - status: ReportStatusValue - # Free-form: shape depends on the CDR engine. - data: dict[str, Any] | None = None - last_error_message: str | None = Field(default=None, alias="lastErrorMessage") - engine_version: str | None = Field(default=None, alias="engineVersion") + description: str | None = None + file_type: str | None = Field(default=None, alias="fileType") + file_size: int | None = Field(default=None, alias="fileSize") + sha256: str | None = None + name: str | None = None + details: list[CdrSanitizedFileInfoDetail] = Field(default_factory=list) class CdrResponse(BaseModel): - """CDR transformation results envelope.""" - model_config = ConfigDict(populate_by_name=True) - - items: list[CdrResult] - total: int + submission: str + status: str + sanitized: list[str] = Field(default_factory=list) + removed: list[str] = Field(default_factory=list) + sanitized_file_info: CdrSanitizedFileInfo | None = Field( + default=None, alias="sanitizedFileInfo" + ) + elapsed_time: str | None = Field(default=None, alias="elapsedTime") + metafields: dict[str, object] | None = None diff --git a/src/threatzone/types/common.py b/src/threatzone/types/common.py index 0eff20b..8361836 100644 --- a/src/threatzone/types/common.py +++ b/src/threatzone/types/common.py @@ -82,5 +82,6 @@ class ReportStatus(BaseModel): status: ReportStatusValue level: ThreatLevel | None = None score: int | None = None + duration: float | None = None format: str | None = None operating_system: ReportOperatingSystem | None = Field(default=None, alias="operatingSystem") diff --git a/src/threatzone/types/config.py b/src/threatzone/types/config.py index 39cd4de..6894ff4 100644 --- a/src/threatzone/types/config.py +++ b/src/threatzone/types/config.py @@ -14,6 +14,7 @@ class MetafieldOptionValue(BaseModel): value: bool | int | str label: str + accessible: bool class MetafieldOption(BaseModel): @@ -26,6 +27,8 @@ class MetafieldOption(BaseModel): description: str type: Literal["boolean", "number", "string", "select"] default: bool | int | str + active: bool + accessible: bool options: list[MetafieldOptionValue] | None = None @@ -50,3 +53,5 @@ class EnvironmentOption(BaseModel): name: str platform: Literal["windows", "linux", "macos", "android"] default: bool + active: bool + accessible: bool diff --git a/src/threatzone/types/indicators.py b/src/threatzone/types/indicators.py index 26791ec..f9e322e 100644 --- a/src/threatzone/types/indicators.py +++ b/src/threatzone/types/indicators.py @@ -33,7 +33,7 @@ class Indicator(BaseModel): id: str name: str description: str - category: str + category: list[str] level: IndicatorLevel score: int pids: list[int] @@ -217,6 +217,8 @@ class OverviewSummary(BaseModel): model_config = ConfigDict(populate_by_name=True) + score: float | None = None + family: str | None = None indicators: SummaryIndicators behavior_event_count: int = Field(alias="behaviorEventCount") syscall_count: int = Field(alias="syscallCount") diff --git a/src/threatzone/types/network_configs.py b/src/threatzone/types/network_configs.py new file mode 100644 index 0000000..e8b95b9 --- /dev/null +++ b/src/threatzone/types/network_configs.py @@ -0,0 +1,29 @@ +"""Network configuration type definitions. + +Mirrors the `network-configs.response.dto.ts` DTO in the Threat.Zone Public API. +Secrets (passwords, VPN config file contents) are never part of this contract — +only `has_config_file` indicates whether a file has been uploaded. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class NetworkConfigListItem(BaseModel): + model_config = ConfigDict(populate_by_name=True) + id: str + name: str + type: str + protocol: str | None = None + host: str | None = None + port: int | None = None + username: str | None = None + has_config_file: bool = Field(alias="hasConfigFile") + created_at: str = Field(alias="createdAt") + updated_at: str = Field(alias="updatedAt") + + +class NetworkConfigListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + items: list[NetworkConfigListItem] = Field(default_factory=list) diff --git a/src/threatzone/types/static_scan.py b/src/threatzone/types/static_scan.py index da5ee20..df4793a 100644 --- a/src/threatzone/types/static_scan.py +++ b/src/threatzone/types/static_scan.py @@ -1,34 +1,71 @@ """Static scan type definitions. Mirrors the `static-scan.response.dto.ts` DTO in the Threat.Zone Public API. + +The `/static-scan` response is a per-format discriminated singleton (not an +items envelope). Common fields are always present; format-specific keys are +inlined at the top level and selected by ``reportFormat``, so the response +model preserves unknown keys (``extra="allow"``) — access them via +``.model_extra``. """ from __future__ import annotations -from typing import Any - from pydantic import BaseModel, ConfigDict, Field -from .common import ReportStatusValue +class StaticScanFileInfo(BaseModel): + """File identity and hash information for a static scan report.""" + + model_config = ConfigDict(populate_by_name=True) -class StaticScanResult(BaseModel): - """A single static scan result for an artifact.""" + md5: str | None = None + sha1: str | None = None + sha256: str | None = None + ssdeep: str | None = None + mime_type: str | None = None + file_type: str | None = None + entropy: float | None = None + filesize: str | None = None + + +class StaticScanStrings(BaseModel): + """Extracted strings summary for a static scan report.""" model_config = ConfigDict(populate_by_name=True) - artifact: str - status: ReportStatusValue - # Free-form: shape depends on the analyzer engine. - data: dict[str, Any] | None = None - last_error_message: str | None = Field(default=None, alias="lastErrorMessage") - engine_version: str | None = Field(default=None, alias="engineVersion") + total_count: int = Field(alias="totalCount") -class StaticScanResponse(BaseModel): - """Static scan results envelope.""" +class StaticScanDieResult(BaseModel): + """A single DetectItEasy detection entry.""" model_config = ConfigDict(populate_by_name=True) - items: list[StaticScanResult] - total: int + name: str | None = None + string: str | None = None + type: str | None = None + version: str | None = None + + +class StaticScanResponse(BaseModel): + """Per-submission static analysis report, discriminated by ``reportFormat``. + + ``extra="allow"`` preserves the inlined per-format keys (``peExeSections``, + ``apkInfo``, ``elfHeader``, ...) which vary by ``reportFormat``; access them + via ``.model_extra``. This mirrors the live DTO's ``additionalProperties:true`` + behavior for the format-specific sections. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + submission: str + status: str + report_format: str = Field(alias="reportFormat") + level: str + score: float + file_info: StaticScanFileInfo | None = Field(default=None, alias="fileInfo") + strings: StaticScanStrings | None = None + die_results: list[StaticScanDieResult] = Field(default_factory=list, alias="dieResults") + analysis_time: str | None = Field(default=None, alias="analysisTime") + metafields: dict[str, object] | None = None diff --git a/src/threatzone/types/submissions.py b/src/threatzone/types/submissions.py index 15e83c2..79ed80b 100644 --- a/src/threatzone/types/submissions.py +++ b/src/threatzone/types/submissions.py @@ -29,8 +29,11 @@ class SubmissionOverviewJobs(BaseModel): model_config = ConfigDict(populate_by_name=True) - completed: int total: int + succeeded: int + failed: int + skipped: int + finished: int class SubmissionOverview(BaseModel): @@ -105,6 +108,8 @@ class Submission(BaseModel): hashes: Hashes | None = None file: FileInfo | None = None level: SubmissionLevel + score: float | None = None + family: str | None = None private: bool tags: list[Tag] reports: list[ReportStatus] diff --git a/tests/conftest.py b/tests/conftest.py index 4275f42..fa75d61 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -117,22 +117,44 @@ def sample_environments() -> list[dict[str, Any]]: "name": "Windows 10 64-bit", "platform": "windows", "default": True, + "active": True, + "accessible": True, }, { "key": "w11_x64", "name": "Windows 11 64-bit", "platform": "windows", "default": False, + "active": True, + "accessible": True, }, { "key": "ubuntu_22", "name": "Ubuntu 22.04", "platform": "linux", "default": False, + "active": True, + "accessible": True, }, ] +@pytest.fixture +def sample_network_config() -> dict[str, Any]: + return { + "id": "cfg1", + "name": "Corp Proxy", + "type": "proxy", + "protocol": "socks5", + "host": "1.2.3.4", + "port": 1080, + "username": "u", + "hasConfigFile": False, + "createdAt": "2026-06-01T00:00:00.000Z", + "updatedAt": "2026-06-01T00:00:00.000Z", + } + + @pytest.fixture def sample_metafields() -> dict[str, list[dict[str, Any]]]: return { @@ -141,8 +163,15 @@ def sample_metafields() -> dict[str, list[dict[str, Any]]]: "key": "timeout", "label": "Timeout", "description": "Analysis timeout in seconds", - "type": "number", + "type": "select", "default": 120, + "active": True, + "accessible": True, + "options": [ + {"value": 60, "label": "60 Seconds", "accessible": True}, + {"value": 120, "label": "120 Seconds", "accessible": True}, + {"value": 300, "label": "300 Seconds", "accessible": False}, + ], } ], "static": [], @@ -155,6 +184,8 @@ def sample_metafields() -> dict[str, list[dict[str, Any]]]: "description": "Browser execution timeout in seconds", "type": "number", "default": 120, + "active": True, + "accessible": True, } ], } @@ -182,6 +213,7 @@ def sample_report_status_completed() -> dict[str, Any]: "level": "malicious", "score": 95, "format": "v2", + "duration": 0.2, "operatingSystem": {"name": "Windows 10", "platform": "windows"}, } @@ -193,6 +225,7 @@ def sample_report_status_in_progress() -> dict[str, Any]: "status": "in_progress", "level": None, "score": None, + "duration": None, } @@ -200,7 +233,7 @@ def sample_report_status_in_progress() -> dict[str, Any]: def sample_submission_overview() -> dict[str, Any]: return { "status": "completed", - "jobs": {"completed": 3, "total": 3}, + "jobs": {"total": 3, "succeeded": 2, "failed": 0, "skipped": 1, "finished": 3}, } @@ -280,6 +313,8 @@ def sample_submission( "source": {"type": "upload", "url": None}, }, "level": "malicious", + "score": 78.5, + "family": "Sheetrat", "private": False, "tags": [{"type": "malware", "value": "trojan"}], "reports": [sample_report_status_completed, static_completed], @@ -307,7 +342,7 @@ def sample_indicator() -> dict[str, Any]: "id": "ind-1", "name": "Creates persistence", "description": "Creates registry key for persistence", - "category": "persistence", + "category": ["evasion", "persistence"], "level": "malicious", "score": 80, "pids": [1234, 5678], @@ -420,6 +455,8 @@ def all_artifact_types() -> list[str]: @pytest.fixture def sample_overview_summary() -> dict[str, Any]: return { + "score": 84.0, + "family": "Sheetrat", "indicators": { "total": 10, "levels": {"malicious": 5, "suspicious": 3, "benign": 2}, @@ -663,32 +700,47 @@ def sample_syscalls_response() -> dict[str, Any]: @pytest.fixture def sample_cdr_response() -> dict[str, Any]: return { - "items": [ - { - "artifact": "art-1", - "status": "completed", - "data": {"reason": "macro stripped"}, - "lastErrorMessage": None, - "engineVersion": "1.2.3", - } - ], - "total": 1, + "submission": "uuid-1", + "status": "completed", + "sanitized": ["doc.pdf"], + "removed": ["macro1"], + "sanitizedFileInfo": { + "description": "Sanitized", + "fileType": "PDF", + "fileSize": 1024, + "sha256": "ab" * 32, + "name": "doc.pdf", + "details": [{"name": "macro", "action": "removed", "data": None}], + }, + "elapsedTime": "00:00:43", + "metafields": None, } @pytest.fixture def sample_static_scan_response() -> dict[str, Any]: return { - "items": [ - { - "artifact": "art-1", - "status": "completed", - "data": {"sections": ["text", "data"]}, - "lastErrorMessage": None, - "engineVersion": "9.9.9", - } - ], - "total": 1, + "submission": "uuid-1", + "status": "completed", + "reportFormat": "exe", + "level": "malicious", + "score": 92.0, + "fileInfo": { + "md5": "a" * 32, + "sha1": "b" * 40, + "sha256": "c" * 64, + "ssdeep": "x", + "mime_type": "application/x-dosexec", + "file_type": "PE32", + "entropy": 7.1, + "filesize": "1.00 MB", + }, + "strings": {"totalCount": 1234}, + "dieResults": [{"name": "PE", "string": "PE32", "type": "exe", "version": None}], + "analysisTime": "00:00:02", + "metafields": None, + "imports": [{"library": "kernel32.dll", "functions": ["VirtualAlloc"]}], + "peExeSections": [{"name": ".text", "entropy": 6.5}], } diff --git a/tests/integration/recipes/test_recipe_03_malicious_indicators.py b/tests/integration/recipes/test_recipe_03_malicious_indicators.py index 26af34d..8a6b3b3 100644 --- a/tests/integration/recipes/test_recipe_03_malicious_indicators.py +++ b/tests/integration/recipes/test_recipe_03_malicious_indicators.py @@ -52,7 +52,7 @@ def test_filter_by_category(fake_api: FakeThreatZoneAPI, sync_client: ThreatZone response = sync_client.get_indicators(created.uuid, category="default") assert response.total >= 1 for indicator in response.items: - assert indicator.category == "default" + assert "default" in indicator.category def test_indicators_response_envelope_shape( diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 33e3fa5..338c32c 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -42,6 +42,7 @@ MetafieldOption, Metafields, MitreResponse, + NetworkConfigListItem, NetworkSummary, NetworkThreat, OverviewSummary, @@ -326,6 +327,26 @@ async def test_get_metafields_all( assert isinstance(meta, Metafields) assert _call_url(mock).endswith("/config/metafields") + async def test_list_network_configs_happy( + self, api_key: str, sample_network_config: dict[str, Any] + ) -> None: + ctx, mock = _patched_async_client(_make_response(200, {"items": [sample_network_config]})) + with ctx: + async with AsyncThreatZone(api_key=api_key) as client: + configs = await client.list_network_configs() + assert all(isinstance(c, NetworkConfigListItem) for c in configs) + assert configs[0].id == "cfg1" + assert configs[0].has_config_file is False + assert _call_url(mock).endswith("/v1/network-configs") + + async def test_list_network_configs_empty(self, api_key: str) -> None: + ctx, mock = _patched_async_client(_make_response(200, {"items": []})) + with ctx: + async with AsyncThreatZone(api_key=api_key) as client: + configs = await client.list_network_configs() + assert configs == [] + assert _call_url(mock).endswith("/v1/network-configs") + async def test_get_metafields_specific( self, api_key: str, sample_metafields: dict[str, list[dict[str, Any]]] ) -> None: @@ -415,7 +436,11 @@ async def test_create_url_submission( assert isinstance(result, SubmissionCreated) assert _call_url(mock).endswith("/submissions/url_analysis") _, kwargs = mock.call_args - assert kwargs["json"] == {"url": "https://example.com", "private": False} + assert kwargs["json"] == { + "url": "https://example.com", + "private": False, + "safeBrowsing": False, + } async def test_create_url_submission_400(self, api_key: str) -> None: ctx, _ = _patched_async_client(_make_response(400, {"message": "bad"})) @@ -424,6 +449,32 @@ async def test_create_url_submission_400(self, api_key: str) -> None: with pytest.raises(BadRequestError): await client.create_url_submission("not-a-url") + async def test_create_url_submission_sends_safe_browsing( + self, api_key: str, sample_submission_created: dict[str, Any] + ) -> None: + ctx, mock = _patched_async_client(_make_response(200, sample_submission_created)) + with ctx: + async with AsyncThreatZone(api_key=api_key) as client: + await client.create_url_submission( + "https://example.com", private=True, safe_browsing=True + ) + _, kwargs = mock.call_args + assert kwargs["json"] == { + "url": "https://example.com", + "private": True, + "safeBrowsing": True, + } + + async def test_create_url_submission_safe_browsing_defaults_false( + self, api_key: str, sample_submission_created: dict[str, Any] + ) -> None: + ctx, mock = _patched_async_client(_make_response(200, sample_submission_created)) + with ctx: + async with AsyncThreatZone(api_key=api_key) as client: + await client.create_url_submission("https://example.com") + _, kwargs = mock.call_args + assert kwargs["json"]["safeBrowsing"] is False + async def test_create_open_in_browser_submission( self, api_key: str, sample_submission_created: dict[str, Any] ) -> None: @@ -561,6 +612,32 @@ async def test_list_submissions_with_datetime( assert params["startDate"].startswith("2024-01-01T12:00:00") assert params["endDate"].startswith("2024-01-31T12:00:00") + async def test_list_submissions_sends_sort_order( + self, + api_key: str, + sample_paginated_submissions: dict[str, Any], + ) -> None: + ctx, mock = _patched_async_client(_make_response(200, sample_paginated_submissions)) + with ctx: + async with AsyncThreatZone(api_key=api_key) as client: + await client.list_submissions(sort="createdAt", order="asc") + params = _call_params(mock) + assert params["sort"] == "createdAt" + assert params["order"] == "asc" + + async def test_list_submissions_omits_sort_order_when_none( + self, + api_key: str, + sample_paginated_submissions: dict[str, Any], + ) -> None: + ctx, mock = _patched_async_client(_make_response(200, sample_paginated_submissions)) + with ctx: + async with AsyncThreatZone(api_key=api_key) as client: + await client.list_submissions() + params = _call_params(mock) + assert "sort" not in params + assert "order" not in params + async def test_list_submissions_401(self, api_key: str) -> None: ctx, _ = _patched_async_client(_make_response(401, {"message": "auth"})) with ctx: @@ -896,6 +973,7 @@ async def test_get_static_scan_results_happy( async with AsyncThreatZone(api_key=api_key) as client: res = await client.get_static_scan_results("sub-789") assert isinstance(res, StaticScanResponse) + assert res.report_format == "exe" assert _call_url(mock).endswith("/submissions/sub-789/static-scan") async def test_get_static_scan_results_409(self, api_key: str) -> None: @@ -1058,6 +1136,34 @@ async def test_get_behaviours_with_filters( assert params["page"] == 2 assert params["limit"] == 50 + async def test_get_behaviours_sends_type_and_process_name( + self, api_key: str, sample_behaviours_response: dict[str, Any] + ) -> 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", + type="registry", + process_name="cmd.exe", + ) + params = _call_params(mock) + assert params["os"] == "windows" + assert params["type"] == "registry" + assert params["processName"] == "cmd.exe" + + async def test_get_behaviours_omits_type_and_process_name_when_none( + self, api_key: str, sample_behaviours_response: dict[str, Any] + ) -> 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") + params = _call_params(mock) + 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"): @@ -1432,6 +1538,24 @@ async def test_download_cdr_result_404(self, api_key: str) -> None: with pytest.raises(NotFoundError): await client.download_cdr_result("ghost") + async def test_get_static_scan_strings_happy(self, api_key: str) -> None: + response = _make_response(200, content=b'{"strings": ["a", "b"]}') + ctx, mock = _patched_async_stream(response) + with ctx: + async with AsyncThreatZone(api_key=api_key) as client: + res = await client.get_static_scan_strings("sub-789") + assert await res.read() == b'{"strings": ["a", "b"]}' + url_arg = mock.call_args.args[1] + assert url_arg.endswith("/submissions/sub-789/static-scan/strings") + + async def test_get_static_scan_strings_404(self, api_key: str) -> None: + response = _make_response(404, {"message": "no"}) + ctx, _ = _patched_async_stream(response) + with ctx: + async with AsyncThreatZone(api_key=api_key) as client: + with pytest.raises(NotFoundError): + await client.get_static_scan_strings("ghost") + class TestAsyncMedia: async def test_get_screenshot_happy(self, api_key: str) -> None: diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py index b7a0d83..c62ed4e 100644 --- a/tests/test_sync_client.py +++ b/tests/test_sync_client.py @@ -47,6 +47,7 @@ MetafieldOption, Metafields, MitreResponse, + NetworkConfigListItem, NetworkSummary, NetworkThreat, OverviewSummary, @@ -317,6 +318,24 @@ def test_get_metafields_all( assert isinstance(meta, Metafields) assert _call_url(mock).endswith("/config/metafields") + def test_list_network_configs_happy( + self, api_key: str, sample_network_config: dict[str, Any] + ) -> None: + ctx, mock = _patched_client(_make_response(200, {"items": [sample_network_config]})) + with ctx, ThreatZone(api_key=api_key) as client: + configs = client.list_network_configs() + assert all(isinstance(c, NetworkConfigListItem) for c in configs) + assert configs[0].id == "cfg1" + assert configs[0].has_config_file is False + assert _call_url(mock).endswith("/v1/network-configs") + + def test_list_network_configs_empty(self, api_key: str) -> None: + ctx, mock = _patched_client(_make_response(200, {"items": []})) + with ctx, ThreatZone(api_key=api_key) as client: + configs = client.list_network_configs() + assert configs == [] + assert _call_url(mock).endswith("/v1/network-configs") + def test_get_metafields_specific( self, api_key: str, sample_metafields: dict[str, list[dict[str, Any]]] ) -> None: @@ -396,13 +415,39 @@ def test_create_url_submission( assert isinstance(result, SubmissionCreated) assert _call_url(mock).endswith("/submissions/url_analysis") _, kwargs = mock.call_args - assert kwargs["json"] == {"url": "https://example.com", "private": False} + assert kwargs["json"] == { + "url": "https://example.com", + "private": False, + "safeBrowsing": False, + } def test_create_url_submission_400(self, api_key: str) -> None: ctx, _ = _patched_client(_make_response(400, {"message": "bad url"})) with ctx, ThreatZone(api_key=api_key) as client, pytest.raises(BadRequestError): client.create_url_submission("not-a-url") + def test_create_url_submission_sends_safe_browsing( + self, api_key: str, sample_submission_created: dict[str, Any] + ) -> None: + ctx, mock = _patched_client(_make_response(200, sample_submission_created)) + with ctx, ThreatZone(api_key=api_key) as client: + client.create_url_submission("https://example.com", private=True, safe_browsing=True) + _, kwargs = mock.call_args + assert kwargs["json"] == { + "url": "https://example.com", + "private": True, + "safeBrowsing": True, + } + + def test_create_url_submission_safe_browsing_defaults_false( + self, api_key: str, sample_submission_created: dict[str, Any] + ) -> None: + ctx, mock = _patched_client(_make_response(200, sample_submission_created)) + with ctx, ThreatZone(api_key=api_key) as client: + client.create_url_submission("https://example.com") + _, kwargs = mock.call_args + assert kwargs["json"]["safeBrowsing"] is False + def test_create_open_in_browser_submission( self, api_key: str, sample_submission_created: dict[str, Any] ) -> None: @@ -537,6 +582,30 @@ def test_list_submissions_with_datetime( assert params["startDate"].startswith("2024-01-01T12:00:00") assert params["endDate"].startswith("2024-01-31T12:00:00") + def test_list_submissions_sends_sort_order( + self, + api_key: str, + sample_paginated_submissions: dict[str, Any], + ) -> None: + ctx, mock = _patched_client(_make_response(200, sample_paginated_submissions)) + with ctx, ThreatZone(api_key=api_key) as client: + client.list_submissions(sort="createdAt", order="asc") + params = _call_params(mock) + assert params["sort"] == "createdAt" + assert params["order"] == "asc" + + def test_list_submissions_omits_sort_order_when_none( + self, + api_key: str, + sample_paginated_submissions: dict[str, Any], + ) -> None: + ctx, mock = _patched_client(_make_response(200, sample_paginated_submissions)) + with ctx, ThreatZone(api_key=api_key) as client: + client.list_submissions() + params = _call_params(mock) + assert "sort" not in params + assert "order" not in params + def test_list_submissions_401(self, api_key: str) -> None: ctx, _ = _patched_client(_make_response(401, {"message": "auth"})) with ctx, ThreatZone(api_key=api_key) as client, pytest.raises(AuthenticationError): @@ -823,6 +892,7 @@ def test_get_static_scan_results_happy( with ctx, ThreatZone(api_key=api_key) as client: res = client.get_static_scan_results("sub-789") assert isinstance(res, StaticScanResponse) + assert res.report_format == "exe" assert _call_url(mock).endswith("/submissions/sub-789/static-scan") def test_get_static_scan_results_409(self, api_key: str) -> None: @@ -970,6 +1040,32 @@ def test_get_behaviours_with_filters( assert params["page"] == 2 assert params["limit"] == 50 + def test_get_behaviours_sends_type_and_process_name( + self, api_key: str, sample_behaviours_response: dict[str, Any] + ) -> 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", + type="registry", + process_name="cmd.exe", + ) + params = _call_params(mock) + assert params["os"] == "windows" + assert params["type"] == "registry" + assert params["processName"] == "cmd.exe" + + def test_get_behaviours_omits_type_and_process_name_when_none( + self, api_key: str, sample_behaviours_response: dict[str, Any] + ) -> 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") + params = _call_params(mock) + 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] @@ -1306,6 +1402,21 @@ def test_download_cdr_result_404(self, api_key: str) -> None: with ctx, ThreatZone(api_key=api_key) as client, pytest.raises(NotFoundError): client.download_cdr_result("ghost") + def test_get_static_scan_strings_happy(self, api_key: str) -> None: + response = _make_response(200, content=b'{"strings": ["a", "b"]}') + ctx, mock = _patched_stream(response) + with ctx, ThreatZone(api_key=api_key) as client: + res = client.get_static_scan_strings("sub-789") + assert res.read() == b'{"strings": ["a", "b"]}' + url_arg = mock.call_args.args[1] + assert url_arg.endswith("/submissions/sub-789/static-scan/strings") + + def test_get_static_scan_strings_404(self, api_key: str) -> None: + response = _make_response(404, {"message": "no"}) + ctx, _ = _patched_stream(response) + with ctx, ThreatZone(api_key=api_key) as client, pytest.raises(NotFoundError): + client.get_static_scan_strings("ghost") + class TestMedia: def test_get_screenshot_happy(self, api_key: str) -> None: diff --git a/tests/test_types.py b/tests/test_types.py index de1c1b3..4c61952 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -24,7 +24,6 @@ BehaviourEvent, BehavioursResponse, CdrResponse, - CdrResult, Connection, ConnectionPackets, DnsQuery, @@ -49,6 +48,8 @@ Metafields, MitreResponse, ModuleInfo, + NetworkConfigListItem, + NetworkConfigListResponse, NetworkSummary, NetworkThreat, NetworkThreatTls, @@ -63,7 +64,6 @@ SignatureCheckResponse, SignatureCheckResult, StaticScanResponse, - StaticScanResult, Submission, SubmissionCreated, SubmissionListItem, @@ -170,9 +170,18 @@ def test_submission_created(self, sample_submission_created: dict[str, Any]) -> assert sc.message == sample_submission_created["message"] def test_submission_overview_jobs(self) -> None: - jobs = SubmissionOverviewJobs.model_validate({"completed": 2, "total": 3}) - assert jobs.completed == 2 - assert jobs.total == 3 + jobs = SubmissionOverviewJobs.model_validate( + {"total": 3, "succeeded": 2, "failed": 0, "skipped": 1, "finished": 3} + ) + assert jobs.finished == jobs.total + assert jobs.succeeded == 2 + assert jobs.skipped == 1 + + def test_submission_score_family_duration(self, sample_submission: dict[str, Any]) -> None: + sub = Submission.model_validate(sample_submission) + assert sub.score == 78.5 + assert sub.family == "Sheetrat" + assert sub.reports[0].duration == 0.2 def test_submission_overview(self, sample_submission_overview: dict[str, Any]) -> None: ov = SubmissionOverview.model_validate(sample_submission_overview) @@ -251,6 +260,10 @@ def test_indicator(self, sample_indicator: dict[str, Any]) -> None: assert ind.syscall_line_numbers == [12, 100] assert ind.author == "system" + def test_indicator_category_is_list(self, sample_indicator: dict[str, Any]) -> None: + ind = Indicator.model_validate(sample_indicator) + assert ind.category == ["evasion", "persistence"] + def test_indicator_missing_field_raises(self, sample_indicator: dict[str, Any]) -> None: bad = {**sample_indicator} bad.pop("attackCodes") @@ -348,6 +361,11 @@ def test_overview_summary(self, sample_overview_summary: dict[str, Any]) -> None assert summary.network is not None assert summary.network.dns_count == 15 + def test_overview_summary_score_family(self, sample_overview_summary: dict[str, Any]) -> None: + s = OverviewSummary.model_validate(sample_overview_summary) + assert s.score == 84.0 + assert s.family == "Sheetrat" + def test_overview_summary_without_network( self, sample_overview_summary: dict[str, Any] ) -> None: @@ -529,27 +547,38 @@ def test_syscalls_response(self, sample_syscalls_response: dict[str, Any]) -> No class TestCdrTypes: - def test_cdr_result(self, sample_cdr_response: dict[str, Any]) -> None: - result = CdrResult.model_validate(sample_cdr_response["items"][0]) - assert result.artifact == "art-1" - assert result.engine_version == "1.2.3" - - def test_cdr_response(self, sample_cdr_response: dict[str, Any]) -> None: + def test_cdr_singleton(self, sample_cdr_response: dict[str, Any]) -> None: resp = CdrResponse.model_validate(sample_cdr_response) - assert resp.total == 1 - assert isinstance(resp.items[0], CdrResult) + assert resp.submission == "uuid-1" + assert resp.sanitized == ["doc.pdf"] + assert resp.removed == ["macro1"] + assert resp.sanitized_file_info is not None + assert resp.sanitized_file_info.file_type == "PDF" + assert resp.sanitized_file_info.file_size == 1024 + assert resp.sanitized_file_info.details[0].action == "removed" + assert resp.elapsed_time == "00:00:43" + assert resp.metafields is None class TestStaticScanTypes: - def test_static_scan_result(self, sample_static_scan_response: dict[str, Any]) -> None: - result = StaticScanResult.model_validate(sample_static_scan_response["items"][0]) - assert result.artifact == "art-1" - assert result.data is not None - assert result.engine_version == "9.9.9" - - def test_static_scan_response(self, sample_static_scan_response: dict[str, Any]) -> None: + def test_static_scan_singleton_and_extra( + self, sample_static_scan_response: dict[str, Any] + ) -> None: resp = StaticScanResponse.model_validate(sample_static_scan_response) - assert isinstance(resp.items[0], StaticScanResult) + assert resp.submission == "uuid-1" + assert resp.report_format == "exe" + assert resp.level == "malicious" + assert resp.score == 92.0 + assert resp.file_info is not None + assert resp.file_info.sha256 == "c" * 64 + assert resp.strings is not None + assert resp.strings.total_count == 1234 + assert resp.die_results[0].name == "PE" + assert resp.analysis_time == "00:00:02" + assert resp.metafields is None + assert resp.model_extra is not None + assert resp.model_extra["peExeSections"][0]["name"] == ".text" + assert resp.model_extra["imports"][0]["library"] == "kernel32.dll" class TestSignatureCheckTypes: @@ -644,7 +673,7 @@ class TestConfigTypes: def test_metafield_option(self, sample_metafields: dict[str, list[dict[str, Any]]]) -> None: opt = MetafieldOption.model_validate(sample_metafields["sandbox"][0]) assert opt.key == "timeout" - assert opt.type == "number" + assert opt.type == "select" def test_metafields(self, sample_metafields: dict[str, list[dict[str, Any]]]) -> None: m = Metafields.model_validate(sample_metafields) @@ -657,6 +686,20 @@ def test_environment_option(self, sample_environments: list[dict[str, Any]]) -> assert env.platform == "windows" assert env.default is True + def test_metafield_active_accessible( + self, sample_metafields: dict[str, list[dict[str, Any]]] + ) -> None: + mf = Metafields.model_validate(sample_metafields) + opt = mf.sandbox[0] + assert opt.active is True and opt.accessible is True + assert opt.options is not None + assert opt.options[0].accessible is True + assert opt.options[2].accessible is False + + def test_environment_active_accessible(self, sample_environments: list[dict[str, Any]]) -> None: + env = EnvironmentOption.model_validate(sample_environments[0]) + assert env.active is True and env.accessible is True + class TestDownloadTypes: def test_media_file(self, sample_media_files: list[dict[str, Any]]) -> None: @@ -718,6 +761,39 @@ def test_user_info_round_trip(self, sample_user_info: dict[str, Any]) -> None: # --------------------------------------------------------------------------- +class TestNetworkConfigs: + def test_network_config_item(self, sample_network_config: dict[str, Any]) -> None: + item = NetworkConfigListItem.model_validate(sample_network_config) + assert item.id == "cfg1" + assert item.type == "proxy" + assert item.port == 1080 + assert item.has_config_file is False + assert item.created_at == "2026-06-01T00:00:00.000Z" + assert item.updated_at == "2026-06-01T00:00:00.000Z" + + def test_network_config_list_response(self, sample_network_config: dict[str, Any]) -> None: + response = NetworkConfigListResponse.model_validate({"items": [sample_network_config]}) + assert len(response.items) == 1 + assert response.items[0].name == "Corp Proxy" + + def test_network_config_optional_fields_default_none(self) -> None: + item = NetworkConfigListItem.model_validate( + { + "id": "cfg2", + "name": "Corp VPN", + "type": "openvpn", + "hasConfigFile": True, + "createdAt": "2026-06-02T00:00:00.000Z", + "updatedAt": "2026-06-02T00:00:00.000Z", + } + ) + assert item.protocol is None + assert item.host is None + assert item.port is None + assert item.username is None + assert item.has_config_file is True + + class TestApiErrorEnvelope: def test_api_error_canonical_payload(self, report_unavailable_409_body: dict[str, Any]) -> None: body = report_unavailable_409_body From a53b9f274a99081f5a09dfa393687f22c50e895d Mon Sep 17 00:00:00 2001 From: batuhanisildak-malwation Date: Tue, 23 Jun 2026 11:54:37 +0000 Subject: [PATCH 2/2] feat: re-add deprecated SubmissionOverviewJobs.completed alias (always equals finished) --- CHANGELOG.md | 7 ++++--- src/threatzone/types/submissions.py | 15 ++++++++++++++- tests/test_types.py | 22 ++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db1c2a0..0288b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,10 @@ API-sync release: brings the SDK into exact agreement with the current ### Breaking -- **Submission overview `jobs` reshape:** `SubmissionOverviewJobs` no longer - exposes `completed`. It now carries `total`, `succeeded`, `failed`, - `skipped`, and `finished`. +- **Submission overview `jobs` reshape:** `SubmissionOverviewJobs` now carries + `total`, `succeeded`, `failed`, `skipped`, and `finished`. `completed` is + retained as a **deprecated** alias of `finished` (always equal) for + backward compatibility; new code should use `finished`. - **CDR response is a per-submission singleton:** `CdrResponse` was rewritten from an items envelope to a single object (`submission`, `status`, `sanitized`, `removed`, `sanitizedFileInfo`, diff --git a/src/threatzone/types/submissions.py b/src/threatzone/types/submissions.py index 79ed80b..e807925 100644 --- a/src/threatzone/types/submissions.py +++ b/src/threatzone/types/submissions.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from .common import FileInfo, Hashes, ReportStatus, ReportStatusValue, Tag, ThreatLevel @@ -34,6 +34,19 @@ class SubmissionOverviewJobs(BaseModel): failed: int skipped: int finished: int + completed: int = 0 + """Deprecated alias of ``finished`` (always equal). + + Kept for backward compatibility with integrations that read + ``overview.jobs.completed``; new code should use ``finished``. The value is + forced to mirror ``finished`` after validation regardless of any incoming + ``completed`` value. + """ + + @model_validator(mode="after") + def _mirror_completed_to_finished(self) -> SubmissionOverviewJobs: + self.completed = self.finished + return self class SubmissionOverview(BaseModel): diff --git a/tests/test_types.py b/tests/test_types.py index 4c61952..0f84dd1 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -177,6 +177,28 @@ def test_submission_overview_jobs(self) -> None: assert jobs.succeeded == 2 assert jobs.skipped == 1 + def test_submission_overview_jobs_completed_alias(self) -> None: + jobs = SubmissionOverviewJobs.model_validate( + {"total": 5, "succeeded": 4, "failed": 1, "skipped": 0, "finished": 5} + ) + assert jobs.completed == jobs.finished + dumped = jobs.model_dump() + assert "completed" in dumped + assert dumped["completed"] == jobs.finished + + def test_submission_overview_jobs_completed_mirrors_finished(self) -> None: + jobs = SubmissionOverviewJobs.model_validate( + { + "total": 5, + "succeeded": 4, + "failed": 1, + "skipped": 0, + "finished": 5, + "completed": 999, + } + ) + assert jobs.completed == 5 + def test_submission_score_family_duration(self, sample_submission: dict[str, Any]) -> None: sub = Submission.model_validate(sample_submission) assert sub.score == 78.5