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,96 @@
|
|
|
1
|
+
"""OSINT Agent — top-level investigation agent.
|
|
2
|
+
|
|
3
|
+
Delegates to the orchestration engine for the full pipeline:
|
|
4
|
+
classify → burst → resolve → cross-ref → synthesize → continue.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OSINTAgent:
|
|
14
|
+
"""High-level OSINT investigation agent with full reasoning pipeline."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, depth: int = 2, api_keys: Optional[Dict[str, str]] = None):
|
|
17
|
+
self.depth = max(1, min(depth, 5))
|
|
18
|
+
self.api_keys = api_keys or {}
|
|
19
|
+
|
|
20
|
+
async def investigate(
|
|
21
|
+
self,
|
|
22
|
+
query: str,
|
|
23
|
+
context: str = "",
|
|
24
|
+
on_event: Optional[Callable[[str, Any], None]] = None,
|
|
25
|
+
image_path: Optional[str] = None,
|
|
26
|
+
) -> List[Dict[str, Any]]:
|
|
27
|
+
"""Run a full investigation through the orchestration engine."""
|
|
28
|
+
|
|
29
|
+
client_id = f"agent-{uuid.uuid4().hex[:8]}"
|
|
30
|
+
events: List[Dict[str, Any]] = []
|
|
31
|
+
|
|
32
|
+
def collect_event(event_type: str, data: Any):
|
|
33
|
+
events.append({"event": event_type, "data": data})
|
|
34
|
+
if on_event:
|
|
35
|
+
on_event(event_type, data)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from src.watson.orchestration import get_engine
|
|
39
|
+
|
|
40
|
+
engine = get_engine(depth=self.depth)
|
|
41
|
+
brief = await engine.investigate(
|
|
42
|
+
query=query,
|
|
43
|
+
focus=context,
|
|
44
|
+
on_event=collect_event,
|
|
45
|
+
client_id=client_id,
|
|
46
|
+
depth=self.depth,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if brief:
|
|
50
|
+
events.append({"event": "brief", "data": brief})
|
|
51
|
+
|
|
52
|
+
except Exception as e:
|
|
53
|
+
# Fallback: use the orchestration engine directly
|
|
54
|
+
try:
|
|
55
|
+
from src.watson.orchestration import get_engine
|
|
56
|
+
|
|
57
|
+
engine = get_engine()
|
|
58
|
+
result = await engine.investigate(query)
|
|
59
|
+
|
|
60
|
+
if result and result.get("findings"):
|
|
61
|
+
for f in result["findings"]:
|
|
62
|
+
events.append({"event": "finding", "data": {
|
|
63
|
+
"title": f.title,
|
|
64
|
+
"description": f.description[:500],
|
|
65
|
+
"source_type": f.source_type,
|
|
66
|
+
"confidence": f.confidence,
|
|
67
|
+
"tier": "CONFIRMED" if f.confidence >= 0.9 else
|
|
68
|
+
("PROBABLE" if f.confidence >= 0.7 else "POSSIBLE"),
|
|
69
|
+
}})
|
|
70
|
+
if on_event:
|
|
71
|
+
on_event("finding", events[-1]["data"])
|
|
72
|
+
except Exception as e2:
|
|
73
|
+
events.append({"event": "error", "data": {
|
|
74
|
+
"message": f"Investigation failed: {e2}",
|
|
75
|
+
"query": query,
|
|
76
|
+
}})
|
|
77
|
+
|
|
78
|
+
if not events:
|
|
79
|
+
events.append({"event": "finding", "data": {
|
|
80
|
+
"title": f"Investigation: {query}",
|
|
81
|
+
"description": f"Basic investigation of {query}",
|
|
82
|
+
"source_type": "web_search",
|
|
83
|
+
"confidence": 0.3,
|
|
84
|
+
"tier": "POSSIBLE",
|
|
85
|
+
}})
|
|
86
|
+
|
|
87
|
+
return events
|
|
88
|
+
|
|
89
|
+
def get_graph(self) -> Dict[str, int]:
|
|
90
|
+
"""Return knowledge graph statistics."""
|
|
91
|
+
try:
|
|
92
|
+
from watson.graph import KnowledgeGraph
|
|
93
|
+
g = KnowledgeGraph()
|
|
94
|
+
return g.stats() if hasattr(g, "stats") else {"nodes": 0, "edges": 0}
|
|
95
|
+
except Exception:
|
|
96
|
+
return {"nodes": 0, "edges": 0}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Agents registry — list all available OSINT sub-agents.
|
|
2
|
+
|
|
3
|
+
Provides get_all_agents() for the /api/agent/agents endpoint.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentRole(str, Enum):
|
|
14
|
+
COORDINATOR = "coordinator"
|
|
15
|
+
DOMAIN_RESEARCHER = "domain_researcher"
|
|
16
|
+
SOCIAL_MEDIA_ANALYST = "social_media_analyst"
|
|
17
|
+
CORPORATE_ANALYST = "corporate_analyst"
|
|
18
|
+
CRYPTO_ANALYST = "crypto_analyst"
|
|
19
|
+
GEOLOCATION_ANALYST = "geolocation_analyst"
|
|
20
|
+
IMAGE_ANALYST = "image_analyst"
|
|
21
|
+
PEOPLE_SEARCHER = "people_searcher"
|
|
22
|
+
DARKWEB_SCOUT = "darkweb_scout"
|
|
23
|
+
CROSS_REFERENCER = "cross_referencer"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AgentInfo:
|
|
28
|
+
role: AgentRole
|
|
29
|
+
description: str
|
|
30
|
+
capabilities: List[str] = field(default_factory=list)
|
|
31
|
+
tool_count: int = 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Registry of all specialized agents
|
|
35
|
+
_AGENTS: Dict[str, AgentInfo] = {
|
|
36
|
+
"coordinator": AgentInfo(
|
|
37
|
+
role=AgentRole.COORDINATOR,
|
|
38
|
+
description="Orchestrates investigations, dispatches sub-agents, and synthesizes findings",
|
|
39
|
+
capabilities=["multi_agent_orchestration", "finding_synthesis", "gap_analysis"],
|
|
40
|
+
tool_count=5,
|
|
41
|
+
),
|
|
42
|
+
"domain_researcher": AgentInfo(
|
|
43
|
+
role=AgentRole.DOMAIN_RESEARCHER,
|
|
44
|
+
description="Domain & DNS investigation — WHOIS, certificate transparency, DNS records",
|
|
45
|
+
capabilities=["whois_lookup", "dns_lookup", "crt_sh", "wayback_machine"],
|
|
46
|
+
tool_count=4,
|
|
47
|
+
),
|
|
48
|
+
"social_media_analyst": AgentInfo(
|
|
49
|
+
role=AgentRole.SOCIAL_MEDIA_ANALYST,
|
|
50
|
+
description="Social media investigation — profiles, posts, network analysis",
|
|
51
|
+
capabilities=["social_search", "profile_analysis", "network_mapping"],
|
|
52
|
+
tool_count=3,
|
|
53
|
+
),
|
|
54
|
+
"corporate_analyst": AgentInfo(
|
|
55
|
+
role=AgentRole.CORPORATE_ANALYST,
|
|
56
|
+
description="Corporate investigation — company registries, directors, financial records",
|
|
57
|
+
capabilities=["opencorporates_search", "sec_filings", "sanctions_check"],
|
|
58
|
+
tool_count=3,
|
|
59
|
+
),
|
|
60
|
+
"crypto_analyst": AgentInfo(
|
|
61
|
+
role=AgentRole.CRYPTO_ANALYST,
|
|
62
|
+
description="Cryptocurrency investigation — blockchain analysis, wallet tracing",
|
|
63
|
+
capabilities=["etherscan_lookup", "wallet_tracing", "transaction_analysis"],
|
|
64
|
+
tool_count=3,
|
|
65
|
+
),
|
|
66
|
+
"geolocation_analyst": AgentInfo(
|
|
67
|
+
role=AgentRole.GEOLOCATION_ANALYST,
|
|
68
|
+
description="Geolocation investigation — satellite imagery, location verification",
|
|
69
|
+
capabilities=["satellite_analysis", "geolocation", "reverse_image_search"],
|
|
70
|
+
tool_count=3,
|
|
71
|
+
),
|
|
72
|
+
"image_analyst": AgentInfo(
|
|
73
|
+
role=AgentRole.IMAGE_ANALYST,
|
|
74
|
+
description="Image & video investigation — EXIF analysis, deepfake detection, visual forensics",
|
|
75
|
+
capabilities=["exif_analysis", "reverse_image_search", "deepfake_detection"],
|
|
76
|
+
tool_count=3,
|
|
77
|
+
),
|
|
78
|
+
"people_searcher": AgentInfo(
|
|
79
|
+
role=AgentRole.PEOPLE_SEARCHER,
|
|
80
|
+
description="People investigation — public records, professional profiles, background checks",
|
|
81
|
+
capabilities=["people_search", "professional_profiles", "public_records"],
|
|
82
|
+
tool_count=3,
|
|
83
|
+
),
|
|
84
|
+
"darkweb_scout": AgentInfo(
|
|
85
|
+
role=AgentRole.DARKWEB_SCOUT,
|
|
86
|
+
description="Dark web investigation — .onion sites, forums, marketplaces",
|
|
87
|
+
capabilities=["darkweb_search", "onion_scan", "forum_monitoring"],
|
|
88
|
+
tool_count=3,
|
|
89
|
+
),
|
|
90
|
+
"cross_referencer": AgentInfo(
|
|
91
|
+
role=AgentRole.CROSS_REFERENCER,
|
|
92
|
+
description="Cross-references findings across agents and external sources",
|
|
93
|
+
capabilities=["cross_reference", "entity_linking", "pattern_detection"],
|
|
94
|
+
tool_count=2,
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_all_agents() -> Dict[str, AgentInfo]:
|
|
100
|
+
"""Return all registered agents."""
|
|
101
|
+
return _AGENTS
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Shared protocol — data models used across Watson's investigation pipeline.
|
|
2
|
+
|
|
3
|
+
This module is the canonical source of Finding, AgentRole, SourceClass, and
|
|
4
|
+
AgentContext. All orchestration, resolution, and synthesis modules import from
|
|
5
|
+
here to avoid circular dependencies and divergent copies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AgentRole(str, Enum):
|
|
18
|
+
"""Roles for specialized investigation agents."""
|
|
19
|
+
ORCHESTRATOR = "orchestrator"
|
|
20
|
+
RECON = "recon"
|
|
21
|
+
SOCIAL = "social"
|
|
22
|
+
CORPORATE = "corporate"
|
|
23
|
+
CRYPTO = "crypto"
|
|
24
|
+
GEO = "geo"
|
|
25
|
+
MEDIA = "media"
|
|
26
|
+
PEOPLE = "people"
|
|
27
|
+
DARK = "dark"
|
|
28
|
+
|
|
29
|
+
# Aliases for backward compat with agents/__init__.py
|
|
30
|
+
COORDINATOR = "orchestrator"
|
|
31
|
+
DOMAIN_RESEARCHER = "recon"
|
|
32
|
+
SOCIAL_MEDIA_ANALYST = "social"
|
|
33
|
+
CORPORATE_ANALYST = "corporate"
|
|
34
|
+
CRYPTO_ANALYST = "crypto"
|
|
35
|
+
GEOLOCATION_ANALYST = "geo"
|
|
36
|
+
IMAGE_ANALYST = "media"
|
|
37
|
+
PEOPLE_SEARCHER = "people"
|
|
38
|
+
DARKWEB_SCOUT = "dark"
|
|
39
|
+
CROSS_REFERENCER = "orchestrator"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SourceClass(str, Enum):
|
|
43
|
+
"""Source classification tiers (Bazzell-standard)."""
|
|
44
|
+
PRIMARY = "primary" # Court records, gov registries, OFAC sanctions
|
|
45
|
+
SECONDARY = "secondary" # News articles, corporate registries, breach data
|
|
46
|
+
TERTIARY = "tertiary" # Wikipedia, social media, self-reported
|
|
47
|
+
UNVERIFIED = "unverified" # Anonymous sources, forum posts, single mentions
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Finding:
|
|
52
|
+
"""Intelligence finding with source tracking and confidence.
|
|
53
|
+
|
|
54
|
+
This is the canonical Finding used across the entire pipeline.
|
|
55
|
+
The orchestration engine has its own Finding (in engine.py) but
|
|
56
|
+
both are duck-type compatible — resolution and synthesis use getattr().
|
|
57
|
+
"""
|
|
58
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
59
|
+
title: str = ""
|
|
60
|
+
description: str = ""
|
|
61
|
+
agent: AgentRole = AgentRole.ORCHESTRATOR
|
|
62
|
+
source_class: SourceClass = SourceClass.SECONDARY
|
|
63
|
+
source_type: str = "web_search"
|
|
64
|
+
source_url: str = ""
|
|
65
|
+
source_tier: str = "SECONDARY"
|
|
66
|
+
confidence: float = 0.5
|
|
67
|
+
entities: list[dict] = field(default_factory=list)
|
|
68
|
+
evidence: list[str] = field(default_factory=list)
|
|
69
|
+
phase: str = ""
|
|
70
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def tier(self) -> str:
|
|
74
|
+
if self.confidence >= 0.90:
|
|
75
|
+
return "CONFIRMED"
|
|
76
|
+
if self.confidence >= 0.70:
|
|
77
|
+
return "PROBABLE"
|
|
78
|
+
if self.confidence >= 0.40:
|
|
79
|
+
return "POSSIBLE"
|
|
80
|
+
if self.confidence >= 0.10:
|
|
81
|
+
return "UNLIKELY"
|
|
82
|
+
return "UNSUBSTANTIATED"
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def agent_value(self) -> str:
|
|
86
|
+
return self.agent.value if isinstance(self.agent, AgentRole) else str(self.agent)
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict:
|
|
89
|
+
return {
|
|
90
|
+
"id": self.id,
|
|
91
|
+
"title": self.title,
|
|
92
|
+
"description": self.description,
|
|
93
|
+
"agent": self.agent_value,
|
|
94
|
+
"source_class": self.source_class.value if isinstance(self.source_class, SourceClass) else str(self.source_class),
|
|
95
|
+
"source_type": self.source_type,
|
|
96
|
+
"source_url": self.source_url,
|
|
97
|
+
"source_tier": self.source_tier,
|
|
98
|
+
"confidence": self.confidence,
|
|
99
|
+
"tier": self.tier,
|
|
100
|
+
"entities": self.entities,
|
|
101
|
+
"phase": self.phase,
|
|
102
|
+
"timestamp": self.timestamp,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class AgentContext:
|
|
108
|
+
"""Context passed to investigation agents."""
|
|
109
|
+
query: str = ""
|
|
110
|
+
target_type: str = "unknown"
|
|
111
|
+
target_value: str = ""
|
|
112
|
+
focus: str = ""
|
|
113
|
+
depth: int = 2
|
|
114
|
+
findings: list[Finding] = field(default_factory=list)
|
|
115
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── Target classification ────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def classify_target(query: str) -> tuple[str, str]:
|
|
121
|
+
"""Quick deterministic target classification.
|
|
122
|
+
|
|
123
|
+
Returns (target_type, target_value).
|
|
124
|
+
Used by tests and as a fast-path before the LLM classifier.
|
|
125
|
+
"""
|
|
126
|
+
import re
|
|
127
|
+
q = query.strip()
|
|
128
|
+
|
|
129
|
+
# Email
|
|
130
|
+
if re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', q):
|
|
131
|
+
return "email", q.lower()
|
|
132
|
+
|
|
133
|
+
# Ethereum wallet
|
|
134
|
+
if re.match(r'^0x[a-fA-F0-9]{40}$', q):
|
|
135
|
+
return "crypto", q
|
|
136
|
+
|
|
137
|
+
# Bitcoin address
|
|
138
|
+
if re.match(r'^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$', q):
|
|
139
|
+
return "crypto", q
|
|
140
|
+
|
|
141
|
+
# IPv4
|
|
142
|
+
if re.match(r'^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$', q):
|
|
143
|
+
return "ip", q
|
|
144
|
+
|
|
145
|
+
# .onion
|
|
146
|
+
if '.onion' in q:
|
|
147
|
+
return "onion", q
|
|
148
|
+
|
|
149
|
+
# GPS coordinates
|
|
150
|
+
if re.match(r'^-?\d{1,3}\.\d+,\s*-?\d{1,3}\.\d+$', q):
|
|
151
|
+
return "gps", q
|
|
152
|
+
|
|
153
|
+
# Flight code
|
|
154
|
+
if re.match(r'^[A-Z]{2}\d{2,4}$', q):
|
|
155
|
+
return "flight", q
|
|
156
|
+
|
|
157
|
+
# Image file
|
|
158
|
+
if re.match(r'^.*\.(jpg|jpeg|png|gif|bmp|webp|tiff)$', q, re.I):
|
|
159
|
+
return "image", q
|
|
160
|
+
|
|
161
|
+
# Domain
|
|
162
|
+
if re.match(r'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:com|org|net|io|gov|edu|co|me|ai|app|dev|xyz|info|biz)$', q, re.I):
|
|
163
|
+
return "domain", q.lower()
|
|
164
|
+
|
|
165
|
+
# Breach keywords
|
|
166
|
+
if any(kw in q.lower() for kw in ['breach', 'leaked', 'passwords', 'data dump']):
|
|
167
|
+
return "breach", q
|
|
168
|
+
|
|
169
|
+
# Company indicators
|
|
170
|
+
if any(suffix in q for suffix in ['Inc', 'LLC', 'Ltd', 'Corp', 'GmbH', 'SA', 'AG', 'Group']):
|
|
171
|
+
return "company", q
|
|
172
|
+
|
|
173
|
+
# Default: person
|
|
174
|
+
if ' ' in q and len(q.split()) >= 2:
|
|
175
|
+
return "person", q
|
|
176
|
+
|
|
177
|
+
# Single word — could be username or company
|
|
178
|
+
return "person", q
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def select_agents(target_type: str) -> list[AgentRole]:
|
|
182
|
+
"""Select which agents to dispatch for a given target type."""
|
|
183
|
+
mapping = {
|
|
184
|
+
"domain": [AgentRole.RECON, AgentRole.CORPORATE, AgentRole.PEOPLE],
|
|
185
|
+
"email": [AgentRole.SOCIAL, AgentRole.DARK, AgentRole.RECON],
|
|
186
|
+
"ip": [AgentRole.RECON, AgentRole.GEO],
|
|
187
|
+
"crypto": [AgentRole.CRYPTO, AgentRole.DARK],
|
|
188
|
+
"gps": [AgentRole.GEO, AgentRole.MEDIA],
|
|
189
|
+
"image": [AgentRole.MEDIA, AgentRole.GEO],
|
|
190
|
+
"onion": [AgentRole.DARK, AgentRole.RECON],
|
|
191
|
+
"company": [AgentRole.CORPORATE, AgentRole.RECON, AgentRole.SOCIAL],
|
|
192
|
+
"person": [AgentRole.SOCIAL, AgentRole.PEOPLE, AgentRole.RECON, AgentRole.CORPORATE],
|
|
193
|
+
"breach": [AgentRole.DARK, AgentRole.SOCIAL],
|
|
194
|
+
"flight": [AgentRole.GEO, AgentRole.RECON],
|
|
195
|
+
}
|
|
196
|
+
return mapping.get(target_type, [AgentRole.RECON, AgentRole.SOCIAL])
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Auth middleware — API key validation, workspace auth.
|
|
2
|
+
|
|
3
|
+
Delegates to watson.web.middleware for actual API key checking.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import JSONResponse, Response
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthMiddleware(BaseHTTPMiddleware):
|
|
17
|
+
"""FastAPI middleware: validate API keys from X-API-Key or Authorization header.
|
|
18
|
+
|
|
19
|
+
Delegates to watson.web.middleware.validate_api_key() when available,
|
|
20
|
+
falling back to WATSON_API_KEYS env var check.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_PUBLIC_PATHS: set[str] = {"/", "/health", "/docs", "/openapi.json", "/redoc",
|
|
24
|
+
"/static", "/api/settings", "/api/agent/detect-intent"}
|
|
25
|
+
|
|
26
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
27
|
+
path = request.url.path
|
|
28
|
+
|
|
29
|
+
# Allow public paths
|
|
30
|
+
if path in self._PUBLIC_PATHS or path.startswith("/static"):
|
|
31
|
+
return await call_next(request)
|
|
32
|
+
|
|
33
|
+
# Extract API key
|
|
34
|
+
api_key = request.headers.get("X-API-Key", "")
|
|
35
|
+
if not api_key:
|
|
36
|
+
auth_header = request.headers.get("Authorization", "")
|
|
37
|
+
if auth_header.startswith("Bearer "):
|
|
38
|
+
api_key = auth_header[7:]
|
|
39
|
+
|
|
40
|
+
# Validate
|
|
41
|
+
valid = self._validate(api_key)
|
|
42
|
+
if not valid:
|
|
43
|
+
return JSONResponse(
|
|
44
|
+
{"error": "Unauthorized — provide API key via X-API-Key or Bearer token"},
|
|
45
|
+
status_code=401,
|
|
46
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return await call_next(request)
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _validate(api_key: str) -> bool:
|
|
53
|
+
# Try delegating to existing watson.web.middleware
|
|
54
|
+
try:
|
|
55
|
+
from watson.web.middleware import validate_api_key
|
|
56
|
+
return validate_api_key(api_key)
|
|
57
|
+
except ImportError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
# Fallback: check env
|
|
61
|
+
valid_keys_raw = os.environ.get("WATSON_API_KEYS", "")
|
|
62
|
+
if not valid_keys_raw:
|
|
63
|
+
return True # Dev mode — no keys configured
|
|
64
|
+
valid_keys = {k.strip() for k in valid_keys_raw.split(",") if k.strip()}
|
|
65
|
+
hermes_key = os.environ.get("HERMES_API_KEY", "")
|
|
66
|
+
if hermes_key:
|
|
67
|
+
valid_keys.add(hermes_key)
|
|
68
|
+
return api_key in valid_keys
|
src/watson/auth/store.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Auth store — workspace/user management backed by a JSON file.
|
|
2
|
+
|
|
3
|
+
Minimal implementation for Phase B auth endpoints.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserRole(str, Enum):
|
|
19
|
+
ADMIN = "admin"
|
|
20
|
+
ANALYST = "analyst"
|
|
21
|
+
VIEWER = "viewer"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class User:
|
|
26
|
+
user_id: str
|
|
27
|
+
workspace_id: str
|
|
28
|
+
email: str
|
|
29
|
+
role: UserRole = UserRole.ANALYST
|
|
30
|
+
created_at: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Workspace:
|
|
35
|
+
workspace_id: str
|
|
36
|
+
name: str
|
|
37
|
+
created_at: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthStore:
|
|
41
|
+
"""In-memory JSON-backed auth store."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, path: str | Path | None = None):
|
|
44
|
+
if path is None:
|
|
45
|
+
path = Path.home() / ".watson" / "auth_store.json"
|
|
46
|
+
self._path = Path(path)
|
|
47
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
self._data: dict = self._load()
|
|
49
|
+
|
|
50
|
+
def _load(self) -> dict:
|
|
51
|
+
if self._path.exists():
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(self._path.read_text())
|
|
54
|
+
except (json.JSONDecodeError, OSError):
|
|
55
|
+
pass
|
|
56
|
+
return {"workspaces": {}, "users": {}, "api_keys": {}, "tokens": {}}
|
|
57
|
+
|
|
58
|
+
def _save(self):
|
|
59
|
+
self._path.write_text(json.dumps(self._data, indent=2, default=str))
|
|
60
|
+
|
|
61
|
+
def create_workspace(self, name: str, email: str) -> tuple[Workspace, User]:
|
|
62
|
+
ws_id = f"ws-{uuid.uuid4().hex[:8]}"
|
|
63
|
+
user_id = f"usr-{uuid.uuid4().hex[:8]}"
|
|
64
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
65
|
+
|
|
66
|
+
ws = Workspace(workspace_id=ws_id, name=name, created_at=now)
|
|
67
|
+
user = User(user_id=user_id, workspace_id=ws_id, email=email,
|
|
68
|
+
role=UserRole.ADMIN, created_at=now)
|
|
69
|
+
|
|
70
|
+
self._data["workspaces"][ws_id] = {"workspace_id": ws_id, "name": name, "created_at": now}
|
|
71
|
+
self._data["users"][user_id] = {"user_id": user_id, "workspace_id": ws_id,
|
|
72
|
+
"email": email, "role": "admin", "created_at": now}
|
|
73
|
+
self._save()
|
|
74
|
+
return ws, user
|
|
75
|
+
|
|
76
|
+
def generate_api_key(self, user_id: str) -> str:
|
|
77
|
+
key = f"watson-{uuid.uuid4().hex}"
|
|
78
|
+
self._data["api_keys"][key] = user_id
|
|
79
|
+
self._save()
|
|
80
|
+
return key
|
|
81
|
+
|
|
82
|
+
def validate_api_key(self, api_key: str) -> Optional[User]:
|
|
83
|
+
user_id = self._data["api_keys"].get(api_key)
|
|
84
|
+
if user_id:
|
|
85
|
+
return self.get_user(user_id)
|
|
86
|
+
# Fallback: check env keys
|
|
87
|
+
valid = os.environ.get("WATSON_API_KEYS", "").split(",")
|
|
88
|
+
if api_key in [k.strip() for k in valid if k.strip()]:
|
|
89
|
+
# Return a synthetic user for env-based keys
|
|
90
|
+
return User(user_id="env-user", workspace_id="default",
|
|
91
|
+
email="admin@watson.local", role=UserRole.ADMIN)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def create_token(self, user: User) -> "Token":
|
|
95
|
+
token_str = f"tok-{uuid.uuid4().hex}"
|
|
96
|
+
expires = (datetime.now(timezone.utc) + timedelta(hours=24)).isoformat()
|
|
97
|
+
self._data["tokens"][token_str] = {"user_id": user.user_id, "expires_at": expires}
|
|
98
|
+
self._save()
|
|
99
|
+
return Token(token=token_str, expires_at=expires, user_id=user.user_id)
|
|
100
|
+
|
|
101
|
+
def get_user(self, user_id: str) -> Optional[User]:
|
|
102
|
+
u = self._data["users"].get(user_id)
|
|
103
|
+
if u:
|
|
104
|
+
return User(user_id=u["user_id"], workspace_id=u["workspace_id"],
|
|
105
|
+
email=u.get("email", ""), role=UserRole(u.get("role", "analyst")),
|
|
106
|
+
created_at=u.get("created_at", ""))
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def get_user_by_email(self, email: str, workspace_id: str) -> Optional[User]:
|
|
110
|
+
for u in self._data["users"].values():
|
|
111
|
+
if u.get("email") == email and u.get("workspace_id") == workspace_id:
|
|
112
|
+
return User(**{k: v for k, v in u.items() if k in
|
|
113
|
+
("user_id", "workspace_id", "email", "role", "created_at")})
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def get_workspace(self, workspace_id: str) -> Optional[Workspace]:
|
|
117
|
+
w = self._data["workspaces"].get(workspace_id)
|
|
118
|
+
if w:
|
|
119
|
+
return Workspace(workspace_id=w["workspace_id"],
|
|
120
|
+
name=w.get("name", ""), created_at=w.get("created_at", ""))
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def list_users(self, workspace_id: str) -> list[User]:
|
|
124
|
+
return [User(**{k: v for k, v in u.items() if k in
|
|
125
|
+
("user_id", "workspace_id", "email", "role", "created_at")})
|
|
126
|
+
for u in self._data["users"].values()
|
|
127
|
+
if u.get("workspace_id") == workspace_id]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class Token:
|
|
132
|
+
token: str
|
|
133
|
+
expires_at: str
|
|
134
|
+
user_id: str = ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Singleton
|
|
138
|
+
_auth_store: Optional[AuthStore] = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_auth_store() -> AuthStore:
|
|
142
|
+
global _auth_store
|
|
143
|
+
if _auth_store is None:
|
|
144
|
+
_auth_store = AuthStore()
|
|
145
|
+
return _auth_store
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Conversation Agent — chat interface with investigation capabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("watson.conversation")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConversationAgent:
|
|
11
|
+
"""Chat agent that can investigate and converse."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, api_key: str = ""):
|
|
14
|
+
self.api_key = api_key
|
|
15
|
+
self._history: list[dict] = []
|
|
16
|
+
|
|
17
|
+
async def chat(
|
|
18
|
+
self,
|
|
19
|
+
message: str,
|
|
20
|
+
findings: list | None = None,
|
|
21
|
+
history: list | None = None,
|
|
22
|
+
on_event: Optional[Callable[[str, Any], None]] = None,
|
|
23
|
+
) -> dict:
|
|
24
|
+
"""Process a chat message. Returns response dict with events."""
|
|
25
|
+
|
|
26
|
+
events = []
|
|
27
|
+
|
|
28
|
+
# Determine if this is an investigation request
|
|
29
|
+
investigation_keywords = [
|
|
30
|
+
"investigate", "look up", "search", "find", "who is",
|
|
31
|
+
"what is", "research", "dig into", "analyze", "check",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
msg_lower = message.lower().strip()
|
|
35
|
+
is_investigation = any(kw in msg_lower for kw in investigation_keywords)
|
|
36
|
+
|
|
37
|
+
if is_investigation:
|
|
38
|
+
# Extract the target from the message
|
|
39
|
+
target = message
|
|
40
|
+
for kw in investigation_keywords:
|
|
41
|
+
idx = msg_lower.find(kw)
|
|
42
|
+
if idx >= 0:
|
|
43
|
+
target = message[idx + len(kw):].strip().rstrip(".!?")
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
if target:
|
|
47
|
+
try:
|
|
48
|
+
from .agent import OSINTAgent
|
|
49
|
+
agent = OSINTAgent(depth=2)
|
|
50
|
+
inv_events = await agent.investigate(
|
|
51
|
+
query=target,
|
|
52
|
+
on_event=on_event,
|
|
53
|
+
)
|
|
54
|
+
events.extend(inv_events)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.warning("conversation_investigation_failed: %s", e)
|
|
57
|
+
events.append({
|
|
58
|
+
"event": "message",
|
|
59
|
+
"data": {"content": f"I tried to investigate '{target}' but encountered an error: {e}"},
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
if not events:
|
|
63
|
+
events.append({
|
|
64
|
+
"event": "message",
|
|
65
|
+
"data": {"content": f"I can help you investigate. Try asking me to investigate a person, company, or domain."},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return {"events": events, "investigation_triggered": is_investigation}
|
|
File without changes
|