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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,40 @@ 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).

## Unreleased

### Added

- `create_url_submission()` accepts `metafields` and `configurations`. Use
`metafields={"timeout": ..., "device_preset": ...}` to set the interactive session
duration and the emulated device. Use
`configurations={"networkConfig": ..., "sessionDurationSeconds": ...}` to select an
egress profile and the session duration (30-600 seconds).
- `get_url_analysis_session(uuid)` returns the interactive browser session state and the
viewer link as a `UrlAnalysisSession`.
- `start_url_analysis_session(uuid)` opens a session on an existing URL submission.
- `restart_url_analysis_session(uuid)` ends the current session and starts a clean one
with the same device and network choice.
- `restart_url_analysis(uuid)` re-runs a completed or errored URL-analysis report.
- `get_device_presets()` lists the selectable browser device presets as
`DevicePresetOption` values.
- `list_media_files()` and `get_media_file()` accept `source="dynamic"` or
`source="url_analysis"`. The parameter is omitted from the request when unset.
- `MediaFile.kind` reports `"video"`, `"screenshot"`, or `None`.
- `UrlAnalysisResponse` carries the scoring fields: `verdict_state`, `threat_score`,
`risk_level`, `privacy_score`, `privacy_risk_level`, `privacy_breakdown`,
`verdict_provenance`, `impersonation_target`, `completeness`, `intel_detections`,
`metafields`, and `scored_at`. Each is `None` or empty until scoring finishes.
- New types: `Message`, `DevicePresetOption`, `UrlAnalysisSession`,
`UrlAnalysisSessionConfig`, `UrlAnalysisPrivacyFactor`, `UrlAnalysisVerdictProvenance`,
`UrlAnalysisImpersonationTarget`, `UrlAnalysisCompleteness`, and
`UrlAnalysisIntelDetection`.
- The bundled `FakeThreatZoneAPI` serves the session, restart, and device-preset routes,
and returns the extended URL-analysis report. The new
`scenarios.seed_interactive_url_session()` seeds a submission with a running session.

Every method above is mirrored on `AsyncThreatZone`.

## 1.1.1

### Fixed
Expand Down
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,72 @@ See [Recipe 13](docs/RECIPES.md#13-control-dynamic_mimetype_check) for the full
breakdown including reading back `FileInfo.is_mimetype_checked` after the
submission completes.

## URL analysis

Submit a URL, open an interactive browser session, then read the scored report.

```python
# Pick an emulated device. Omit the metafield to use the plan default.
presets = client.get_device_presets()
mobile = next(p for p in presets if p.category == "mobile")

created = client.create_url_submission(
"https://suspicious-landing.example.com/update.php",
private=True,
safe_browsing=True,
metafields={"timeout": 240, "device_preset": mobile.id},
configurations={"networkConfig": "<network-config-id>"},
)

# Poll for the viewer link. A session over workspace capacity is queued, not rejected.
session = client.get_url_analysis_session(created.uuid)
if session.state == "queued":
print(f"Queued at position {session.queue_position}")
if session.vnc_available:
print(f"Viewer: {session.link}")

client.wait_for_completion(created.uuid, timeout=300)
report = client.get_url_analysis(created.uuid)

print(report.threat_score, report.risk_level) # 88 Critical
print(report.privacy_score, report.privacy_risk_level) # 35 Medium
if report.impersonation_target is not None:
print(report.impersonation_target.brand_name)
for hit in report.intel_detections:
print(hit.source, hit.severity)
```

Session control:

- `start_url_analysis_session(uuid)` opens a session on an existing submission.
- `restart_url_analysis_session(uuid)` ends the current session and starts a clean one
with the same device and network choice.
- Neither call consumes daily submission quota.
- `restart_url_analysis(uuid)` re-runs the analysis itself. It counts against the
concurrent-submission quota.

Scoring fields (`threat_score`, `risk_level`, `privacy_score`, `privacy_risk_level`,
`privacy_breakdown`, `verdict_state`, `verdict_provenance`, `impersonation_target`,
`completeness`, `intel_detections`, `metafields`, `scored_at`) are `None` or empty until
scoring finishes. Read them after the report reaches `completed`.

Media from the session:

```python
media = client.list_media_files(created.uuid, source="url_analysis")
for item in media:
print(item.id, item.kind) # kind: "video", "screenshot", or None

data = client.get_media_file(created.uuid, media[0].id, source="url_analysis")
```

`source` accepts `"dynamic"` or `"url_analysis"`. When omitted, the server picks
`dynamic` if the submission has a dynamic report, otherwise `url_analysis`. A submission
with neither report returns 409.

See [recipe 2](./docs/RECIPES.md#2-submit-a-url-and-get-the-url-analysis-report) for the
full flow.

## Core concepts

- **Submissions** — one analysis target (file or URL) with a stable `uuid`, a workspace
Expand Down
44 changes: 44 additions & 0 deletions docs/RECIPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,55 @@ def analyse_url(url: str) -> None:
analyse_url("https://example-phish.test/login")
```

**Interactive session and scoring.**

Pass `safe_browsing=True` to run an interactive browser session alongside the analysis.
Poll `get_url_analysis_session()` for the viewer link, then read the scored report.

```python
presets = client.get_device_presets()
mobile = next(p for p in presets if p.category == "mobile")

created = client.create_url_submission(
url,
private=True,
safe_browsing=True,
metafields={"timeout": 240, "device_preset": mobile.id},
configurations={"networkConfig": "<network-config-id>"},
)

session = client.get_url_analysis_session(created.uuid)
if session.state == "queued":
print(f"Queued at position {session.queue_position}")
if session.vnc_available:
print(f"Viewer: {session.link}")

client.wait_for_completion(created.uuid, timeout=300)
report = client.get_url_analysis(created.uuid)

print(f"Score: {report.threat_score} ({report.risk_level})")
print(f"Privacy: {report.privacy_score} ({report.privacy_risk_level})")
if report.impersonation_target is not None:
print(f"Brand: {report.impersonation_target.brand_name}")
for hit in report.intel_detections:
print(f"Intel: {hit.source} {hit.severity}")

for item in client.list_media_files(created.uuid, source="url_analysis"):
print(item.id, item.kind)
```

**Notes.**

- `screenshot.available` is `True` when a page screenshot is available. Fetch the bytes
via `client.get_screenshot(uuid)`.
- `whois` and `ssl_certificate` are both `Optional` &mdash; always null-check.
- The scoring fields are `None` (or `[]` for `intel_detections`) until scoring finishes.
- `start_url_analysis_session(uuid)` opens a session on an existing submission.
`restart_url_analysis_session(uuid)` gives a clean session with the same configuration.
Neither consumes daily submission quota.
- `restart_url_analysis(uuid)` re-runs the analysis. It counts against the
concurrent-submission quota.
- A session over workspace capacity is queued, not rejected. Read `queue_position`.
- Use `create_open_in_browser_submission(url, environment="w10_x64")` instead if you
need the full dynamic + browser capture on top of the URL analysis.

Expand Down
16 changes: 14 additions & 2 deletions docs/TYPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ peer report type to dynamic/static/cdr &mdash; it is guarded separately by

| Type | Purpose |
|------|---------|
| `UrlAnalysisResponse` | Top-level report: `level`, `status`, `general_info`, `screenshot`, optional `ip_info`, `dns_records`, optional `whois`, optional `ssl_certificate`, optional `response_headers`, optional `extracted_file`, optional `threat_analysis`, `pages`. |
| `UrlAnalysisResponse` | Top-level report: `level`, `status`, `general_info`, `screenshot`, optional `ip_info`, `dns_records`, optional `whois`, optional `ssl_certificate`, optional `response_headers`, optional `extracted_file`, optional `threat_analysis`, `pages`, plus the scoring fields `verdict_state`, `threat_score`, `risk_level`, `privacy_score`, `privacy_risk_level`, `privacy_breakdown`, `verdict_provenance`, `impersonation_target`, `completeness`, `intel_detections`, `metafields`, `scored_at`. |
| `UrlAnalysisGeneralInfo` | `{url, domain, website_title}`. |
| `UrlAnalysisScreenshot` | `{available: bool}`. Fetch actual bytes with `client.get_screenshot(uuid)`. |
| `UrlAnalysisIpInfo` | `{ip, asn, city, country, isp, organization, threat_status}`. |
Expand All @@ -146,11 +146,21 @@ peer report type to dynamic/static/cdr &mdash; it is guarded separately by
| `UrlAnalysisThreatAnalysis` | `{overview, blacklist, threat_details}`. |
| `UrlAnalysisThreatOverviewItem` | `{source, title, description, verdict}`. Per-intel-source verdict. |
| `UrlAnalysisThreatDetailItem` | `{source, details}`. Per-intel-source raw detail payload. |
| `UrlAnalysisPrivacyFactor` | `{factor, label, delta, detail}`. One contributor to the privacy score. |
| `UrlAnalysisVerdictProvenance` | `{raw_verdict, quorum_applied, allowlist_applied, collection_state, pass_}`. How the verdict was produced. `pass_` maps to the wire field `pass`. |
| `UrlAnalysisImpersonationTarget` | `{brand_name, canonical_domain, signals, confidence}`. Brand the page most likely impersonates. |
| `UrlAnalysisCompleteness` | `{measured_sources, skipped_sources, unavailable_sources, browser_collection}`. Which sources produced evidence. |
| `UrlAnalysisIntelDetection` | `{source, detection_type, severity, details}`. One threat-intelligence hit. |
| `UrlAnalysisSession` | Interactive browser session state: `state`, `collection_status`, `recording_status`, `queue_position`, `vnc_available`, `report_available`, `started_at`, `ready_at`, `expires_at`, `finished_at`, `failure_code`, `extensions_used`, `extensions_max`, `session_config`, `link`, `expired`. Returned by `get_url_analysis_session()`. |
| `UrlAnalysisSessionConfig` | `{device_profile_id, network_config_id, session_duration_seconds, device_preset_id, device_preset_name}`. Device and network choice the session runs with. |

`UrlAnalysisResponse.level` is `ThreatLevel`, shared with submission level. Everything
`Optional` is intentional &mdash; a submission may have no WHOIS (e.g. raw-IP URL), no TLS
cert (plain HTTP), etc.

The scoring fields are `None` (or `[]` for `intel_detections`) until scoring finishes.
Read them after the report reaches `completed`.

---

## Account
Expand Down Expand Up @@ -222,6 +232,7 @@ Types shared across multiple surfaces.
| `FileEntrypoint` | `{filename}`. Entrypoint inside an archive submission. |
| `Tag` | `{type, value}`. |
| `ReportStatus` | Per-report state &mdash; see Submissions section. |
| `Message` | `{message}`. One-line acknowledgement returned by an action endpoint. |
| `ReportOperatingSystem` | `{name, platform}`. `platform` is one of `windows`/`linux`/`android`/`macos`. |
| `ThreatLevel` | Literal union: `unknown` / `benign` / `suspicious` / `malicious`. |
| `ReportType` | Literal union: `dynamic` / `static` / `cdr` / `url_analysis` / `open_in_browser`. |
Expand All @@ -237,7 +248,8 @@ Types shared across multiple surfaces.
| `Metafields` | Metafields grouped by scan type: `sandbox`, `static`, `cdr`, `url`, `open_in_browser`. | `GET /config/metafields` |
| `MetafieldOption` | Single metafield: `key`, `label`, `description`, `type`, `default`, optional `options`. | same |
| `EnvironmentOption` | `{key, name, platform, default}`. One available OS environment. | `GET /config/environments` |
| `MediaFile` | `{id, name, content_type, size}`. | `GET /submissions/:uuid/media` |
| `DevicePresetOption` | `{id, name, category, base_key, default}`. One selectable browser device preset. Pass `id` as the `device_preset` metafield. | `GET /config/device-presets` |
| `MediaFile` | `{id, name, content_type, size, kind}`. `kind` is `"video"`, `"screenshot"`, or `None`. | `GET /submissions/:uuid/media` |

Download endpoints return `DownloadResponse` (sync) or `AsyncDownloadResponse` (async)
from `threatzone._streaming`. Both are context managers; both expose `filename`,
Expand Down
53 changes: 50 additions & 3 deletions examples/recipe_02_url_analysis.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Recipe 2: Submit a URL, wait, print phishing findings.
"""Recipe 2: Submit a URL with an interactive session, wait, print the score.

Runnable two ways:

Expand All @@ -15,16 +15,59 @@

import os
import sys
import time

from threatzone import ReportUnavailableError, ThreatZone
from threatzone.types import UrlAnalysisResponse

SESSION_POLL_ATTEMPTS = 30
SESSION_POLL_INTERVAL = 2.0


def wait_for_session_link(
client: ThreatZone,
uuid: str,
*,
attempts: int = SESSION_POLL_ATTEMPTS,
interval: float = SESSION_POLL_INTERVAL,
) -> str | None:
"""Poll the interactive session until it runs. Return the viewer link."""
for _ in range(attempts):
session = client.get_url_analysis_session(uuid)
if session.state == "running" and session.link is not None:
return session.link
if session.state in {"error", "finished"}:
print(f"Session ended early: {session.state} ({session.failure_code})")
return None
if session.state == "queued":
print(f"Session queued at position {session.queue_position}")
if interval > 0:
time.sleep(interval)
return None


def main(client: ThreatZone, url: str) -> UrlAnalysisResponse:
"""Submit URL, wait, fetch URL analysis, print phishing-relevant fields."""
created = client.create_url_submission(url, private=True)
"""Submit a URL, open a session, wait, then print the scored report."""
presets = client.get_device_presets()
mobile = next((p for p in presets if p.category == "mobile"), None)

metafields: dict[str, object] = {"timeout": 240}
if mobile is not None:
metafields["device_preset"] = mobile.id
print(f"Using device preset {mobile.name}")

created = client.create_url_submission(
url,
private=True,
safe_browsing=True,
metafields=metafields,
)
print(f"Created URL submission {created.uuid}")

link = wait_for_session_link(client, created.uuid, interval=0.0)
if link is not None:
print(f"Viewer: {link}")

final = client.wait_for_completion(created.uuid, timeout=300)

try:
Expand All @@ -36,6 +79,10 @@ def main(client: ThreatZone, url: str) -> UrlAnalysisResponse:
print(f"URL: {report.general_info.url}")
print(f"Domain: {report.general_info.domain}")
print(f"Verdict: {report.level}")
print(f"Score: {report.threat_score} ({report.risk_level})")
if report.impersonation_target is not None:
target = report.impersonation_target
print(f"Brand: impersonates {target.brand_name} ({target.confidence} confidence)")
if report.ip_info is not None:
print(f"IP: {report.ip_info.ip} ({report.ip_info.country})")
if report.threat_analysis is not None and report.threat_analysis.blacklist:
Expand Down
6 changes: 6 additions & 0 deletions src/threatzone/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
CdrSanitizedFileInfo,
CdrSanitizedFileInfoDetail,
Connection,
DevicePresetOption,
DnsQuery,
EmlAnalysis,
EnvironmentOption,
Expand All @@ -54,6 +55,7 @@
IoCsResponse,
LimitsCount,
MediaFile,
Message,
MetafieldOption,
Metafields,
MitreResponse,
Expand Down Expand Up @@ -82,6 +84,7 @@
SyscallsResponse,
Tag,
UrlAnalysisResponse,
UrlAnalysisSession,
UserInfo,
UserInfoDetails,
WorkspaceBasicInfo,
Expand Down Expand Up @@ -118,10 +121,12 @@
"FileInfo",
"Tag",
"ReportStatus",
"Message",
# Types - Config
"MetafieldOption",
"Metafields",
"EnvironmentOption",
"DevicePresetOption",
# Types - Downloads
"MediaFile",
# Types - Indicators
Expand Down Expand Up @@ -180,6 +185,7 @@
"SignatureCheckResult",
# Types - URL analysis
"UrlAnalysisResponse",
"UrlAnalysisSession",
# Types - Submissions
"SubmissionCreated",
"SubmissionListItem",
Expand Down
Loading
Loading