clue-api 1.5.0.dev271__tar.gz → 1.5.0.dev295__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.
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/PKG-INFO +1 -1
- clue_api-1.5.0.dev295/clue/plugin/helpers/central_server.py +417 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/services/type_service.py +1 -1
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/pyproject.toml +1 -1
- clue_api-1.5.0.dev271/clue/plugin/helpers/central_server.py +0 -27
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/LICENSE +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/README.md +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/.gitignore +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/base.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/v1/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/v1/actions.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/v1/auth.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/v1/configs.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/v1/fetchers.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/v1/lookup.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/v1/registration.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/v1/static.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/api/v1/sync.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/app.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/cache/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/bytes_utils.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/classification.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/classification.yml +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/dict_utils.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/exceptions.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/forge.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/json_utils.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/list_utils.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/logging/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/logging/audit.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/logging/format.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/regex.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/str_utils.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/swagger.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/common/uid.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/config.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/constants/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/constants/env.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/constants/supported_types.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/cronjobs/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/cronjobs/plugins.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/error.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/extensions/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/extensions/config.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/gunicorn_config.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/healthz.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/helper/discover.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/helper/headers.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/helper/oauth.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/actions.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/config.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/fetchers.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/graph.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/model_list.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/network.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/results/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/results/base.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/results/file.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/results/graph.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/results/image.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/results/status.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/results/validation.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/schema.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/selector.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/sync.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/models/validators.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/patched.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/plugin/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/plugin/celery_app.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/plugin/helpers/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/plugin/helpers/email_render.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/plugin/helpers/token.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/plugin/helpers/trino.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/plugin/models.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/plugin/utils.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/py.typed +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/remote/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/remote/datatypes/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/remote/datatypes/cache.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/remote/datatypes/events.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/remote/datatypes/hash.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/remote/datatypes/queues/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/remote/datatypes/queues/comms.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/remote/datatypes/set.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/remote/datatypes/user_quota_tracker.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/security/__init__.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/security/obo.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/security/utils.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/services/action_service.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/services/auth_service.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/services/config_service.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/services/fetcher_service.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/services/jwt_service.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/services/lookup_service.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/services/mongo_service.py +0 -0
- {clue_api-1.5.0.dev271 → clue_api-1.5.0.dev295}/clue/services/user_service.py +0 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import os
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
from urllib.parse import urljoin
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from flask import request
|
|
8
|
+
from pydantic import TypeAdapter, ValidationError
|
|
9
|
+
from requests import JSONDecodeError
|
|
10
|
+
from requests.adapters import HTTPAdapter, Retry
|
|
11
|
+
|
|
12
|
+
from clue.common.logging import get_logger
|
|
13
|
+
from clue.models.actions import ActionResult, ActionSpec
|
|
14
|
+
from clue.models.fetchers import FetcherDefinition, FetcherResult
|
|
15
|
+
from clue.models.network import QueryEntry
|
|
16
|
+
from clue.models.selector import Selector
|
|
17
|
+
from clue.plugin.models import BulkEntry
|
|
18
|
+
|
|
19
|
+
CENTRAL_SERVER_URL = os.getenv("CENTRAL_API_URL", "http://enrichment-rest.enrichment.svc.cluster.local:5000")
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__file__)
|
|
22
|
+
|
|
23
|
+
# Module-level session cache keyed by sha256(url:retries).
|
|
24
|
+
_SESSIONS: dict[str, requests.Session] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_session(base_url: str, retries: int) -> requests.Session:
|
|
28
|
+
"""Return a cached, retry-configured :class:`requests.Session` for *base_url*.
|
|
29
|
+
|
|
30
|
+
Sessions are keyed by ``(base_url, retries)``. Request timeout is provided
|
|
31
|
+
per call and does not affect Session or adapter configuration.
|
|
32
|
+
"""
|
|
33
|
+
cache_key = sha256(f"{base_url}:{retries}".encode()).hexdigest()
|
|
34
|
+
if cache_key not in _SESSIONS:
|
|
35
|
+
session = requests.Session()
|
|
36
|
+
pool_size = max(1, math.floor(int(os.environ.get("EXECUTOR_THREADS", 32)) / 2))
|
|
37
|
+
retry_strategy = Retry(
|
|
38
|
+
total=retries,
|
|
39
|
+
backoff_factor=0.5,
|
|
40
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
41
|
+
allowed_methods=["GET", "POST"],
|
|
42
|
+
)
|
|
43
|
+
adapter = HTTPAdapter(
|
|
44
|
+
pool_connections=pool_size,
|
|
45
|
+
pool_maxsize=pool_size,
|
|
46
|
+
max_retries=retry_strategy,
|
|
47
|
+
)
|
|
48
|
+
session.mount("http://", adapter)
|
|
49
|
+
session.mount("https://", adapter)
|
|
50
|
+
_SESSIONS[cache_key] = session
|
|
51
|
+
return _SESSIONS[cache_key]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _connect_to_central_server(retries: int = 3) -> tuple[requests.Session, dict[str, str]]:
|
|
55
|
+
"""Return a ``(session, headers)`` pair for calling the central server.
|
|
56
|
+
|
|
57
|
+
Token extraction priority:
|
|
58
|
+
|
|
59
|
+
1. ``X-Clue-Authorization`` — a pre-OBO'd token forwarded by the central API.
|
|
60
|
+
Used as the primary ``Authorization`` bearer. The original ``Authorization``
|
|
61
|
+
token is included as ``X-Clue-Authorization`` so the central API can
|
|
62
|
+
propagate it further downstream if needed.
|
|
63
|
+
2. ``Authorization`` — the raw user bearer token, used when no pre-OBO token
|
|
64
|
+
is present.
|
|
65
|
+
|
|
66
|
+
The returned *session* is shared across calls (pooled); the returned *headers*
|
|
67
|
+
dict is scoped to the current request and must not be mutated after use.
|
|
68
|
+
"""
|
|
69
|
+
clue_token = request.headers.get("X-Clue-Authorization", None)
|
|
70
|
+
raw_auth = request.headers.get("Authorization", None)
|
|
71
|
+
auth_token = raw_auth.split(" ", 1)[1] if raw_auth and " " in raw_auth else None
|
|
72
|
+
|
|
73
|
+
if clue_token:
|
|
74
|
+
logger.info("X-Clue-Authorization header specified, using pre-OBO token")
|
|
75
|
+
primary_token = clue_token
|
|
76
|
+
secondary_token = auth_token # forward original as X-Clue-Authorization
|
|
77
|
+
elif auth_token:
|
|
78
|
+
logger.warning("X-Clue-Authorization header not specified, falling back to Authorization header")
|
|
79
|
+
primary_token = auth_token
|
|
80
|
+
secondary_token = None
|
|
81
|
+
else:
|
|
82
|
+
logger.warning("No token specified, continuing with no authentication")
|
|
83
|
+
primary_token = None
|
|
84
|
+
secondary_token = None
|
|
85
|
+
|
|
86
|
+
headers: dict[str, str] = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
87
|
+
if primary_token:
|
|
88
|
+
headers["Authorization"] = f"Bearer {primary_token}"
|
|
89
|
+
if secondary_token:
|
|
90
|
+
headers["X-Clue-Authorization"] = secondary_token
|
|
91
|
+
|
|
92
|
+
session = _get_session(CENTRAL_SERVER_URL, retries)
|
|
93
|
+
return session, headers
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _safe_central_get(
|
|
97
|
+
url: str,
|
|
98
|
+
session: requests.Session,
|
|
99
|
+
headers: dict[str, str],
|
|
100
|
+
timeout: float = 5.0,
|
|
101
|
+
) -> dict | None:
|
|
102
|
+
"""Make a GET request to the central server with standardized error handling.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
url: The full URL to request.
|
|
106
|
+
session: A cached ``requests.Session`` from ``_get_session()``.
|
|
107
|
+
headers: Request headers dict from ``_connect_to_central_server()``.
|
|
108
|
+
timeout: HTTP timeout in seconds. Defaults to 5.0.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The ``api_response`` dict from the response body on success, or ``None``
|
|
112
|
+
on any connection, parse, or HTTP error.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
rsp = session.get(url, headers=headers, timeout=timeout)
|
|
116
|
+
rsp.raise_for_status()
|
|
117
|
+
return rsp.json().get("api_response", {})
|
|
118
|
+
except requests.exceptions.ConnectionError:
|
|
119
|
+
logger.exception("Unable to connect to central server at %s", url)
|
|
120
|
+
except requests.exceptions.Timeout:
|
|
121
|
+
logger.exception("Timeout calling central server %s", url)
|
|
122
|
+
except (JSONDecodeError, KeyError, AttributeError):
|
|
123
|
+
logger.exception("Central server returned unexpected format for %s", url)
|
|
124
|
+
except requests.exceptions.HTTPError:
|
|
125
|
+
logger.exception("HTTP error from central server %s", url)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _safe_central_post(
|
|
130
|
+
url: str,
|
|
131
|
+
session: requests.Session,
|
|
132
|
+
headers: dict[str, str],
|
|
133
|
+
json_body: dict | list | None = None,
|
|
134
|
+
params: dict | None = None,
|
|
135
|
+
timeout: float | tuple[float, float] = 5.0,
|
|
136
|
+
) -> dict | None:
|
|
137
|
+
"""Make a POST request to the central server with standardized error handling.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
url: The full URL to request.
|
|
141
|
+
session: A cached ``requests.Session`` from ``_get_session()``.
|
|
142
|
+
headers: Request headers dict from ``_connect_to_central_server()``.
|
|
143
|
+
json_body: JSON payload to POST, or ``None``.
|
|
144
|
+
params: Query parameters dict, or ``None``.
|
|
145
|
+
timeout: HTTP timeout in seconds or (connect, read) tuple. Defaults to 5.0.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The ``api_response`` dict from the response body on success, or ``None``
|
|
149
|
+
on any connection, parse, or HTTP error.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
rsp = session.post(url, json=json_body, params=params, headers=headers, timeout=timeout)
|
|
153
|
+
rsp.raise_for_status()
|
|
154
|
+
return rsp.json().get("api_response", {})
|
|
155
|
+
except requests.exceptions.ConnectionError:
|
|
156
|
+
logger.exception("Unable to connect to central server at %s", url)
|
|
157
|
+
except requests.exceptions.Timeout:
|
|
158
|
+
logger.exception("Timeout calling central server %s", url)
|
|
159
|
+
except (JSONDecodeError, KeyError, AttributeError):
|
|
160
|
+
logger.exception("Central server returned unexpected format for %s", url)
|
|
161
|
+
except requests.exceptions.HTTPError:
|
|
162
|
+
logger.exception("HTTP error from central server %s", url)
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_sources() -> dict[str, list[str]]:
|
|
167
|
+
"""Return the types supported by each plugin visible to the current user.
|
|
168
|
+
|
|
169
|
+
Calls ``GET /api/v1/lookup/types/`` on the central server. Results are
|
|
170
|
+
filtered server-side by the user's classification — no local filtering is
|
|
171
|
+
performed here.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A dict mapping each source name to its list of supported type names,
|
|
175
|
+
e.g. ``{"geoip": ["ipv4", "ipv6"], "vt": ["ipv4", "domain", ...]}``.
|
|
176
|
+
Returns an empty dict on any connection or parse error.
|
|
177
|
+
"""
|
|
178
|
+
session, headers = _connect_to_central_server()
|
|
179
|
+
url = urljoin(CENTRAL_SERVER_URL, "/api/v1/lookup/types/")
|
|
180
|
+
result = _safe_central_get(url, session, headers)
|
|
181
|
+
return result if result is not None else {}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def enrich(
|
|
185
|
+
sources: str | list[str],
|
|
186
|
+
selectors: Selector | list[Selector],
|
|
187
|
+
*,
|
|
188
|
+
limit: int = 10,
|
|
189
|
+
timeout: float = 5.0,
|
|
190
|
+
no_annotation: bool = False,
|
|
191
|
+
include_raw: bool = False,
|
|
192
|
+
no_cache: bool = False,
|
|
193
|
+
) -> dict[str, dict[str, dict[str, BulkEntry]]]:
|
|
194
|
+
"""Run bulk enrichment on one or more selectors via the central API.
|
|
195
|
+
|
|
196
|
+
Plugins can call this helper to query other plugins before or during their
|
|
197
|
+
own enrichment — for example, invoking a geolocation plugin on an IP address
|
|
198
|
+
and using the result to produce a richer annotation.
|
|
199
|
+
|
|
200
|
+
A single :class:`~clue.models.selector.Selector` or source string is
|
|
201
|
+
automatically promoted to a list.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
sources: Plugin name(s) to target. Pass an empty list to query all sources
|
|
205
|
+
available to the current user.
|
|
206
|
+
selectors: One or more :class:`~clue.models.selector.Selector` objects.
|
|
207
|
+
limit: Maximum result items returned per source per selector. Defaults to 10.
|
|
208
|
+
timeout: HTTP timeout in seconds. Defaults to 5.0.
|
|
209
|
+
no_annotation: Omit annotation data from results. Defaults to ``False``.
|
|
210
|
+
include_raw: Include raw plugin data in results. Defaults to ``False``.
|
|
211
|
+
no_cache: Bypass the cache and re-query plugins directly. Defaults to ``False``.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
A three-level dict ``{type: {value: {source: BulkEntry}}}`` mirroring the
|
|
215
|
+
structure returned by the central ``/api/v1/lookup/enrich`` endpoint.
|
|
216
|
+
Returns an empty dict on connection or parse errors.
|
|
217
|
+
"""
|
|
218
|
+
if isinstance(sources, str):
|
|
219
|
+
sources = [sources]
|
|
220
|
+
if isinstance(selectors, Selector):
|
|
221
|
+
selectors = [selectors]
|
|
222
|
+
|
|
223
|
+
session, headers = _connect_to_central_server()
|
|
224
|
+
url = urljoin(CENTRAL_SERVER_URL, "/api/v1/lookup/enrich")
|
|
225
|
+
|
|
226
|
+
params: dict[str, str | int | float | bool] = {
|
|
227
|
+
"limit": limit,
|
|
228
|
+
"max_timeout": max(timeout * 0.95, 0.5),
|
|
229
|
+
}
|
|
230
|
+
if sources:
|
|
231
|
+
params["sources"] = "|".join(sources)
|
|
232
|
+
if no_annotation:
|
|
233
|
+
params["no_annotation"] = True
|
|
234
|
+
if include_raw:
|
|
235
|
+
params["include_raw"] = True
|
|
236
|
+
if no_cache:
|
|
237
|
+
params["no_cache"] = True
|
|
238
|
+
|
|
239
|
+
payload = [s.model_dump(exclude_none=True, exclude_unset=True) for s in selectors]
|
|
240
|
+
result: dict[str, dict[str, dict[str, BulkEntry]]] = {}
|
|
241
|
+
|
|
242
|
+
api_response = _safe_central_post(
|
|
243
|
+
url,
|
|
244
|
+
session,
|
|
245
|
+
headers,
|
|
246
|
+
json_body=payload,
|
|
247
|
+
params=params,
|
|
248
|
+
timeout=(timeout, timeout * 3),
|
|
249
|
+
)
|
|
250
|
+
if api_response is None:
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
_parse_enrich_response(api_response, result)
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _parse_enrich_response(
|
|
258
|
+
api_response: dict,
|
|
259
|
+
result: dict[str, dict[str, dict[str, BulkEntry]]],
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Parse the central-API bulk-enrich response into the *result* dict in-place.
|
|
262
|
+
|
|
263
|
+
Central response shape: ``{type: {value: {source: QueryResult}}}``.
|
|
264
|
+
"""
|
|
265
|
+
for type_name, values in api_response.items():
|
|
266
|
+
result.setdefault(type_name, {})
|
|
267
|
+
for value, sources_data in values.items():
|
|
268
|
+
result[type_name].setdefault(value, {})
|
|
269
|
+
for source_name, query_result in sources_data.items():
|
|
270
|
+
try:
|
|
271
|
+
items = [QueryEntry.model_validate(item) for item in (query_result.get("items") or [])]
|
|
272
|
+
result[type_name][value][source_name] = BulkEntry(
|
|
273
|
+
error=query_result.get("error"),
|
|
274
|
+
items=items,
|
|
275
|
+
raw_data=query_result.get("raw_data"),
|
|
276
|
+
)
|
|
277
|
+
except Exception:
|
|
278
|
+
logger.exception("Failed to parse result from source %s for %s/%s", source_name, type_name, value)
|
|
279
|
+
result[type_name][value][source_name] = BulkEntry(
|
|
280
|
+
error=f"Failed to parse response from {source_name}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def list_actions() -> dict[str, ActionSpec]:
|
|
285
|
+
"""List all actions available on the central server for the current user.
|
|
286
|
+
|
|
287
|
+
Calls ``GET /api/v1/actions/`` and returns a dict keyed by
|
|
288
|
+
``"<plugin_id>.<action_id>"``, filtered server-side by user classification.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
A dict of ``{plugin_id.action_id: ActionSpec}``.
|
|
292
|
+
Returns an empty dict on connection or parse errors.
|
|
293
|
+
"""
|
|
294
|
+
session, headers = _connect_to_central_server()
|
|
295
|
+
url = urljoin(CENTRAL_SERVER_URL, "/api/v1/actions/")
|
|
296
|
+
api_response = _safe_central_get(url, session, headers)
|
|
297
|
+
if api_response is None:
|
|
298
|
+
return {}
|
|
299
|
+
try:
|
|
300
|
+
return TypeAdapter(dict[str, ActionSpec]).validate_python(api_response)
|
|
301
|
+
except ValidationError:
|
|
302
|
+
logger.exception("Failed to validate actions response")
|
|
303
|
+
return {}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def execute_action(
|
|
307
|
+
plugin_id: str,
|
|
308
|
+
action_id: str,
|
|
309
|
+
payload: dict | None = None,
|
|
310
|
+
*,
|
|
311
|
+
timeout: float = 30.0,
|
|
312
|
+
) -> ActionResult:
|
|
313
|
+
"""Execute an action on another plugin via the central server.
|
|
314
|
+
|
|
315
|
+
Calls ``POST /api/v1/actions/execute/{plugin_id}/{action_id}``.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
plugin_id: ID of the plugin that owns the action (e.g. ``"geoip"``).
|
|
319
|
+
action_id: ID of the action to execute (e.g. ``"locate"``).
|
|
320
|
+
payload: Optional JSON-serialisable dict passed as the request body.
|
|
321
|
+
Typically contains a ``selector`` dict and any action-specific parameters.
|
|
322
|
+
timeout: HTTP timeout in seconds. Defaults to 30.0.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
:class:`~clue.models.actions.ActionResult` from the central server.
|
|
326
|
+
Returns a ``failure`` result on connection or parse errors.
|
|
327
|
+
"""
|
|
328
|
+
# Retrying a POST action execution can unintentionally execute the action multiple times.
|
|
329
|
+
session, headers = _connect_to_central_server(retries=0)
|
|
330
|
+
url = urljoin(CENTRAL_SERVER_URL, f"/api/v1/actions/execute/{plugin_id}/{action_id}")
|
|
331
|
+
api_response = _safe_central_post(
|
|
332
|
+
url,
|
|
333
|
+
session,
|
|
334
|
+
headers,
|
|
335
|
+
json_body=payload or {},
|
|
336
|
+
timeout=timeout,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if api_response is None:
|
|
340
|
+
return ActionResult(
|
|
341
|
+
outcome="failure",
|
|
342
|
+
summary=f"Unable to connect to central server to execute {plugin_id}.{action_id}.",
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
return ActionResult.model_validate(api_response, context={"is_response": True})
|
|
347
|
+
except ValidationError:
|
|
348
|
+
logger.exception("Failed to validate action result for %s.%s", plugin_id, action_id)
|
|
349
|
+
return ActionResult(
|
|
350
|
+
outcome="failure",
|
|
351
|
+
summary=f"Unexpected response format from central server for {plugin_id}.{action_id}.",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def list_fetchers() -> dict[str, FetcherDefinition]:
|
|
356
|
+
"""List all fetchers available on the central server for the current user.
|
|
357
|
+
|
|
358
|
+
Calls ``GET /api/v1/fetchers/`` and returns a dict keyed by
|
|
359
|
+
``"<plugin_id>.<fetcher_id>"``, filtered server-side by user classification.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
A dict of ``{plugin_id.fetcher_id: FetcherDefinition}``.
|
|
363
|
+
Returns an empty dict on connection or parse errors.
|
|
364
|
+
"""
|
|
365
|
+
session, headers = _connect_to_central_server()
|
|
366
|
+
url = urljoin(CENTRAL_SERVER_URL, "/api/v1/fetchers/")
|
|
367
|
+
api_response = _safe_central_get(url, session, headers)
|
|
368
|
+
if api_response is None:
|
|
369
|
+
return {}
|
|
370
|
+
try:
|
|
371
|
+
return TypeAdapter(dict[str, FetcherDefinition]).validate_python(api_response)
|
|
372
|
+
except ValidationError:
|
|
373
|
+
logger.exception("Failed to validate fetchers response")
|
|
374
|
+
return {}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def run_fetcher(
|
|
378
|
+
plugin_id: str,
|
|
379
|
+
fetcher_id: str,
|
|
380
|
+
selector: Selector,
|
|
381
|
+
*,
|
|
382
|
+
timeout: float = 60.0,
|
|
383
|
+
) -> FetcherResult:
|
|
384
|
+
"""Execute a fetcher on another plugin via the central server.
|
|
385
|
+
|
|
386
|
+
Calls ``POST /api/v1/fetchers/{plugin_id}/{fetcher_id}`` with the provided
|
|
387
|
+
:class:`~clue.models.selector.Selector` as the JSON body.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
plugin_id: ID of the plugin that owns the fetcher (e.g. ``"geoip"``).
|
|
391
|
+
fetcher_id: ID of the fetcher to run (e.g. ``"location_report"``).
|
|
392
|
+
selector: The :class:`~clue.models.selector.Selector` to enrich.
|
|
393
|
+
timeout: HTTP timeout in seconds. Defaults to 60.0.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
:class:`~clue.models.fetchers.FetcherResult` from the central server.
|
|
397
|
+
Returns a ``failure`` result on connection or parse errors.
|
|
398
|
+
"""
|
|
399
|
+
session, headers = _connect_to_central_server()
|
|
400
|
+
url = urljoin(CENTRAL_SERVER_URL, f"/api/v1/fetchers/{plugin_id}/{fetcher_id}")
|
|
401
|
+
payload = selector.model_dump(exclude_none=True, exclude_unset=True)
|
|
402
|
+
api_response = _safe_central_post(
|
|
403
|
+
url,
|
|
404
|
+
session,
|
|
405
|
+
headers,
|
|
406
|
+
json_body=payload,
|
|
407
|
+
timeout=timeout,
|
|
408
|
+
)
|
|
409
|
+
if api_response is None:
|
|
410
|
+
return FetcherResult.error_result(f"Unable to connect to central server to run {plugin_id}.{fetcher_id}.")
|
|
411
|
+
try:
|
|
412
|
+
return FetcherResult.model_validate(api_response, context={"is_response": True})
|
|
413
|
+
except ValidationError:
|
|
414
|
+
logger.exception("Failed to validate fetcher result for %s.%s", plugin_id, fetcher_id)
|
|
415
|
+
return FetcherResult.error_result(
|
|
416
|
+
f"Unexpected response format from central server for {plugin_id}.{fetcher_id}."
|
|
417
|
+
)
|
|
@@ -137,7 +137,7 @@ def all_supported_types(user: dict[str, Any], access_token: str | None = None) -
|
|
|
137
137
|
return all_types
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
def get_plugins_supported_types(user: dict[str, Any]):
|
|
140
|
+
def get_plugins_supported_types(user: dict[str, Any]) -> dict[str, list[str]]:
|
|
141
141
|
"""Return the supported type names of each external service, filtered to what the user has access to."""
|
|
142
142
|
configured_sources: list[ExternalSource] = getattr(config.api, "external_sources", [])
|
|
143
143
|
available_types: dict[str, list[str]] = {}
|
|
@@ -141,7 +141,7 @@ log_cli_level = "WARN"
|
|
|
141
141
|
[tool.poetry]
|
|
142
142
|
package-mode = true
|
|
143
143
|
name = "clue-api"
|
|
144
|
-
version = "1.5.0.
|
|
144
|
+
version = "1.5.0.dev295"
|
|
145
145
|
description = "Clue distributed enrichment service"
|
|
146
146
|
authors = ["Canadian Centre for Cyber Security <contact@cyber.gc.ca>"]
|
|
147
147
|
license = "MIT"
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# import os
|
|
2
|
-
|
|
3
|
-
# from clue_client import get_client
|
|
4
|
-
# from clue_client.api.v1 import V1
|
|
5
|
-
# from flask import request
|
|
6
|
-
|
|
7
|
-
# from clue.common.logging import get_logger
|
|
8
|
-
|
|
9
|
-
# CENTRAL_SERVER_URL = os.getenv("CENTRAL_API_URL", "http://enrichment-rest.enrichment.svc.cluster.local:5000")
|
|
10
|
-
|
|
11
|
-
# logger = get_logger(__file__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# def connect_to_central_server(timeout: int | None = 3, retries: int = 3) -> V1:
|
|
15
|
-
# "Connect to the central server using the clue client"
|
|
16
|
-
# access_token = request.headers.get("X-Clue-Authorization", None)
|
|
17
|
-
|
|
18
|
-
# if access_token:
|
|
19
|
-
# logger.info("X-Clue-Authorization header specified, using pre-OBO token")
|
|
20
|
-
# else:
|
|
21
|
-
# logger.warning("X-Clue-Authorization header not specified, falling back to core Authorization Header")
|
|
22
|
-
# access_token = request.headers.get("Authorization", " ", type=str).split(" ")[1]
|
|
23
|
-
|
|
24
|
-
# if not access_token:
|
|
25
|
-
# logger.warning("No token specified, continuing with no authentication")
|
|
26
|
-
|
|
27
|
-
# return get_client(CENTRAL_SERVER_URL, auth=access_token, version=1, timeout=timeout, retries=retries, logger=logger)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|