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
src/watson/search.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Search — full-text search across investigations.
|
|
2
|
+
|
|
3
|
+
Delegates to watson.memory for SQLite FTS5 search.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SearchInterface:
|
|
12
|
+
"""Unified search across investigations and findings."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._memory = None
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def memory(self):
|
|
19
|
+
if self._memory is None:
|
|
20
|
+
try:
|
|
21
|
+
from watson.memory import memory as mem
|
|
22
|
+
self._memory = mem
|
|
23
|
+
except ImportError:
|
|
24
|
+
self._memory = None
|
|
25
|
+
return self._memory
|
|
26
|
+
|
|
27
|
+
def search(
|
|
28
|
+
self,
|
|
29
|
+
query: str,
|
|
30
|
+
limit: int = 20,
|
|
31
|
+
agent_filter: str = "",
|
|
32
|
+
tier_filter: str = "",
|
|
33
|
+
) -> List[Dict[str, Any]]:
|
|
34
|
+
"""Full-text search across findings.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
query: Search query.
|
|
38
|
+
limit: Max results.
|
|
39
|
+
agent_filter: Filter by agent role (not currently implemented beyond pass-through).
|
|
40
|
+
tier_filter: Filter by evidence tier.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
List of result dicts.
|
|
44
|
+
"""
|
|
45
|
+
results: List[Dict[str, Any]] = []
|
|
46
|
+
|
|
47
|
+
if self.memory and hasattr(self.memory, "search"):
|
|
48
|
+
try:
|
|
49
|
+
raw = self.memory.search(query, limit=limit)
|
|
50
|
+
for r in raw:
|
|
51
|
+
result = dict(r) if isinstance(r, dict) else {"title": str(r)}
|
|
52
|
+
if tier_filter and result.get("tier", "").lower() != tier_filter.lower():
|
|
53
|
+
continue
|
|
54
|
+
results.append(result)
|
|
55
|
+
return results
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# Fallback: return empty results
|
|
60
|
+
return results
|
|
61
|
+
|
|
62
|
+
def search_investigations(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
|
63
|
+
"""Search investigation-level metadata."""
|
|
64
|
+
if self.memory and hasattr(self.memory, "list_recent"):
|
|
65
|
+
try:
|
|
66
|
+
all_inv = self.memory.list_recent(limit=100)
|
|
67
|
+
matched = []
|
|
68
|
+
q = query.lower()
|
|
69
|
+
for inv in all_inv:
|
|
70
|
+
inv_q = (inv.get("query", "") or "").lower()
|
|
71
|
+
if q in inv_q or q in str(inv).lower():
|
|
72
|
+
matched.append(dict(inv) if isinstance(inv, dict) else {"id": str(inv)})
|
|
73
|
+
if len(matched) >= limit:
|
|
74
|
+
break
|
|
75
|
+
return matched
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
81
|
+
"""Get search index statistics."""
|
|
82
|
+
try:
|
|
83
|
+
if self.memory and hasattr(self.memory, "stats"):
|
|
84
|
+
stats = self.memory.stats()
|
|
85
|
+
return {
|
|
86
|
+
"total_indexed": stats.get("investigations", 0),
|
|
87
|
+
"entities": stats.get("entities", 0),
|
|
88
|
+
"findings": stats.get("findings", 0),
|
|
89
|
+
}
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
return {"total_indexed": 0, "entities": 0, "findings": 0}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Singleton
|
|
96
|
+
_search: Optional[SearchInterface] = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_search() -> SearchInterface:
|
|
100
|
+
global _search
|
|
101
|
+
if _search is None:
|
|
102
|
+
_search = SearchInterface()
|
|
103
|
+
return _search
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""STIX 2.1 Serializer — convert Watson findings to structured threat intelligence.
|
|
2
|
+
|
|
3
|
+
Produces valid STIX 2.1 bundles with:
|
|
4
|
+
- Report → SDO with object_refs
|
|
5
|
+
- Finding → Indicator (pattern-based) + ObservedData
|
|
6
|
+
- Entity → Identity (individual/organization), Location, DomainName, IPv4Addr
|
|
7
|
+
- Relationships between all objects
|
|
8
|
+
- External references for source URLs
|
|
9
|
+
- Confidence mapping: CONFIRMED→85, PROBABLE→65, POSSIBLE→40, UNLIKELY→15, UNVERIFIED→0
|
|
10
|
+
|
|
11
|
+
Reference: https://docs.oasis-open.org/cti/stix/v2.1/os/stix-v2.1-os.html
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import uuid
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("watson.stix")
|
|
26
|
+
|
|
27
|
+
# ── Confidence mapping ────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
_TIER_CONFIDENCE: dict[str, int] = {
|
|
30
|
+
"CONFIRMED": 85,
|
|
31
|
+
"PROBABLE": 65,
|
|
32
|
+
"POSSIBLE": 40,
|
|
33
|
+
"UNLIKELY": 15,
|
|
34
|
+
"UNVERIFIED": 0,
|
|
35
|
+
"UNSUBSTANTIATED": 0,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# ── Entity type → STIX identity_class ─────────────────────────
|
|
39
|
+
|
|
40
|
+
_ENTITY_STIX_CLASS: dict[str, str] = {
|
|
41
|
+
"person": "individual",
|
|
42
|
+
"organization": "organization",
|
|
43
|
+
"company": "organization",
|
|
44
|
+
"government": "organization",
|
|
45
|
+
"unknown": "unknown",
|
|
46
|
+
"": "unknown",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# ── STIX ID generation ────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def _stix_id(stix_type: str, value: str = "") -> str:
|
|
52
|
+
"""Generate deterministic STIX ID from type + value, or random UUID."""
|
|
53
|
+
if value:
|
|
54
|
+
# Namespace-based UUID for reproducibility
|
|
55
|
+
ns = uuid.uuid5(uuid.NAMESPACE_DNS, f"watson-osint:{stix_type}")
|
|
56
|
+
return f"{stix_type}--{uuid.uuid5(ns, value)}"
|
|
57
|
+
return f"{stix_type}--{uuid.uuid4()}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── IP regex ──────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
_IPV4_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
|
|
63
|
+
_IPV6_RE = re.compile(r"^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_ip(value: str) -> str | None:
|
|
67
|
+
"""Return 'ipv4', 'ipv6', or None."""
|
|
68
|
+
v = value.strip()
|
|
69
|
+
if _IPV4_RE.match(v):
|
|
70
|
+
return "ipv4-addr"
|
|
71
|
+
if _IPV6_RE.match(v):
|
|
72
|
+
return "ipv6-addr"
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_domain(value: str) -> bool:
|
|
77
|
+
"""Rough domain detection."""
|
|
78
|
+
v = value.strip().lower()
|
|
79
|
+
if not v or " " in v or len(v) > 253:
|
|
80
|
+
return False
|
|
81
|
+
return "." in v and not v.startswith(("http://", "https://"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_email(value: str) -> bool:
|
|
85
|
+
return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value.strip()))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_url(value: str) -> bool:
|
|
89
|
+
return value.strip().startswith(("http://", "https://"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── Main serializer ───────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
class STIXSerializer:
|
|
95
|
+
"""Convert Watson investigation results to STIX 2.1 bundles."""
|
|
96
|
+
|
|
97
|
+
def __init__(self):
|
|
98
|
+
self._objects: list[dict[str, Any]] = []
|
|
99
|
+
self._seen_ids: set[str] = set()
|
|
100
|
+
self._report_refs: list[str] = []
|
|
101
|
+
self._entity_cache: dict[str, str] = {} # value → stix_id
|
|
102
|
+
|
|
103
|
+
def serialize(
|
|
104
|
+
self,
|
|
105
|
+
query: str,
|
|
106
|
+
case_id: str,
|
|
107
|
+
target_type: str,
|
|
108
|
+
findings: list[Any],
|
|
109
|
+
entities: list[dict] | None = None,
|
|
110
|
+
brief: dict | None = None,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
"""Produce a STIX 2.1 bundle from investigation results."""
|
|
113
|
+
self._objects = []
|
|
114
|
+
self._seen_ids = set()
|
|
115
|
+
self._report_refs = []
|
|
116
|
+
self._entity_cache = {}
|
|
117
|
+
|
|
118
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
119
|
+
|
|
120
|
+
# ── 1. Report SDO ──
|
|
121
|
+
report_id = _stix_id("report", case_id)
|
|
122
|
+
report = {
|
|
123
|
+
"type": "report",
|
|
124
|
+
"id": report_id,
|
|
125
|
+
"spec_version": "2.1",
|
|
126
|
+
"created": now,
|
|
127
|
+
"modified": now,
|
|
128
|
+
"name": f"Watson Investigation: {query[:80]}",
|
|
129
|
+
"description": brief.get("executive_summary", "") if brief else f"OSINT investigation of {query}",
|
|
130
|
+
"report_types": ["investigation"],
|
|
131
|
+
"published": now,
|
|
132
|
+
"object_refs": self._report_refs, # will be filled
|
|
133
|
+
"labels": ["osint", target_type],
|
|
134
|
+
"external_references": [],
|
|
135
|
+
}
|
|
136
|
+
self._add_object(report)
|
|
137
|
+
|
|
138
|
+
# ── 2. Target identity ──
|
|
139
|
+
target_id = self._entity_for(query, target_type, brief)
|
|
140
|
+
self._report_refs.append(target_id)
|
|
141
|
+
self._relate(report_id, "investigates", target_id)
|
|
142
|
+
|
|
143
|
+
# ── 3. Findings → Indicators & ObservedData ──
|
|
144
|
+
for f in findings:
|
|
145
|
+
self._serialize_finding(f, report_id, target_id)
|
|
146
|
+
|
|
147
|
+
# ── 4. Entities → Identities, Locations, SCOs ──
|
|
148
|
+
if entities:
|
|
149
|
+
for ent in entities:
|
|
150
|
+
ent_id = self._serialize_entity(ent)
|
|
151
|
+
if ent_id:
|
|
152
|
+
self._report_refs.append(ent_id)
|
|
153
|
+
self._relate(report_id, "references", ent_id)
|
|
154
|
+
|
|
155
|
+
# ── 5. Fix up report object_refs ──
|
|
156
|
+
report["object_refs"] = self._report_refs
|
|
157
|
+
|
|
158
|
+
bundle = {
|
|
159
|
+
"type": "bundle",
|
|
160
|
+
"id": _stix_id("bundle", case_id),
|
|
161
|
+
"spec_version": "2.1",
|
|
162
|
+
"objects": self._objects,
|
|
163
|
+
}
|
|
164
|
+
return bundle
|
|
165
|
+
|
|
166
|
+
def to_json(self, **kwargs) -> str:
|
|
167
|
+
bundle = self.serialize(**kwargs)
|
|
168
|
+
return json.dumps(bundle, indent=2, default=str)
|
|
169
|
+
|
|
170
|
+
def to_file(self, path: Path | str, **kwargs) -> Path:
|
|
171
|
+
path = Path(path)
|
|
172
|
+
path.write_text(self.to_json(**kwargs))
|
|
173
|
+
logger.info("stix_exported: %s", path)
|
|
174
|
+
return path
|
|
175
|
+
|
|
176
|
+
# ── Internal ──────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def _add_object(self, obj: dict) -> None:
|
|
179
|
+
oid = obj.get("id", "")
|
|
180
|
+
if oid and oid not in self._seen_ids:
|
|
181
|
+
self._seen_ids.add(oid)
|
|
182
|
+
self._objects.append(obj)
|
|
183
|
+
|
|
184
|
+
def _relate(self, source_id: str, rel_type: str, target_id: str) -> str | None:
|
|
185
|
+
if not source_id or not target_id:
|
|
186
|
+
return None
|
|
187
|
+
rid = _stix_id("relationship", f"{source_id}|{rel_type}|{target_id}")
|
|
188
|
+
rel = {
|
|
189
|
+
"type": "relationship",
|
|
190
|
+
"id": rid,
|
|
191
|
+
"spec_version": "2.1",
|
|
192
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
193
|
+
"modified": datetime.now(timezone.utc).isoformat(),
|
|
194
|
+
"relationship_type": rel_type,
|
|
195
|
+
"source_ref": source_id,
|
|
196
|
+
"target_ref": target_id,
|
|
197
|
+
}
|
|
198
|
+
self._add_object(rel)
|
|
199
|
+
return rid
|
|
200
|
+
|
|
201
|
+
def _confidence(self, tier: str) -> int:
|
|
202
|
+
return _TIER_CONFIDENCE.get(tier, 30)
|
|
203
|
+
|
|
204
|
+
def _entity_for(self, value: str, etype: str = "unknown", brief: dict | None = None) -> str:
|
|
205
|
+
"""Get or create a STIX identity for an entity value."""
|
|
206
|
+
key = f"{value}|{etype}"
|
|
207
|
+
if key in self._entity_cache:
|
|
208
|
+
return self._entity_cache[key]
|
|
209
|
+
|
|
210
|
+
identity_class = _ENTITY_STIX_CLASS.get(etype, "unknown")
|
|
211
|
+
eid = _stix_id("identity", value)
|
|
212
|
+
|
|
213
|
+
identity = {
|
|
214
|
+
"type": "identity",
|
|
215
|
+
"id": eid,
|
|
216
|
+
"spec_version": "2.1",
|
|
217
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
218
|
+
"modified": datetime.now(timezone.utc).isoformat(),
|
|
219
|
+
"name": value[:200],
|
|
220
|
+
"identity_class": identity_class,
|
|
221
|
+
"description": f"Target entity of type: {etype}",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# Add sectors/labels from brief if available
|
|
225
|
+
if brief and identity_class == "organization":
|
|
226
|
+
risk_themes = brief.get("risk_themes", [])
|
|
227
|
+
if risk_themes:
|
|
228
|
+
identity["sectors"] = [t.get("theme", "")[:80] for t in risk_themes[:3]]
|
|
229
|
+
identity["labels"] = ["risk-associated"] + [
|
|
230
|
+
t.get("severity", "").lower() for t in risk_themes if t.get("severity")
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
self._add_object(identity)
|
|
234
|
+
self._entity_cache[key] = eid
|
|
235
|
+
return eid
|
|
236
|
+
|
|
237
|
+
def _serialize_finding(self, finding: Any, report_id: str, target_id: str) -> None:
|
|
238
|
+
"""Convert a Watson Finding to STIX Indicator + ObservedData."""
|
|
239
|
+
title = getattr(finding, "title", str(finding))[:200]
|
|
240
|
+
desc = getattr(finding, "description", "")[:500]
|
|
241
|
+
tier = getattr(finding, "tier", "UNVERIFIED")
|
|
242
|
+
source_url = getattr(finding, "source_url", "")
|
|
243
|
+
src_type = getattr(finding, "source_type", "osint")
|
|
244
|
+
confidence_val = self._confidence(tier) if tier else 30
|
|
245
|
+
fid = _stix_id("indicator", getattr(finding, "id", str(uuid.uuid4())))
|
|
246
|
+
|
|
247
|
+
# ── Indicator (pattern-based) ──
|
|
248
|
+
# Build a STIX pattern from finding text
|
|
249
|
+
pattern = self._build_pattern(title, desc)
|
|
250
|
+
indicator = {
|
|
251
|
+
"type": "indicator",
|
|
252
|
+
"id": fid,
|
|
253
|
+
"spec_version": "2.1",
|
|
254
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
255
|
+
"modified": datetime.now(timezone.utc).isoformat(),
|
|
256
|
+
"name": title,
|
|
257
|
+
"description": desc or title,
|
|
258
|
+
"indicator_types": ["osint-report"],
|
|
259
|
+
"pattern": pattern,
|
|
260
|
+
"pattern_type": "stix",
|
|
261
|
+
"valid_from": datetime.now(timezone.utc).isoformat(),
|
|
262
|
+
"confidence": confidence_val,
|
|
263
|
+
"labels": [src_type, tier.lower() if tier else "unverified"],
|
|
264
|
+
}
|
|
265
|
+
if source_url:
|
|
266
|
+
indicator["external_references"] = [{
|
|
267
|
+
"source_name": "OSINT Source",
|
|
268
|
+
"url": source_url,
|
|
269
|
+
"description": "Original investigation source",
|
|
270
|
+
}]
|
|
271
|
+
self._add_object(indicator)
|
|
272
|
+
self._report_refs.append(fid)
|
|
273
|
+
self._relate(fid, "derived-from", target_id)
|
|
274
|
+
|
|
275
|
+
# ── ObservedData (structured facts) ──
|
|
276
|
+
# Extract IPs, domains, emails from the finding text
|
|
277
|
+
observables = self._extract_observables(desc)
|
|
278
|
+
if observables:
|
|
279
|
+
odata_id = _stix_id("observed-data", fid)
|
|
280
|
+
observed = {
|
|
281
|
+
"type": "observed-data",
|
|
282
|
+
"id": odata_id,
|
|
283
|
+
"spec_version": "2.1",
|
|
284
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
285
|
+
"modified": datetime.now(timezone.utc).isoformat(),
|
|
286
|
+
"first_observed": datetime.now(timezone.utc).isoformat(),
|
|
287
|
+
"last_observed": datetime.now(timezone.utc).isoformat(),
|
|
288
|
+
"number_observed": 1,
|
|
289
|
+
"objects": observables,
|
|
290
|
+
}
|
|
291
|
+
self._add_object(observed)
|
|
292
|
+
self._report_refs.append(odata_id)
|
|
293
|
+
self._relate(fid, "based-on", odata_id)
|
|
294
|
+
|
|
295
|
+
def _serialize_entity(self, entity: dict | Any) -> str | None:
|
|
296
|
+
"""Serialize a Watson entity to STIX."""
|
|
297
|
+
if isinstance(entity, dict):
|
|
298
|
+
val = entity.get("value", entity.get("name", entity.get("canonical", "")))
|
|
299
|
+
etype = entity.get("type", "unknown")
|
|
300
|
+
elif hasattr(entity, "value"):
|
|
301
|
+
val = getattr(entity, "value", "") or ""
|
|
302
|
+
etype = getattr(entity, "type", "unknown")
|
|
303
|
+
else:
|
|
304
|
+
val = str(entity)
|
|
305
|
+
etype = "unknown"
|
|
306
|
+
|
|
307
|
+
if not val or not val.strip():
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
v = val.strip()
|
|
311
|
+
|
|
312
|
+
# IP address → IPv4Addr / IPv6Addr SCO
|
|
313
|
+
ip_type = _is_ip(v)
|
|
314
|
+
if ip_type:
|
|
315
|
+
ip_id = _stix_id(ip_type, v)
|
|
316
|
+
ip_obj = {
|
|
317
|
+
"type": ip_type,
|
|
318
|
+
"id": ip_id,
|
|
319
|
+
"spec_version": "2.1",
|
|
320
|
+
"value": v,
|
|
321
|
+
}
|
|
322
|
+
self._add_object(ip_obj)
|
|
323
|
+
return ip_id
|
|
324
|
+
|
|
325
|
+
# Domain → DomainName SCO
|
|
326
|
+
if _is_domain(v):
|
|
327
|
+
dom_id = _stix_id("domain-name", v)
|
|
328
|
+
domain = {
|
|
329
|
+
"type": "domain-name",
|
|
330
|
+
"id": dom_id,
|
|
331
|
+
"spec_version": "2.1",
|
|
332
|
+
"value": v,
|
|
333
|
+
}
|
|
334
|
+
self._add_object(domain)
|
|
335
|
+
return dom_id
|
|
336
|
+
|
|
337
|
+
# Email → EmailAddr SCO
|
|
338
|
+
if _is_email(v):
|
|
339
|
+
email_id = _stix_id("email-addr", v)
|
|
340
|
+
email_obj = {
|
|
341
|
+
"type": "email-addr",
|
|
342
|
+
"id": email_id,
|
|
343
|
+
"spec_version": "2.1",
|
|
344
|
+
"value": v,
|
|
345
|
+
}
|
|
346
|
+
self._add_object(email_obj)
|
|
347
|
+
return email_id
|
|
348
|
+
|
|
349
|
+
# URL → URL SCO
|
|
350
|
+
if _is_url(v):
|
|
351
|
+
url_id = _stix_id("url", v)
|
|
352
|
+
url_obj = {
|
|
353
|
+
"type": "url",
|
|
354
|
+
"id": url_id,
|
|
355
|
+
"spec_version": "2.1",
|
|
356
|
+
"value": v,
|
|
357
|
+
}
|
|
358
|
+
self._add_object(url_obj)
|
|
359
|
+
return url_id
|
|
360
|
+
|
|
361
|
+
# Default → Identity
|
|
362
|
+
return self._entity_for(v, etype)
|
|
363
|
+
|
|
364
|
+
def _build_pattern(self, title: str, desc: str) -> str:
|
|
365
|
+
"""Build a STIX pattern string from finding text."""
|
|
366
|
+
# Extract first significant noun phrase
|
|
367
|
+
text = f"{title} {desc}"[:500]
|
|
368
|
+
# Simple: mark as osint-report indicator with description
|
|
369
|
+
return f"[indicator:pattern_type = 'osint-report' AND indicator:description MATCHES '{self._escape_pattern(desc[:100])}']"
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _escape_pattern(text: str) -> str:
|
|
373
|
+
"""Escape single quotes for STIX pattern."""
|
|
374
|
+
return text.replace("'", "\\'").replace("\n", " ")[:200]
|
|
375
|
+
|
|
376
|
+
def _extract_observables(self, text: str) -> dict[str, dict]:
|
|
377
|
+
"""Extract IPs, domains, emails from text as STIX Cyber Observable objects."""
|
|
378
|
+
objects: dict[str, dict] = {}
|
|
379
|
+
if not text:
|
|
380
|
+
return objects
|
|
381
|
+
|
|
382
|
+
# Extract IPs
|
|
383
|
+
for ip_match in re.finditer(_IPV4_RE, text):
|
|
384
|
+
ip = ip_match.group(0)
|
|
385
|
+
if ip not in objects:
|
|
386
|
+
objects[f"0"] = {
|
|
387
|
+
"type": "ipv4-addr",
|
|
388
|
+
"value": ip,
|
|
389
|
+
}
|
|
390
|
+
break # just one per finding
|
|
391
|
+
|
|
392
|
+
# Extract domains (rough)
|
|
393
|
+
domain_match = re.search(r'(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}', text)
|
|
394
|
+
if domain_match:
|
|
395
|
+
dom = domain_match.group(0).lower()
|
|
396
|
+
if dom not in objects:
|
|
397
|
+
key = f"{len(objects)}"
|
|
398
|
+
objects[key] = {
|
|
399
|
+
"type": "domain-name",
|
|
400
|
+
"value": dom,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# Extract emails
|
|
404
|
+
email_match = re.search(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', text)
|
|
405
|
+
if email_match:
|
|
406
|
+
email = email_match.group(0)
|
|
407
|
+
key = f"{len(objects)}"
|
|
408
|
+
objects[key] = {
|
|
409
|
+
"type": "email-addr",
|
|
410
|
+
"value": email,
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return objects
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ── Convenience export function ───────────────────────────────
|
|
417
|
+
|
|
418
|
+
def export_stix(
|
|
419
|
+
query: str,
|
|
420
|
+
case_id: str,
|
|
421
|
+
target_type: str,
|
|
422
|
+
findings: list[Any],
|
|
423
|
+
entities: list[dict] | None = None,
|
|
424
|
+
brief: dict | None = None,
|
|
425
|
+
output_path: Path | str | None = None,
|
|
426
|
+
) -> tuple[dict, Path | None]:
|
|
427
|
+
"""Export investigation results as STIX 2.1.
|
|
428
|
+
|
|
429
|
+
Returns (bundle_dict, output_path_or_None).
|
|
430
|
+
"""
|
|
431
|
+
serializer = STIXSerializer()
|
|
432
|
+
bundle = serializer.serialize(
|
|
433
|
+
query=query,
|
|
434
|
+
case_id=case_id,
|
|
435
|
+
target_type=target_type,
|
|
436
|
+
findings=findings,
|
|
437
|
+
entities=entities,
|
|
438
|
+
brief=brief,
|
|
439
|
+
)
|
|
440
|
+
path = None
|
|
441
|
+
if output_path:
|
|
442
|
+
path = serializer.to_file(
|
|
443
|
+
output_path,
|
|
444
|
+
query=query,
|
|
445
|
+
case_id=case_id,
|
|
446
|
+
target_type=target_type,
|
|
447
|
+
findings=findings,
|
|
448
|
+
entities=entities,
|
|
449
|
+
brief=brief,
|
|
450
|
+
)
|
|
451
|
+
return bundle, path
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""OSINT tool modules — each implements the OSINTTool base class.
|
|
2
|
+
|
|
3
|
+
To add a new tool:
|
|
4
|
+
1. Create a new .py file in this directory
|
|
5
|
+
2. Inherit from `base.OSINTTool`
|
|
6
|
+
3. Import and register it here
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from . import satellite
|
|
10
|
+
from . import geolocation
|
|
11
|
+
from . import image_video
|
|
12
|
+
from . import social_media
|
|
13
|
+
from . import people
|
|
14
|
+
from . import websites
|
|
15
|
+
from . import corporate
|
|
16
|
+
from . import conflict
|
|
17
|
+
from . import scraper
|
|
18
|
+
from . import captcha
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"satellite",
|
|
22
|
+
"geolocation",
|
|
23
|
+
"image_video",
|
|
24
|
+
"social_media",
|
|
25
|
+
"people",
|
|
26
|
+
"websites",
|
|
27
|
+
"corporate",
|
|
28
|
+
"conflict",
|
|
29
|
+
"scraper",
|
|
30
|
+
"captcha",
|
|
31
|
+
]
|
src/watson/tools/base.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Abstract base class for OSINT tools. Every tool inherits from this."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from ..core.models import Finding, FindingSeverity, FindingSource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OSINTTool(ABC):
|
|
11
|
+
"""Base class for all OSINT investigation tools.
|
|
12
|
+
|
|
13
|
+
To add a new tool:
|
|
14
|
+
1. Inherit from OSINTTool
|
|
15
|
+
2. Set `category`, `name`, `description`
|
|
16
|
+
3. Set API key metadata if the tool needs one
|
|
17
|
+
4. Implement `async investigate(query, context) -> list[Finding]`
|
|
18
|
+
5. Register in tools/__init__.py
|
|
19
|
+
|
|
20
|
+
API Key awareness:
|
|
21
|
+
- If requires_api_key=True, the dispatcher checks config BEFORE running
|
|
22
|
+
- If the key is missing, the tool is SKIPPED with a clear message and setup instructions
|
|
23
|
+
- Watson never runs a tool it knows will fail due to missing credentials
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
category: FindingSource
|
|
27
|
+
name: str
|
|
28
|
+
description: str
|
|
29
|
+
|
|
30
|
+
# ── API Key Metadata ─────────────────────────────────────────
|
|
31
|
+
requires_api_key: bool = False
|
|
32
|
+
# Config key name in [watson.api_keys] (e.g., "tineye", "azure", "newscatcher")
|
|
33
|
+
api_key_name: str = ""
|
|
34
|
+
# If True, tool CANNOT function without this key (vs optional enhancement)
|
|
35
|
+
api_key_required: bool = False
|
|
36
|
+
# Terminal command to set the key (e.g., "run: set_api_key tineye YOUR_KEY")
|
|
37
|
+
api_key_setup_command: str = ""
|
|
38
|
+
# Where to get the API key
|
|
39
|
+
api_key_url: str = ""
|
|
40
|
+
# Human-readable reason why the key is needed and what it unlocks
|
|
41
|
+
api_key_description: str = ""
|
|
42
|
+
# Alternative tools that can work WITHOUT this key
|
|
43
|
+
workarounds: list[str] = []
|
|
44
|
+
# If no public API exists at all, explain why
|
|
45
|
+
unavailable_reason: str = ""
|
|
46
|
+
|
|
47
|
+
free_tier_available: bool = True
|
|
48
|
+
rate_limit_rps: float = 1.0 # requests per second
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def investigate(self, query: str, context: str = "") -> list[Finding]:
|
|
52
|
+
"""Run investigation and return findings.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
query: The search query or target
|
|
56
|
+
context: Additional context from the parent investigation
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
List of Finding objects
|
|
60
|
+
"""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def _make_finding(
|
|
64
|
+
self,
|
|
65
|
+
title: str,
|
|
66
|
+
description: str,
|
|
67
|
+
evidence: list[str] | None = None,
|
|
68
|
+
confidence: float = 0.5,
|
|
69
|
+
severity: FindingSeverity | None = None,
|
|
70
|
+
**metadata,
|
|
71
|
+
) -> Finding:
|
|
72
|
+
"""Helper to create a Finding with source pre-filled.
|
|
73
|
+
|
|
74
|
+
All extra kwargs go into Finding.metadata, except keys that collide
|
|
75
|
+
with Finding's own fields (which are silently dropped from metadata).
|
|
76
|
+
"""
|
|
77
|
+
import uuid
|
|
78
|
+
|
|
79
|
+
# Strip any kwargs that collide with Finding model fields
|
|
80
|
+
_finding_fields = {"id", "source", "tool", "title", "description",
|
|
81
|
+
"evidence", "severity", "confidence", "timestamp", "metadata"}
|
|
82
|
+
safe_metadata = {k: v for k, v in metadata.items() if k not in _finding_fields}
|
|
83
|
+
|
|
84
|
+
return Finding(
|
|
85
|
+
id=f"{self.category.value}-{uuid.uuid4().hex[:8]}",
|
|
86
|
+
source=self.category,
|
|
87
|
+
tool=self.name,
|
|
88
|
+
title=title,
|
|
89
|
+
description=description,
|
|
90
|
+
evidence=evidence or [],
|
|
91
|
+
confidence=confidence,
|
|
92
|
+
severity=severity or FindingSeverity.INFO,
|
|
93
|
+
metadata=safe_metadata,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __repr__(self) -> str:
|
|
97
|
+
return f"<OSINTTool {self.name} ({self.category.value})>"
|