osintengine 1.0.2__py3-none-any.whl
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.
- osintengine-1.0.2.dist-info/METADATA +311 -0
- osintengine-1.0.2.dist-info/RECORD +103 -0
- osintengine-1.0.2.dist-info/WHEEL +5 -0
- osintengine-1.0.2.dist-info/entry_points.txt +2 -0
- osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
- osintengine-1.0.2.dist-info/top_level.txt +2 -0
- src/watson/__init__.py +10 -0
- src/watson/agent/__init__.py +96 -0
- src/watson/agents/__init__.py +101 -0
- src/watson/agents/protocol.py +196 -0
- src/watson/auth/__init__.py +68 -0
- src/watson/auth/store.py +145 -0
- src/watson/conversation.py +68 -0
- src/watson/core/__init__.py +0 -0
- src/watson/core/models.py +96 -0
- src/watson/ethics.py +205 -0
- src/watson/exports.py +182 -0
- src/watson/graph/__init__.py +69 -0
- src/watson/graph/entities.py +207 -0
- src/watson/graph/graph.py +489 -0
- src/watson/graph/osint_framework.py +266 -0
- src/watson/graph/relationships.py +116 -0
- src/watson/graph/transforms.py +787 -0
- src/watson/infra/__init__.py +43 -0
- src/watson/infra/cache.py +171 -0
- src/watson/infra/ratelimit.py +125 -0
- src/watson/infra/resilience.py +239 -0
- src/watson/infra/retry.py +186 -0
- src/watson/metrics.py +128 -0
- src/watson/opsec/__init__.py +290 -0
- src/watson/orchestration/__init__.py +23 -0
- src/watson/orchestration/engine.py +5587 -0
- src/watson/orchestration/executor.py +96 -0
- src/watson/orchestration/intent.py +139 -0
- src/watson/orchestration/intent_classifier.py +45 -0
- src/watson/orchestration/llm_config.py +160 -0
- src/watson/orchestration/resolution.py +555 -0
- src/watson/orchestration/scraper.py +94 -0
- src/watson/orchestration/synthesis.py +726 -0
- src/watson/orchestration/target_profile.py +748 -0
- src/watson/persistence/__init__.py +18 -0
- src/watson/persistence/models.py +161 -0
- src/watson/persistence/store.py +230 -0
- src/watson/pipeline/__init__.py +16 -0
- src/watson/pipeline/pre_synthesis.py +621 -0
- src/watson/search.py +103 -0
- src/watson/serializers/stix.py +451 -0
- src/watson/tools/__init__.py +31 -0
- src/watson/tools/base.py +97 -0
- src/watson/tools/blockchain.py +359 -0
- src/watson/tools/captcha.py +276 -0
- src/watson/tools/conflict.py +107 -0
- src/watson/tools/corporate.py +287 -0
- src/watson/tools/darkweb.py +144 -0
- src/watson/tools/geolocation.py +347 -0
- src/watson/tools/image_video.py +108 -0
- src/watson/tools/marinetraffic.py +142 -0
- src/watson/tools/people.py +476 -0
- src/watson/tools/registry.py +56 -0
- src/watson/tools/satellite.py +118 -0
- src/watson/tools/scraper.py +745 -0
- src/watson/tools/shodan.py +129 -0
- src/watson/tools/social_media.py +160 -0
- src/watson/tools/websites.py +291 -0
- src/watson/tools/wikidata.py +398 -0
- src/watson/utils/__init__.py +1 -0
- src/watson/utils/helpers.py +49 -0
- src/watson/utils/http.py +119 -0
- src/watson/verification/__init__.py +245 -0
- watson/__init__.py +6 -0
- watson/agents/__init__.py +20 -0
- watson/agents/base.py +103 -0
- watson/agents/direct.py +225 -0
- watson/agents/hermes.py +275 -0
- watson/agents/hermes_mcp.py +292 -0
- watson/api_keys.py +176 -0
- watson/browser_scraper.py +481 -0
- watson/cli.py +836 -0
- watson/crossref.py +175 -0
- watson/ethics.py +20 -0
- watson/graph.py +406 -0
- watson/mcp_server.py +601 -0
- watson/memory.py +552 -0
- watson/neo4j_graph.py +124 -0
- watson/opsec/__init__.py +307 -0
- watson/reporter.py +537 -0
- watson/scheduler.py +486 -0
- watson/serializers/stix.py +451 -0
- watson/terminal.py +410 -0
- watson/toolkit.py +252 -0
- watson/toolkit_api.py +347 -0
- watson/toolkit_automation.py +95 -0
- watson/toolkit_registry.py +345 -0
- watson/verification/__init__.py +251 -0
- watson/web/__init__.py +1 -0
- watson/web/app.py +1650 -0
- watson/web/middleware.py +301 -0
- watson/web/static/assets/index-B7hPOc0z.js +278 -0
- watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
- watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
- watson/web/static/index.html +14 -0
- watson/web/templates/chat.html +2 -0
- watson/web/templates/investigation-map.html +429 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Infrastructure utilities — caching, retry, circuit breakers, rate limiting, resilience."""
|
|
2
|
+
|
|
3
|
+
from .retry import (
|
|
4
|
+
retry,
|
|
5
|
+
CircuitBreaker,
|
|
6
|
+
CircuitOpenError,
|
|
7
|
+
get_circuit,
|
|
8
|
+
_is_retryable,
|
|
9
|
+
)
|
|
10
|
+
from .ratelimit import (
|
|
11
|
+
TokenBucket,
|
|
12
|
+
get_bucket,
|
|
13
|
+
RateLimitExceeded,
|
|
14
|
+
)
|
|
15
|
+
from .cache import (
|
|
16
|
+
TTLCache,
|
|
17
|
+
get_cache,
|
|
18
|
+
)
|
|
19
|
+
from .resilience import (
|
|
20
|
+
FailureReason,
|
|
21
|
+
ToolResult,
|
|
22
|
+
safe_execute,
|
|
23
|
+
classify_failure,
|
|
24
|
+
suggest_alternatives,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"retry",
|
|
29
|
+
"CircuitBreaker",
|
|
30
|
+
"CircuitOpenError",
|
|
31
|
+
"get_circuit",
|
|
32
|
+
"_is_retryable",
|
|
33
|
+
"TokenBucket",
|
|
34
|
+
"get_bucket",
|
|
35
|
+
"RateLimitExceeded",
|
|
36
|
+
"TTLCache",
|
|
37
|
+
"get_cache",
|
|
38
|
+
"FailureReason",
|
|
39
|
+
"ToolResult",
|
|
40
|
+
"safe_execute",
|
|
41
|
+
"classify_failure",
|
|
42
|
+
"suggest_alternatives",
|
|
43
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""TTL cache with size-bounded LRU eviction and stats.
|
|
2
|
+
|
|
3
|
+
Also exposes a small registry/helpers for aggregating cache statistics.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Entry:
|
|
14
|
+
__slots__ = ("value", "expiry", "hits", "inserted_at")
|
|
15
|
+
|
|
16
|
+
def __init__(self, value: Any, expiry: float, now: float):
|
|
17
|
+
self.value = value
|
|
18
|
+
self.expiry = expiry
|
|
19
|
+
self.hits = 0
|
|
20
|
+
self.inserted_at = now
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TTLCache:
|
|
24
|
+
"""In-memory TTL cache with max-size eviction.
|
|
25
|
+
|
|
26
|
+
Note: :meth:`set` takes ``(value, key)`` — value first, key second —
|
|
27
|
+
matching the project's OSINT-collection ergonomics.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, name: str, ttl: float = 60.0, max_size: int = 1000):
|
|
31
|
+
self.name = name
|
|
32
|
+
self.ttl = float(ttl)
|
|
33
|
+
self.max_size = int(max_size)
|
|
34
|
+
self._data: Dict[str, _Entry] = {}
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
self.stats: Dict[str, Any] = {
|
|
37
|
+
"hits": 0,
|
|
38
|
+
"misses": 0,
|
|
39
|
+
"evictions": 0,
|
|
40
|
+
"size": 0,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# -- core operations ------------------------------------------
|
|
44
|
+
def set(self, value: Any, key: str) -> None:
|
|
45
|
+
"""Store *value* under *key* with the configured TTL."""
|
|
46
|
+
now = time.monotonic()
|
|
47
|
+
with self._lock:
|
|
48
|
+
if key in self._data:
|
|
49
|
+
# Update in place, keep hit count
|
|
50
|
+
entry = self._data[key]
|
|
51
|
+
entry.value = value
|
|
52
|
+
entry.expiry = now + self.ttl
|
|
53
|
+
else:
|
|
54
|
+
self._data[key] = _Entry(value, now + self.ttl, now)
|
|
55
|
+
if len(self._data) > self.max_size:
|
|
56
|
+
self._evict()
|
|
57
|
+
self.stats["size"] = len(self._data)
|
|
58
|
+
|
|
59
|
+
def get(self, key: str) -> Any:
|
|
60
|
+
"""Return the value for *key* or ``None`` on miss/expiry."""
|
|
61
|
+
now = time.monotonic()
|
|
62
|
+
with self._lock:
|
|
63
|
+
entry = self._data.get(key)
|
|
64
|
+
if entry is None:
|
|
65
|
+
self.stats["misses"] += 1
|
|
66
|
+
return None
|
|
67
|
+
if now >= entry.expiry:
|
|
68
|
+
# Expired — remove and count as miss
|
|
69
|
+
del self._data[key]
|
|
70
|
+
self.stats["misses"] += 1
|
|
71
|
+
self.stats["size"] = len(self._data)
|
|
72
|
+
return None
|
|
73
|
+
entry.hits += 1
|
|
74
|
+
self.stats["hits"] += 1
|
|
75
|
+
return entry.value
|
|
76
|
+
|
|
77
|
+
def invalidate(self, key: str) -> None:
|
|
78
|
+
"""Remove *key* from the cache if present."""
|
|
79
|
+
with self._lock:
|
|
80
|
+
if key in self._data:
|
|
81
|
+
del self._data[key]
|
|
82
|
+
self.stats["size"] = len(self._data)
|
|
83
|
+
|
|
84
|
+
def clear(self) -> None:
|
|
85
|
+
with self._lock:
|
|
86
|
+
self._data.clear()
|
|
87
|
+
self.stats["size"] = 0
|
|
88
|
+
|
|
89
|
+
# -- eviction --------------------------------------------------
|
|
90
|
+
def _evict(self) -> None:
|
|
91
|
+
"""Evict the least-hit (oldest on tie) entry. Caller holds lock."""
|
|
92
|
+
if not self._data:
|
|
93
|
+
return
|
|
94
|
+
# Find entry with fewest hits; ties broken by insertion order (oldest first)
|
|
95
|
+
victim_key = None
|
|
96
|
+
victim_hits = None
|
|
97
|
+
for k, entry in self._data.items():
|
|
98
|
+
if victim_key is None or entry.hits < victim_hits:
|
|
99
|
+
victim_key = k
|
|
100
|
+
victim_hits = entry.hits
|
|
101
|
+
if victim_key is not None:
|
|
102
|
+
del self._data[victim_key]
|
|
103
|
+
self.stats["evictions"] += 1
|
|
104
|
+
|
|
105
|
+
# -- derived metrics ------------------------------------------
|
|
106
|
+
@property
|
|
107
|
+
def hit_rate(self) -> float:
|
|
108
|
+
total = self.stats["hits"] + self.stats["misses"]
|
|
109
|
+
if total == 0:
|
|
110
|
+
return 0.0
|
|
111
|
+
return self.stats["hits"] / total
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ─────────────────────────────────────────────────────────────────
|
|
115
|
+
# Pre-configured caches
|
|
116
|
+
# ─────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
_PRECONFIGURED: Dict[str, Dict[str, Any]] = {
|
|
119
|
+
"dns": {"ttl": 300, "max_size": 500},
|
|
120
|
+
"whois": {"ttl": 3600, "max_size": 500},
|
|
121
|
+
"cert": {"ttl": 600, "max_size": 1000},
|
|
122
|
+
"default": {"ttl": 60, "max_size": 1000},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_caches: Dict[str, TTLCache] = {}
|
|
126
|
+
_caches_lock = threading.Lock()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_cache(name: str) -> TTLCache:
|
|
130
|
+
"""Get-or-create a singleton :class:`TTLCache` for *name*."""
|
|
131
|
+
with _caches_lock:
|
|
132
|
+
c = _caches.get(name)
|
|
133
|
+
if c is None:
|
|
134
|
+
cfg = _PRECONFIGURED.get(name, _PRECONFIGURED["default"])
|
|
135
|
+
c = TTLCache(
|
|
136
|
+
name=name,
|
|
137
|
+
ttl=cfg.get("ttl", 60),
|
|
138
|
+
max_size=cfg.get("max_size", 1000),
|
|
139
|
+
)
|
|
140
|
+
_caches[name] = c
|
|
141
|
+
return c
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ─────────────────────────────────────────────────────────────────
|
|
145
|
+
# Stats aggregation (legacy helpers)
|
|
146
|
+
# ─────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
_cache_registry: Dict[str, Any] = {}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def register_cache(name: str, cache_obj: Any) -> None:
|
|
152
|
+
"""Register a cache for stats collection."""
|
|
153
|
+
_cache_registry[name] = cache_obj
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def all_cache_stats() -> Dict[str, Dict[str, Any]]:
|
|
157
|
+
"""Return stats for all registered caches."""
|
|
158
|
+
stats: Dict[str, Dict[str, Any]] = {}
|
|
159
|
+
for name, cache in _cache_registry.items():
|
|
160
|
+
s = getattr(cache, "stats", None)
|
|
161
|
+
if isinstance(s, dict):
|
|
162
|
+
stats[name] = dict(s)
|
|
163
|
+
elif callable(s):
|
|
164
|
+
stats[name] = s()
|
|
165
|
+
elif hasattr(cache, "get_stats"):
|
|
166
|
+
stats[name] = cache.get_stats()
|
|
167
|
+
else:
|
|
168
|
+
stats[name] = {"size": 0, "hits": 0, "misses": 0}
|
|
169
|
+
if not stats:
|
|
170
|
+
stats["default"] = {"size": 0, "hits": 0, "misses": 0}
|
|
171
|
+
return stats
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Token-bucket rate limiting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RateLimitExceeded(Exception):
|
|
12
|
+
"""Raised when a rate-limited operation cannot acquire a token in time."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, name: str = "", message: str = ""):
|
|
15
|
+
self.name = name
|
|
16
|
+
super().__init__(message or f"rate limit exceeded for '{name}'")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TokenBucket:
|
|
20
|
+
"""Classic token-bucket rate limiter.
|
|
21
|
+
|
|
22
|
+
Tokens accumulate at *refill_rate* per second up to *capacity*. Each
|
|
23
|
+
:meth:`acquire` consumes one token. ``acquire`` blocks up to *timeout*
|
|
24
|
+
seconds waiting for a token to become available.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
name: str = "",
|
|
30
|
+
capacity: float = 5,
|
|
31
|
+
refill_rate: float = 1.0,
|
|
32
|
+
):
|
|
33
|
+
self.name = name
|
|
34
|
+
self.capacity = float(capacity)
|
|
35
|
+
self.refill_rate = float(refill_rate)
|
|
36
|
+
self._tokens = float(capacity)
|
|
37
|
+
self._last_refill = time.monotonic()
|
|
38
|
+
self._lock = threading.Lock()
|
|
39
|
+
|
|
40
|
+
# -- internals -------------------------------------------------
|
|
41
|
+
def _refill(self) -> None:
|
|
42
|
+
"""Add tokens based on elapsed wall-clock time. Caller holds lock."""
|
|
43
|
+
now = time.monotonic()
|
|
44
|
+
elapsed = now - self._last_refill
|
|
45
|
+
if elapsed > 0 and self.refill_rate > 0:
|
|
46
|
+
self._tokens = min(self.capacity, self._tokens + elapsed * self.refill_rate)
|
|
47
|
+
self._last_refill = now
|
|
48
|
+
|
|
49
|
+
def _try_consume(self) -> bool:
|
|
50
|
+
"""Non-blocking consume. Caller must hold lock."""
|
|
51
|
+
self._refill()
|
|
52
|
+
if self._tokens >= 1.0:
|
|
53
|
+
self._tokens -= 1.0
|
|
54
|
+
return True
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
# -- public API ------------------------------------------------
|
|
58
|
+
def acquire(self, timeout: float = 0.0) -> bool:
|
|
59
|
+
"""Block up to *timeout* seconds for a token. Return True if acquired.
|
|
60
|
+
|
|
61
|
+
A *timeout* of 0 means non-blocking: try once and return immediately.
|
|
62
|
+
"""
|
|
63
|
+
deadline = time.monotonic() + timeout
|
|
64
|
+
while True:
|
|
65
|
+
with self._lock:
|
|
66
|
+
if self._try_consume():
|
|
67
|
+
return True
|
|
68
|
+
if time.monotonic() >= deadline:
|
|
69
|
+
return False
|
|
70
|
+
# Sleep a short slice before retrying so we don't busy-spin hard.
|
|
71
|
+
remaining = deadline - time.monotonic()
|
|
72
|
+
time.sleep(max(0.0, min(0.005, remaining)))
|
|
73
|
+
|
|
74
|
+
async def async_acquire(self, timeout: float = 0.0) -> bool:
|
|
75
|
+
"""Async variant of :meth:`acquire`."""
|
|
76
|
+
deadline = time.monotonic() + timeout
|
|
77
|
+
while True:
|
|
78
|
+
with self._lock:
|
|
79
|
+
if self._try_consume():
|
|
80
|
+
return True
|
|
81
|
+
if time.monotonic() >= deadline:
|
|
82
|
+
return False
|
|
83
|
+
remaining = deadline - time.monotonic()
|
|
84
|
+
await asyncio.sleep(max(0.0, min(0.005, remaining)))
|
|
85
|
+
|
|
86
|
+
# -- context manager ------------------------------------------
|
|
87
|
+
def __enter__(self) -> "TokenBucket":
|
|
88
|
+
if not self.acquire(timeout=0.0):
|
|
89
|
+
raise RateLimitExceeded(self.name)
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
93
|
+
return False # never suppress
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ─────────────────────────────────────────────────────────────────
|
|
97
|
+
# Pre-configured buckets
|
|
98
|
+
# ─────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
_PRECONFIGURED: Dict[str, Dict[str, Any]] = {
|
|
101
|
+
"crt.sh": {"capacity": 3, "refill_rate": 1.0},
|
|
102
|
+
"virustotal": {"capacity": 4, "refill_rate": 1.0},
|
|
103
|
+
"shodan": {"capacity": 1, "refill_rate": 1.0},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_DEFAULT_CAPACITY = 5
|
|
107
|
+
_DEFAULT_REFILL_RATE = 1.0
|
|
108
|
+
|
|
109
|
+
_buckets: Dict[str, TokenBucket] = {}
|
|
110
|
+
_buckets_lock = threading.Lock()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_bucket(name: str) -> TokenBucket:
|
|
114
|
+
"""Get-or-create a singleton :class:`TokenBucket` for *name*."""
|
|
115
|
+
with _buckets_lock:
|
|
116
|
+
b = _buckets.get(name)
|
|
117
|
+
if b is None:
|
|
118
|
+
cfg = _PRECONFIGURED.get(name, {})
|
|
119
|
+
b = TokenBucket(
|
|
120
|
+
name=name,
|
|
121
|
+
capacity=cfg.get("capacity", _DEFAULT_CAPACITY),
|
|
122
|
+
refill_rate=cfg.get("refill_rate", _DEFAULT_REFILL_RATE),
|
|
123
|
+
)
|
|
124
|
+
_buckets[name] = b
|
|
125
|
+
return b
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Resilience layer — failure classification, safe execution, alternatives.
|
|
2
|
+
|
|
3
|
+
Wraps tool invocations so the orchestrator gets a uniform :class:`ToolResult`
|
|
4
|
+
regardless of whether a tool succeeded, returned nothing, or raised.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Callable, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FailureReason(enum.Enum):
|
|
15
|
+
"""Why a tool invocation failed (or yielded no usable data)."""
|
|
16
|
+
|
|
17
|
+
NO_RESULTS = "no_results"
|
|
18
|
+
TIMEOUT = "timeout"
|
|
19
|
+
CLOUDFLARE_PROTECTED = "cloudflare_protected"
|
|
20
|
+
RATE_LIMITED = "rate_limited"
|
|
21
|
+
WHOIS_REDACTED = "whois_redacted"
|
|
22
|
+
BLOCKED = "blocked"
|
|
23
|
+
UNKNOWN = "unknown"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Reasons that indicate the target is actively blocking us — these are
|
|
27
|
+
# intelligence signals, not mere failures.
|
|
28
|
+
_BLOCK_REASONS = {
|
|
29
|
+
FailureReason.CLOUDFLARE_PROTECTED,
|
|
30
|
+
FailureReason.RATE_LIMITED,
|
|
31
|
+
FailureReason.WHOIS_REDACTED,
|
|
32
|
+
FailureReason.BLOCKED,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ToolResult:
|
|
37
|
+
"""Uniform result wrapper for tool invocations."""
|
|
38
|
+
|
|
39
|
+
__slots__ = (
|
|
40
|
+
"tool",
|
|
41
|
+
"success",
|
|
42
|
+
"data",
|
|
43
|
+
"failure_reason",
|
|
44
|
+
"latency_ms",
|
|
45
|
+
"retry_count",
|
|
46
|
+
"context",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
success: bool,
|
|
52
|
+
data: Any = None,
|
|
53
|
+
failure_reason: Optional[FailureReason] = None,
|
|
54
|
+
latency_ms: float = 0.0,
|
|
55
|
+
retry_count: int = 0,
|
|
56
|
+
tool: str = "",
|
|
57
|
+
context: Any = None,
|
|
58
|
+
):
|
|
59
|
+
self.tool = tool
|
|
60
|
+
self.success = success
|
|
61
|
+
self.data = data
|
|
62
|
+
self.failure_reason = failure_reason
|
|
63
|
+
self.latency_ms = latency_ms
|
|
64
|
+
self.retry_count = retry_count
|
|
65
|
+
self.context = context
|
|
66
|
+
|
|
67
|
+
# -- derived properties ---------------------------------------
|
|
68
|
+
@property
|
|
69
|
+
def blocked(self) -> bool:
|
|
70
|
+
"""True when the failure reason indicates active blocking."""
|
|
71
|
+
return self.failure_reason in _BLOCK_REASONS
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_intelligence(self) -> bool:
|
|
75
|
+
"""True when the outcome itself is an intelligence signal."""
|
|
76
|
+
return self.failure_reason in _BLOCK_REASONS
|
|
77
|
+
|
|
78
|
+
# -- serialization --------------------------------------------
|
|
79
|
+
def to_dict(self) -> dict:
|
|
80
|
+
return {
|
|
81
|
+
"tool": self.tool,
|
|
82
|
+
"success": self.success,
|
|
83
|
+
"data": self.data,
|
|
84
|
+
"failure_reason": self.failure_reason.value if self.failure_reason else None,
|
|
85
|
+
"latency_ms": self.latency_ms,
|
|
86
|
+
"retry_count": self.retry_count,
|
|
87
|
+
"context": self.context,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
91
|
+
return (
|
|
92
|
+
f"ToolResult(success={self.success}, tool={self.tool!r}, "
|
|
93
|
+
f"failure_reason={self.failure_reason}, latency_ms={self.latency_ms})"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ─────────────────────────────────────────────────────────────────
|
|
98
|
+
# Failure classification
|
|
99
|
+
# ─────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def classify_failure(exc: BaseException) -> FailureReason:
|
|
103
|
+
"""Classify an exception into a :class:`FailureReason`."""
|
|
104
|
+
# 1. Timeout
|
|
105
|
+
if isinstance(exc, TimeoutError):
|
|
106
|
+
return FailureReason.TIMEOUT
|
|
107
|
+
|
|
108
|
+
# 2. HTTP-style rate limiting (objects exposing a status code)
|
|
109
|
+
code = getattr(exc, "code", None)
|
|
110
|
+
if code is None:
|
|
111
|
+
code = getattr(exc, "status_code", None)
|
|
112
|
+
if code == 429:
|
|
113
|
+
return FailureReason.RATE_LIMITED
|
|
114
|
+
|
|
115
|
+
msg = str(exc).lower()
|
|
116
|
+
|
|
117
|
+
# 3. Cloudflare / WAF protection
|
|
118
|
+
if "cloudflare" in msg or "cf-ray" in msg or "attention required" in msg:
|
|
119
|
+
return FailureReason.CLOUDFLARE_PROTECTED
|
|
120
|
+
|
|
121
|
+
# 4. WHOIS privacy / GDPR redaction
|
|
122
|
+
if "redacted" in msg or "gdpr" in msg or "private registration" in msg:
|
|
123
|
+
return FailureReason.WHOIS_REDACTED
|
|
124
|
+
|
|
125
|
+
# 5. Generic network errors → could be transient; classify as unknown
|
|
126
|
+
return FailureReason.UNKNOWN
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ─────────────────────────────────────────────────────────────────
|
|
130
|
+
# Safe execution
|
|
131
|
+
# ─────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def safe_execute(
|
|
135
|
+
tool: str,
|
|
136
|
+
func: Callable[[], Any],
|
|
137
|
+
context: Any = None,
|
|
138
|
+
retry_count: int = 0,
|
|
139
|
+
) -> ToolResult:
|
|
140
|
+
"""Run *func* and return a :class:`ToolResult` capturing the outcome.
|
|
141
|
+
|
|
142
|
+
A return value of ``None`` or an empty collection is treated as
|
|
143
|
+
``NO_RESULTS`` rather than success.
|
|
144
|
+
"""
|
|
145
|
+
start = time.monotonic()
|
|
146
|
+
try:
|
|
147
|
+
data = func()
|
|
148
|
+
except BaseException as exc: # noqa: BLE001
|
|
149
|
+
latency_ms = (time.monotonic() - start) * 1000.0
|
|
150
|
+
reason = classify_failure(exc)
|
|
151
|
+
return ToolResult(
|
|
152
|
+
success=False,
|
|
153
|
+
failure_reason=reason,
|
|
154
|
+
latency_ms=latency_ms,
|
|
155
|
+
retry_count=retry_count,
|
|
156
|
+
tool=tool,
|
|
157
|
+
context=context,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
latency_ms = (time.monotonic() - start) * 1000.0
|
|
161
|
+
|
|
162
|
+
# Treat None / empty containers as "no results"
|
|
163
|
+
if data is None:
|
|
164
|
+
return ToolResult(
|
|
165
|
+
success=False,
|
|
166
|
+
failure_reason=FailureReason.NO_RESULTS,
|
|
167
|
+
latency_ms=latency_ms,
|
|
168
|
+
retry_count=retry_count,
|
|
169
|
+
tool=tool,
|
|
170
|
+
context=context,
|
|
171
|
+
)
|
|
172
|
+
if isinstance(data, (list, tuple, set, dict, str)) and len(data) == 0:
|
|
173
|
+
return ToolResult(
|
|
174
|
+
success=False,
|
|
175
|
+
failure_reason=FailureReason.NO_RESULTS,
|
|
176
|
+
latency_ms=latency_ms,
|
|
177
|
+
retry_count=retry_count,
|
|
178
|
+
tool=tool,
|
|
179
|
+
context=context,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return ToolResult(
|
|
183
|
+
success=True,
|
|
184
|
+
data=data,
|
|
185
|
+
latency_ms=latency_ms,
|
|
186
|
+
retry_count=retry_count,
|
|
187
|
+
tool=tool,
|
|
188
|
+
context=context,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ─────────────────────────────────────────────────────────────────
|
|
193
|
+
# Alternative-source suggestions
|
|
194
|
+
# ─────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
_ALTERNATIVES: dict = {
|
|
197
|
+
FailureReason.WHOIS_REDACTED: [
|
|
198
|
+
"crt.sh certificate transparency search",
|
|
199
|
+
"DNS historical records (SecurityTrails)",
|
|
200
|
+
"Search engine cached WHOIS",
|
|
201
|
+
"Passive DNS replication",
|
|
202
|
+
],
|
|
203
|
+
FailureReason.CLOUDFLARE_PROTECTED: [
|
|
204
|
+
"Historical DNS records (SecurityTrails)",
|
|
205
|
+
"crt.sh certificate transparency search",
|
|
206
|
+
"Search engine cache / archive.org",
|
|
207
|
+
"Shodan / Censys internet scan",
|
|
208
|
+
],
|
|
209
|
+
FailureReason.RATE_LIMITED: [
|
|
210
|
+
"Wait and retry with backoff",
|
|
211
|
+
"Use an alternate data source",
|
|
212
|
+
"Reduce request frequency",
|
|
213
|
+
],
|
|
214
|
+
FailureReason.TIMEOUT: [
|
|
215
|
+
"Retry with longer timeout",
|
|
216
|
+
"Use an alternate data source",
|
|
217
|
+
"crt.sh certificate transparency search",
|
|
218
|
+
],
|
|
219
|
+
FailureReason.NO_RESULTS: [
|
|
220
|
+
"Try alternate query / domain variant",
|
|
221
|
+
"Use an alternate data source",
|
|
222
|
+
"crt.sh certificate transparency search",
|
|
223
|
+
],
|
|
224
|
+
FailureReason.BLOCKED: [
|
|
225
|
+
"Historical DNS records",
|
|
226
|
+
"crt.sh certificate transparency search",
|
|
227
|
+
"archive.org",
|
|
228
|
+
],
|
|
229
|
+
FailureReason.UNKNOWN: [
|
|
230
|
+
"Retry the operation",
|
|
231
|
+
"Use an alternate data source",
|
|
232
|
+
"crt.sh certificate transparency search",
|
|
233
|
+
],
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def suggest_alternatives(reason: FailureReason) -> List[str]:
|
|
238
|
+
"""Return a list of suggested alternative approaches for *reason*."""
|
|
239
|
+
return list(_ALTERNATIVES.get(reason, _ALTERNATIVES[FailureReason.UNKNOWN]))
|