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
@@ -0,0 +1,96 @@
1
+ """Tool executor — retry wrappers, circuit breakers, graceful degradation."""
2
+
3
+ from __future__ import annotations
4
+ import asyncio
5
+ import logging
6
+ import time
7
+ from typing import Any, Callable
8
+
9
+ logger = logging.getLogger("watson.executor")
10
+
11
+ # ── Circuit breaker ───────────────────────────────────────────
12
+
13
+ class CircuitBreaker:
14
+ """Prevent hammering a failing API."""
15
+
16
+ def __init__(self, name: str, failure_threshold: int = 3, cooldown: float = 60.0):
17
+ self.name = name
18
+ self.failure_threshold = failure_threshold
19
+ self.cooldown = cooldown
20
+ self._failures = 0
21
+ self._last_failure = 0.0
22
+ self._open = False
23
+
24
+ @property
25
+ def is_open(self) -> bool:
26
+ if not self._open:
27
+ return False
28
+ if time.time() - self._last_failure > self.cooldown:
29
+ self._open = False
30
+ self._failures = 0
31
+ return False
32
+ return True
33
+
34
+ def record_failure(self):
35
+ self._failures += 1
36
+ self._last_failure = time.time()
37
+ if self._failures >= self.failure_threshold:
38
+ self._open = True
39
+ logger.warning("circuit_breaker_open: %s", self.name)
40
+
41
+ def record_success(self):
42
+ self._failures = 0
43
+ self._open = False
44
+
45
+
46
+ # Global circuit breakers
47
+ _circuits: dict[str, CircuitBreaker] = {}
48
+
49
+
50
+ def get_circuit(name: str) -> CircuitBreaker:
51
+ if name not in _circuits:
52
+ _circuits[name] = CircuitBreaker(name)
53
+ return _circuits[name]
54
+
55
+
56
+ # ── Retry wrapper ─────────────────────────────────────────────
57
+
58
+ async def with_retry(
59
+ fn: Callable,
60
+ *args,
61
+ tool_name: str = "unknown",
62
+ max_retries: int = 2,
63
+ backoff: float = 2.0,
64
+ **kwargs,
65
+ ) -> Any:
66
+ """Call fn with retry + circuit breaker. Returns result or error finding."""
67
+ circuit = get_circuit(tool_name)
68
+
69
+ if circuit.is_open:
70
+ return _error_finding(tool_name, "Circuit breaker open — API unavailable")
71
+
72
+ last_error = None
73
+ for attempt in range(max_retries + 1):
74
+ try:
75
+ result = await fn(*args, **kwargs) if asyncio.iscoroutinefunction(fn) else fn(*args, **kwargs)
76
+ circuit.record_success()
77
+ return result
78
+ except Exception as e:
79
+ last_error = e
80
+ logger.warning("tool_attempt_failed: %s attempt %d: %s", tool_name, attempt + 1, e)
81
+ if attempt < max_retries:
82
+ await asyncio.sleep(backoff * (attempt + 1))
83
+
84
+ circuit.record_failure()
85
+ return _error_finding(tool_name, str(last_error)[:200])
86
+
87
+
88
+ def _error_finding(tool_name: str, error_msg: str) -> dict:
89
+ """Return a structured error finding — NOT None, so the pipeline knows the tool ran."""
90
+ return {
91
+ "title": f"⚠️ {tool_name}: API error",
92
+ "description": f"Tool '{tool_name}' failed: {error_msg}",
93
+ "source_type": "error",
94
+ "confidence": 0.0,
95
+ "evidence": [],
96
+ }
@@ -0,0 +1,139 @@
1
+ """Intent classification — understand what the user is investigating."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional
6
+
7
+ # ── Data model ────────────────────────────────────────────────
8
+
9
+ @dataclass
10
+ class Intent:
11
+ """What Watson understood about the investigation target."""
12
+ focus: str # natural-language focus area
13
+ target_type: str # person | company | domain | topic | wallet
14
+ confidence: float # classification confidence 0-1
15
+ entities: list[str] = field(default_factory=list)
16
+ categories: list[str] = field(default_factory=list)
17
+ raw_query: str = ""
18
+
19
+ # ── LLM prompt for classification ─────────────────────────────
20
+
21
+ _INTENT_PROMPT = """You are an OSINT target classifier. Given a search query, determine what is being investigated.
22
+
23
+ Return STRICT JSON with these fields:
24
+ {
25
+ "target_type": "person|company|domain|topic|wallet|ip|email",
26
+ "focus": "short description of what aspect to investigate (1 sentence)",
27
+ "confidence": 0.0-1.0,
28
+ "categories": ["list of tool categories to use: social, corporate, dark, crypto, geo, media, recon, vision"],
29
+ "entities": ["list of named entities found in the query"]
30
+ }
31
+
32
+ ENTITY TYPE RULES:
33
+ - Classify each entity independently based on context
34
+ - People have names, DOBs, nationalities, aliases, professions
35
+ - Companies have suffixes (Inc, LLC, Ltd, Corp, GmbH, SA, AG, Group, Holdings) or are known orgs
36
+ - Entities with version numbers (e.g., "LockBit 3.0") are NEVER typed as person
37
+ - Email addresses, crypto wallets, IPs, domains have their own types
38
+ - If ambiguous, prefer the more specific type
39
+
40
+ Query: {query}
41
+
42
+ JSON:"""
43
+
44
+ # ── Deterministic fallback — no LLM needed for obvious cases ──
45
+
46
+ def _deterministic_classify(query: str) -> Optional[Intent]:
47
+ """Quick classification without LLM for obvious cases."""
48
+ q = query.strip().lower()
49
+
50
+ # Email
51
+ if "@" in q and "." in q.split("@")[-1]:
52
+ return Intent(focus=f"Email investigation of {q}", target_type="email",
53
+ confidence=0.95, entities=[query.strip()],
54
+ categories=["social", "recon"], raw_query=query)
55
+
56
+ # Crypto wallet
57
+ if q.startswith("0x") and len(q) >= 40:
58
+ return Intent(focus=f"Cryptocurrency wallet {q[:10]}...", target_type="wallet",
59
+ confidence=0.95, entities=[q], categories=["crypto", "dark"],
60
+ raw_query=query)
61
+
62
+ # IP address
63
+ parts = q.split(".")
64
+ if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
65
+ return Intent(focus=f"IP address investigation of {q}", target_type="ip",
66
+ confidence=0.95, entities=[q], categories=["geo", "dark"],
67
+ raw_query=query)
68
+
69
+ # Domain
70
+ if "." in q and not " " in q and not q.startswith("http"):
71
+ if any(q.endswith(t) for t in [".com", ".org", ".net", ".io", ".gov", ".ru", ".cn", ".de"]):
72
+ return Intent(focus=f"Domain investigation of {q}", target_type="domain",
73
+ confidence=0.85, entities=[q],
74
+ categories=["recon", "dark", "corporate"],
75
+ raw_query=query)
76
+
77
+ return None
78
+
79
+ # ── LLM fallback classifier ──────────────────────────────────
80
+
81
+ async def _llm_classify(query: str, call_llm) -> Intent:
82
+ """Use LLM to classify the query intent."""
83
+ prompt = _INTENT_PROMPT.format(query=query[:500])
84
+
85
+ try:
86
+ raw = await call_llm(prompt, timeout=30)
87
+ except Exception:
88
+ raw = None
89
+
90
+ if not raw:
91
+ return Intent(focus=query, target_type="topic", confidence=0.3,
92
+ entities=[], categories=["recon", "social"],
93
+ raw_query=query)
94
+
95
+ import json, re
96
+ # Strip markdown fences
97
+ raw = raw.strip()
98
+ if raw.startswith("```"):
99
+ raw = re.sub(r"^```(?:json)?\s*", "", raw)
100
+ raw = re.sub(r"\s*```$", "", raw)
101
+
102
+ try:
103
+ parsed = json.loads(raw)
104
+ except json.JSONDecodeError:
105
+ m = re.search(r"\{.*\}", raw, re.DOTALL)
106
+ if m:
107
+ try:
108
+ parsed = json.loads(m.group(0))
109
+ except json.JSONDecodeError:
110
+ parsed = {}
111
+ else:
112
+ parsed = {}
113
+
114
+ target_type = str(parsed.get("target_type", "topic")).lower()
115
+ if target_type not in ("person", "company", "domain", "topic", "wallet", "ip", "email"):
116
+ target_type = "topic"
117
+
118
+ return Intent(
119
+ focus=str(parsed.get("focus", query)),
120
+ target_type=target_type,
121
+ confidence=float(parsed.get("confidence", 0.5)),
122
+ entities=[str(e) for e in parsed.get("entities", []) if str(e).strip()],
123
+ categories=[str(c) for c in parsed.get("categories", ["recon", "social"])],
124
+ raw_query=query,
125
+ )
126
+
127
+ # ── Main entry point ─────────────────────────────────────────
128
+
129
+ async def classify_intent(query: str, call_llm=None) -> Intent:
130
+ """Classify investigation intent. Uses deterministic rules first, LLM as fallback."""
131
+ det = _deterministic_classify(query)
132
+ if det:
133
+ return det
134
+
135
+ if call_llm:
136
+ return await _llm_classify(query, call_llm)
137
+
138
+ return Intent(focus=query, target_type="topic", confidence=0.4,
139
+ entities=[], categories=["recon", "social"], raw_query=query)
@@ -0,0 +1,45 @@
1
+ """Intent classifier — determines if a message is an investigation or chat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Coroutine, Tuple
6
+
7
+
8
+ async def classify_intent(
9
+ msg: str,
10
+ call_llm: Callable[..., Coroutine],
11
+ ) -> Tuple[str, float, str]:
12
+ """Classify a message as 'investigate' or 'chat' using LLM fallback.
13
+
14
+ Args:
15
+ msg: The user message to classify.
16
+ call_llm: Async function to call the LLM.
17
+
18
+ Returns:
19
+ Tuple of (intent: str, confidence: float, reason: str)
20
+ """
21
+ prompt = (
22
+ "Classify this message as 'investigate' or 'chat'.\n"
23
+ "'investigate' means the user wants to investigate a person, domain, company, "
24
+ "crypto address, IP, email, or similar target.\n"
25
+ "'chat' means it's a conversational question about OSINT, methodology, or general knowledge.\n\n"
26
+ f"Message: \"{msg[:500]}\"\n\n"
27
+ "Respond with only one word: investigate or chat"
28
+ )
29
+
30
+ try:
31
+ result = await call_llm(
32
+ messages=[{"role": "user", "content": prompt}],
33
+ max_tokens=10,
34
+ temperature=0.0,
35
+ )
36
+ answer = result.strip().lower()
37
+ if "investigate" in answer:
38
+ return "investigate", 0.65, "llm_classifier"
39
+ elif "chat" in answer:
40
+ return "chat", 0.65, "llm_classifier"
41
+ else:
42
+ # Default: ambiguous → treat as chat for safety
43
+ return "chat", 0.5, "llm_ambiguous"
44
+ except Exception:
45
+ return "chat", 0.5, "llm_unavailable"
@@ -0,0 +1,160 @@
1
+ """LLM configuration — call any OpenAI-compatible API."""
2
+
3
+ from __future__ import annotations
4
+ import os, json, logging, re
5
+
6
+ logger = logging.getLogger("watson.llm")
7
+
8
+ # ── Provider config ──────────────────────────────────────────
9
+
10
+ _PROVIDER_CONFIG = {
11
+ "deepseek": {
12
+ "base_url": "https://api.deepseek.com/v1",
13
+ "api_key_env": "DEEPSEEK_API_KEY",
14
+ "default_model": os.environ.get("DEEPSEEK_MODEL", "deepseek-v4-pro"),
15
+ },
16
+ "openai": {
17
+ "base_url": "https://api.openai.com/v1",
18
+ "api_key_env": "OPENAI_API_KEY",
19
+ "default_model": "gpt-4o",
20
+ },
21
+ "anthropic": {
22
+ "base_url": "https://api.anthropic.com/v1",
23
+ "api_key_env": "ANTHROPIC_API_KEY",
24
+ "default_model": "claude-sonnet-4-20250514",
25
+ },
26
+ "hermes": {
27
+ "base_url": os.environ.get("HERMES_API_BASE", "http://localhost:8080/v1"),
28
+ "api_key_env": "HERMES_API_KEY",
29
+ "default_model": "hermes",
30
+ },
31
+ "openrouter": {
32
+ "base_url": "https://openrouter.ai/api/v1",
33
+ "api_key_env": "OPENROUTER_API_KEY",
34
+ "default_model": "deepseek/deepseek-chat",
35
+ },
36
+ }
37
+
38
+ # ── Main call function ───────────────────────────────────────
39
+
40
+ def _auto_detect_provider() -> str:
41
+ """Auto-detect which LLM provider has an API key configured.
42
+
43
+ Checks environment variables and the Watson key store.
44
+ Returns the first available provider name, or '' if none found.
45
+ """
46
+ # Check env vars for each provider
47
+ for provider_name, cfg in _PROVIDER_CONFIG.items():
48
+ if os.environ.get(cfg["api_key_env"], ""):
49
+ return provider_name
50
+
51
+ # Check the Watson UI key store
52
+ try:
53
+ from watson.api_keys import list_keys
54
+ stored = list_keys()
55
+ for entry in stored:
56
+ if entry.get("configured") and entry.get("slug") in _PROVIDER_CONFIG:
57
+ return entry["slug"]
58
+ except (ImportError, Exception):
59
+ pass
60
+
61
+ return ""
62
+
63
+ async def call_llm(
64
+ prompt: str,
65
+ timeout: int = 60,
66
+ max_tokens: int = 2048,
67
+ model: str | None = None,
68
+ provider: str | None = None,
69
+ system: str = "",
70
+ ) -> str | None:
71
+ """Call an LLM via OpenAI-compatible API. Returns text or None."""
72
+
73
+ # Resolve provider: explicit > env var > auto-detect
74
+ if not provider:
75
+ provider = os.environ.get("WATSON_LLM_PROVIDER", "")
76
+ if not provider:
77
+ provider = _auto_detect_provider()
78
+ if not provider:
79
+ logger.warning(
80
+ "llm: no LLM provider configured. Set one of: "
81
+ "DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, "
82
+ "OPENROUTER_API_KEY, or WATSON_LLM_PROVIDER. "
83
+ "See .env.example for setup instructions."
84
+ )
85
+ return None
86
+
87
+ cfg = _PROVIDER_CONFIG.get(provider)
88
+ if not cfg:
89
+ logger.warning("llm: unknown provider '%s' — known: %s", provider, list(_PROVIDER_CONFIG.keys()))
90
+ return None
91
+
92
+ api_key = os.environ.get(cfg["api_key_env"], "")
93
+
94
+ # Fallback: check the Watson key store (user-configured via UI)
95
+ if not api_key:
96
+ try:
97
+ from watson.api_keys import get_key
98
+ api_key = get_key(provider) or ""
99
+ except ImportError:
100
+ pass
101
+
102
+ if not api_key:
103
+ logger.warning("llm: no API key for provider %s (env: %s)", provider, cfg["api_key_env"])
104
+ return None
105
+
106
+ if not model:
107
+ model = cfg["default_model"]
108
+
109
+ url = f"{cfg['base_url']}/chat/completions"
110
+
111
+ messages = []
112
+ if system:
113
+ messages.append({"role": "system", "content": system})
114
+ messages.append({"role": "user", "content": prompt})
115
+
116
+ payload = {
117
+ "model": model,
118
+ "messages": messages,
119
+ "max_tokens": max_tokens,
120
+ "temperature": 0.3,
121
+ }
122
+
123
+ try:
124
+ import httpx
125
+ async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=15.0)) as client:
126
+ resp = await client.post(
127
+ url,
128
+ json=payload,
129
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
130
+ )
131
+ if resp.status_code == 200:
132
+ data = resp.json()
133
+ msg = data["choices"][0]["message"]
134
+ content = msg.get("content", "") or ""
135
+ # Reasoning models (deepseek-v4-pro, R1) put thinking in reasoning_content
136
+ # and the final answer in content. If content is empty (all tokens spent
137
+ # on reasoning), fall back to reasoning_content.
138
+ if not content.strip():
139
+ reasoning = msg.get("reasoning_content", "") or ""
140
+ if reasoning.strip():
141
+ logger.debug("llm: using reasoning_content (content was empty)")
142
+ return reasoning
143
+ # Both empty — log response for debugging
144
+ logger.warning("llm: empty content AND empty reasoning_content — finish_reason=%s model=%s",
145
+ data["choices"][0].get("finish_reason", "?"), model)
146
+ # Check if there's any text anywhere in the response
147
+ all_keys = list(msg.keys())
148
+ logger.debug("llm: message keys: %s", all_keys)
149
+ for k in all_keys:
150
+ v = msg.get(k, "")
151
+ if isinstance(v, str) and v.strip():
152
+ logger.info("llm: found text in key '%s', using that", k)
153
+ return v
154
+ return content
155
+ logger.warning("llm: HTTP %d from %s — body: %s", resp.status_code, provider,
156
+ resp.text[:300] if resp.text else "(empty)")
157
+ return None
158
+ except Exception as e:
159
+ logger.warning("llm: request failed: %s", e)
160
+ return None