spot-sdk-python 1.0.0__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/PKG-INFO +1 -1
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/pyproject.toml +1 -1
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/__init__.py +13 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/plugin.py +7 -2
- spot_sdk_python-1.1.0/spot_sdk/retriever.py +267 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/README.md +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/analysis_context.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/analyzer.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/analyzer_base.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/api_gateway.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/config.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/config_client.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/config_helpers.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/email.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/errors.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/knowledge.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/knowledge_tags.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/logging.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/ollama.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/orchestrator.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/results.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/settings_schema.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/testing/README.md +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/testing/__init__.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/testing/factories.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/testing/fake_knowledge_client.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/threat_levels.py +0 -0
- {spot_sdk_python-1.0.0 → spot_sdk_python-1.1.0}/spot_sdk/workflow.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "spot-sdk-python"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "Python SDK for SPOT platform - API contracts, models, and utilities"
|
|
9
9
|
authors = ["SPOT Project <spot@sonn.lu>"]
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from .analysis_context import AnalysisContextReader
|
|
4
4
|
from .analyzer import AnalyzerCapability, AnalyzerInterface, ThreatLevel
|
|
5
5
|
from .config import ConfigOption, ConfigReloadResult, ConfigStatus, ConfigUpdateRequest
|
|
6
|
+
from .config_client import ConfigClient
|
|
6
7
|
from .config_helpers import merge_settings
|
|
7
8
|
from .email import Attachment, Email, EmailHeader
|
|
8
9
|
from .errors import ErrorResponse
|
|
@@ -18,6 +19,12 @@ from .results import (
|
|
|
18
19
|
AnalysisResult,
|
|
19
20
|
IndicatorType,
|
|
20
21
|
)
|
|
22
|
+
from .retriever import (
|
|
23
|
+
IngestionRequest,
|
|
24
|
+
IngestionResult,
|
|
25
|
+
IngestionStatus,
|
|
26
|
+
MailRetrieverClient,
|
|
27
|
+
)
|
|
21
28
|
from .settings_schema import register_settings_schema
|
|
22
29
|
from .threat_levels import confidence_to_threat_level
|
|
23
30
|
from .workflow import (
|
|
@@ -39,6 +46,7 @@ __all__ = [
|
|
|
39
46
|
"ConfigStatus",
|
|
40
47
|
"ConfigUpdateRequest",
|
|
41
48
|
"ConfigReloadResult",
|
|
49
|
+
"ConfigClient",
|
|
42
50
|
# Error response
|
|
43
51
|
"ErrorResponse",
|
|
44
52
|
# Email models
|
|
@@ -70,6 +78,11 @@ __all__ = [
|
|
|
70
78
|
"content_hash",
|
|
71
79
|
# Plugin vocabulary
|
|
72
80
|
"PluginKind",
|
|
81
|
+
# Mail-retriever ingest contract
|
|
82
|
+
"IngestionRequest",
|
|
83
|
+
"IngestionResult",
|
|
84
|
+
"IngestionStatus",
|
|
85
|
+
"MailRetrieverClient",
|
|
73
86
|
# Orchestration
|
|
74
87
|
"OrchestrationResult",
|
|
75
88
|
"AnalyzerResult",
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"""Plugin vocabulary shared across the platform.
|
|
2
2
|
|
|
3
3
|
A "plugin" is any pluggable component registered with a SPOT platform
|
|
4
|
-
through the plugin catalog, installer, and sources. Plugins come in
|
|
5
|
-
kinds today:
|
|
4
|
+
through the plugin catalog, installer, and sources. Plugins come in
|
|
5
|
+
three kinds today:
|
|
6
6
|
|
|
7
7
|
- ``analyzer``: runs ``POST /internal/analyze`` and produces an
|
|
8
8
|
``AnalysisResult`` (a phishing verdict contributing to aggregation).
|
|
9
9
|
- ``context_provider``: runs ``POST /internal/enrich`` and produces an
|
|
10
10
|
``EnrichmentResult`` whose data populates ``analysis_context`` for
|
|
11
11
|
downstream analyzers.
|
|
12
|
+
- ``mail_retriever``: ingests emails from any source (SMTP filter,
|
|
13
|
+
IMAP, mailbox API, ...) and submits them to the platform via
|
|
14
|
+
``POST /internal/ingest`` on the mail-orchestrator. Retrievers push;
|
|
15
|
+
the platform never polls them.
|
|
12
16
|
|
|
13
17
|
The ``PluginKind`` value is surfaced by the platform catalog (derived
|
|
14
18
|
from OCI image labels) so the installer can route a plugin to the
|
|
@@ -25,6 +29,7 @@ class PluginKind(str, Enum):
|
|
|
25
29
|
|
|
26
30
|
ANALYZER = "analyzer"
|
|
27
31
|
CONTEXT_PROVIDER = "context_provider"
|
|
32
|
+
MAIL_RETRIEVER = "mail_retriever"
|
|
28
33
|
|
|
29
34
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
30
35
|
return self.value
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Mail-retriever SDK: ingest models and client.
|
|
2
|
+
|
|
3
|
+
A mail-retriever is a SPOT plugin that delivers emails to the
|
|
4
|
+
mail-orchestrator. The platform never polls retrievers; retrievers
|
|
5
|
+
**push** by calling ``POST /internal/ingest`` whenever they have a new
|
|
6
|
+
email — irrespective of how they obtained it (Postfix ``content_filter``,
|
|
7
|
+
IMAP IDLE, Maildir watch, future Microsoft Graph webhook, ...).
|
|
8
|
+
|
|
9
|
+
Two ingestion modes are supported, selected per-call via
|
|
10
|
+
:attr:`IngestionRequest.wait_for_verdict`:
|
|
11
|
+
|
|
12
|
+
- **Asynchronous** (``wait_for_verdict=False``, default): the
|
|
13
|
+
orchestrator queues the email for analysis and returns immediately
|
|
14
|
+
with ``status="accepted"``. Used by retrievers that don't need a
|
|
15
|
+
per-message decision (Maildir watchers, fire-and-forget journaling).
|
|
16
|
+
- **Synchronous** (``wait_for_verdict=True``): the orchestrator runs
|
|
17
|
+
the workflow inline and returns the verdict. Used by SMTP-time
|
|
18
|
+
filters that must decide accept / tag / reject within the MTA's
|
|
19
|
+
timeout. The retriever supplies its own ``timeout_ms``; on platform
|
|
20
|
+
timeout the orchestrator returns ``status="timeout"`` so the caller
|
|
21
|
+
can apply its fail-open / fail-closed policy.
|
|
22
|
+
|
|
23
|
+
Quick start (a Postfix content_filter retriever)::
|
|
24
|
+
|
|
25
|
+
from spot_sdk import Email, EmailHeader, MailRetrieverClient
|
|
26
|
+
|
|
27
|
+
client = MailRetrieverClient(
|
|
28
|
+
url=os.environ["SPOT_MAIL_ORCHESTRATOR_URL"],
|
|
29
|
+
api_key=os.environ["SPOT_INTERNAL_API_KEY"],
|
|
30
|
+
)
|
|
31
|
+
email = Email(headers=EmailHeader(...), body_text="...", source="smtp")
|
|
32
|
+
result = await client.ingest(email, wait_for_verdict=True, timeout_ms=30000)
|
|
33
|
+
if result.status == "analyzed" and result.is_phishing:
|
|
34
|
+
# tag headers, quarantine, or 5xx the SMTP transaction
|
|
35
|
+
...
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import os
|
|
41
|
+
from enum import Enum
|
|
42
|
+
from typing import Any, cast
|
|
43
|
+
|
|
44
|
+
import httpx
|
|
45
|
+
from pydantic import BaseModel, Field
|
|
46
|
+
|
|
47
|
+
from .email import Email
|
|
48
|
+
from .logging import get_logger
|
|
49
|
+
|
|
50
|
+
logger = get_logger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ----------------------------------------------------------------------- #
|
|
54
|
+
# Models
|
|
55
|
+
# ----------------------------------------------------------------------- #
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class IngestionStatus(str, Enum):
|
|
59
|
+
"""Terminal state of an ingestion call."""
|
|
60
|
+
|
|
61
|
+
ACCEPTED = "accepted" # async path: queued, no verdict yet
|
|
62
|
+
ANALYZED = "analyzed" # sync path: verdict is in the response
|
|
63
|
+
REJECTED = "rejected" # platform refused (validation / quota)
|
|
64
|
+
TIMEOUT = "timeout" # sync path: workflow did not complete in time
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
67
|
+
return self.value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IngestionRequest(BaseModel):
|
|
71
|
+
"""Payload sent by a retriever to ``POST /internal/ingest``."""
|
|
72
|
+
|
|
73
|
+
email: Email = Field(
|
|
74
|
+
...,
|
|
75
|
+
description="The full email to analyse, as a spot-sdk Email model.",
|
|
76
|
+
)
|
|
77
|
+
workflow_id: str | None = Field(
|
|
78
|
+
default=None,
|
|
79
|
+
description=(
|
|
80
|
+
"Workflow to run for this email. ``None`` means the platform "
|
|
81
|
+
"default workflow chosen by the orchestrator."
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
priority: str = Field(
|
|
85
|
+
default="normal",
|
|
86
|
+
pattern="^(low|normal|high)$",
|
|
87
|
+
description="Job priority hint for the analyzer-orchestrator queue.",
|
|
88
|
+
)
|
|
89
|
+
wait_for_verdict: bool = Field(
|
|
90
|
+
default=False,
|
|
91
|
+
description=(
|
|
92
|
+
"When True, the orchestrator runs the workflow synchronously "
|
|
93
|
+
"and the response carries the verdict. When False, the "
|
|
94
|
+
"orchestrator queues the work and returns ``accepted`` "
|
|
95
|
+
"immediately."
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
timeout_ms: int = Field(
|
|
99
|
+
default=30_000,
|
|
100
|
+
ge=1_000,
|
|
101
|
+
le=300_000,
|
|
102
|
+
description=(
|
|
103
|
+
"Caller-side budget when ``wait_for_verdict=True``. The "
|
|
104
|
+
"orchestrator returns ``status='timeout'`` if the workflow "
|
|
105
|
+
"does not complete in time so the retriever can apply its "
|
|
106
|
+
"own fail-open / fail-closed policy."
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class IngestionResult(BaseModel):
|
|
112
|
+
"""Response returned from ``POST /internal/ingest``."""
|
|
113
|
+
|
|
114
|
+
job_id: str = Field(
|
|
115
|
+
...,
|
|
116
|
+
description="Stable id for this ingestion. Track via /internal/jobs/{id}.",
|
|
117
|
+
)
|
|
118
|
+
status: IngestionStatus = Field(
|
|
119
|
+
...,
|
|
120
|
+
description="Terminal state of the call.",
|
|
121
|
+
)
|
|
122
|
+
is_phishing: bool | None = Field(
|
|
123
|
+
default=None,
|
|
124
|
+
description="Verdict; populated only when ``status='analyzed'``.",
|
|
125
|
+
)
|
|
126
|
+
threat_level: str | None = Field(
|
|
127
|
+
default=None,
|
|
128
|
+
description="Threat level string; populated only when ``status='analyzed'``.",
|
|
129
|
+
)
|
|
130
|
+
confidence: float | None = Field(
|
|
131
|
+
default=None,
|
|
132
|
+
ge=0.0,
|
|
133
|
+
le=1.0,
|
|
134
|
+
description="Verdict confidence 0.0–1.0; populated only when ``status='analyzed'``.",
|
|
135
|
+
)
|
|
136
|
+
error: str | None = Field(
|
|
137
|
+
default=None,
|
|
138
|
+
description="Human-readable error message when ``status='rejected'``.",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ----------------------------------------------------------------------- #
|
|
143
|
+
# Client
|
|
144
|
+
# ----------------------------------------------------------------------- #
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class MailRetrieverClient:
|
|
148
|
+
"""HTTP client used by mail-retriever plugins to call the orchestrator.
|
|
149
|
+
|
|
150
|
+
Mirrors the shape of :class:`spot_sdk.knowledge.KnowledgeClient`: thin
|
|
151
|
+
httpx wrapper, internal API key on a fixed header, no retry logic
|
|
152
|
+
beyond the underlying httpx defaults. Every retriever — push or pull
|
|
153
|
+
— uses the same client to deliver emails to the mail-orchestrator.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
DEFAULT_TIMEOUT = 35.0 # slightly above the default IngestionRequest.timeout_ms
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
url: str,
|
|
161
|
+
api_key: str | None = None,
|
|
162
|
+
retriever_id: str | None = None,
|
|
163
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Initialize the client.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
url: Base URL of the mail-orchestrator
|
|
169
|
+
(e.g. ``http://mail-orchestrator:8000``).
|
|
170
|
+
api_key: ``SPOT_INTERNAL_API_KEY`` value used to authenticate
|
|
171
|
+
the call. Required for the orchestrator's
|
|
172
|
+
``/internal/ingest`` endpoint.
|
|
173
|
+
retriever_id: Identifier sent in the ``X-Retriever-Id`` header
|
|
174
|
+
so the orchestrator can attribute jobs to this plugin
|
|
175
|
+
(e.g. ``retriever-postfix``). Optional but recommended.
|
|
176
|
+
timeout: HTTP timeout in seconds. Should be >= the longest
|
|
177
|
+
``IngestionRequest.timeout_ms`` the caller will use.
|
|
178
|
+
"""
|
|
179
|
+
self.url = url.rstrip("/")
|
|
180
|
+
self.api_key = api_key
|
|
181
|
+
self.retriever_id = retriever_id
|
|
182
|
+
self.timeout = timeout
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_env(cls, *, timeout: float = DEFAULT_TIMEOUT) -> MailRetrieverClient:
|
|
186
|
+
"""Build a client from standard SPOT environment variables.
|
|
187
|
+
|
|
188
|
+
Reads ``SPOT_MAIL_ORCHESTRATOR_URL``, ``SPOT_INTERNAL_API_KEY``
|
|
189
|
+
and ``SPOT_RETRIEVER_ID`` — the same convention used by the
|
|
190
|
+
api-gateway and knowledge service.
|
|
191
|
+
"""
|
|
192
|
+
url = os.environ.get("SPOT_MAIL_ORCHESTRATOR_URL")
|
|
193
|
+
if not url:
|
|
194
|
+
raise RuntimeError(
|
|
195
|
+
"SPOT_MAIL_ORCHESTRATOR_URL is not set. The SPOT installer "
|
|
196
|
+
"normally injects this when the plugin is installed; in "
|
|
197
|
+
"tests, set it explicitly."
|
|
198
|
+
)
|
|
199
|
+
return cls(
|
|
200
|
+
url=url,
|
|
201
|
+
api_key=os.environ.get("SPOT_INTERNAL_API_KEY"),
|
|
202
|
+
retriever_id=os.environ.get("SPOT_RETRIEVER_ID"),
|
|
203
|
+
timeout=timeout,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
async def ingest(
|
|
207
|
+
self,
|
|
208
|
+
email: Email,
|
|
209
|
+
*,
|
|
210
|
+
workflow_id: str | None = None,
|
|
211
|
+
priority: str = "normal",
|
|
212
|
+
wait_for_verdict: bool = False,
|
|
213
|
+
timeout_ms: int = 30_000,
|
|
214
|
+
) -> IngestionResult:
|
|
215
|
+
"""Submit an email to the mail-orchestrator for analysis.
|
|
216
|
+
|
|
217
|
+
See :class:`IngestionRequest` for the field semantics.
|
|
218
|
+
"""
|
|
219
|
+
request = IngestionRequest(
|
|
220
|
+
email=email,
|
|
221
|
+
workflow_id=workflow_id,
|
|
222
|
+
priority=priority,
|
|
223
|
+
wait_for_verdict=wait_for_verdict,
|
|
224
|
+
timeout_ms=timeout_ms,
|
|
225
|
+
)
|
|
226
|
+
# Caller's HTTP timeout must outlive the orchestrator's own work
|
|
227
|
+
# window; otherwise we'd cancel a synchronous wait that could
|
|
228
|
+
# still complete server-side.
|
|
229
|
+
http_timeout = max(self.timeout, (timeout_ms / 1000.0) + 5.0)
|
|
230
|
+
data = await self._post(
|
|
231
|
+
"/internal/ingest",
|
|
232
|
+
json=request.model_dump(mode="json"),
|
|
233
|
+
timeout=http_timeout,
|
|
234
|
+
)
|
|
235
|
+
return IngestionResult(**data)
|
|
236
|
+
|
|
237
|
+
async def get_job(self, job_id: str) -> IngestionResult:
|
|
238
|
+
"""Look up a previously-submitted ingestion by id."""
|
|
239
|
+
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
|
240
|
+
r = await c.get(
|
|
241
|
+
f"{self.url}/internal/jobs/{job_id}",
|
|
242
|
+
headers=self._headers(),
|
|
243
|
+
)
|
|
244
|
+
r.raise_for_status()
|
|
245
|
+
return IngestionResult(**r.json())
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------ #
|
|
248
|
+
# HTTP helpers
|
|
249
|
+
# ------------------------------------------------------------------ #
|
|
250
|
+
|
|
251
|
+
def _headers(self) -> dict[str, str]:
|
|
252
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
253
|
+
if self.api_key:
|
|
254
|
+
h["X-Internal-API-Key"] = self.api_key
|
|
255
|
+
if self.retriever_id:
|
|
256
|
+
h["X-Retriever-Id"] = self.retriever_id
|
|
257
|
+
return h
|
|
258
|
+
|
|
259
|
+
async def _post(
|
|
260
|
+
self, path: str, *, json: dict[str, Any], timeout: float
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
async with httpx.AsyncClient(timeout=timeout) as c:
|
|
263
|
+
r = await c.post(self.url + path, json=json, headers=self._headers())
|
|
264
|
+
r.raise_for_status()
|
|
265
|
+
if r.status_code == 204 or not r.content:
|
|
266
|
+
return {}
|
|
267
|
+
return cast("dict[str, Any]", r.json())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|