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.
Files changed (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. 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
+ ]
@@ -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})>"