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
watson/mcp_server.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""mcp_server.py — REST + MCP Protocol API.
|
|
2
|
+
|
|
3
|
+
Write endpoints require API key auth. Read endpoints are open.
|
|
4
|
+
Set MCP_API_KEY env var on the server. Clients pass X-API-Key header.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI, HTTPException, Query, Request
|
|
18
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
19
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
20
|
+
from pydantic import BaseModel, field_validator
|
|
21
|
+
|
|
22
|
+
from .graph import KnowledgeGraph
|
|
23
|
+
|
|
24
|
+
# ── API Key Auth ──────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
MCP_API_KEY = os.environ.get("MCP_API_KEY", "")
|
|
27
|
+
MCP_READ_KEY = os.environ.get("MCP_READ_KEY", "") # If set, read endpoints also require auth
|
|
28
|
+
MCP_DEV_MODE = os.environ.get("WATSON_DEV", "") in ("1", "true", "yes")
|
|
29
|
+
|
|
30
|
+
if not MCP_API_KEY and not MCP_DEV_MODE:
|
|
31
|
+
print("⚠ WARNING: MCP_API_KEY not set. Write endpoints are BLOCKED (503).")
|
|
32
|
+
print(" Set MCP_API_KEY env var to enable publishing, or WATSON_DEV=1 for dev mode.")
|
|
33
|
+
|
|
34
|
+
if MCP_READ_KEY:
|
|
35
|
+
print("🔒 MCP_READ_KEY set — read endpoints require X-API-Key header.")
|
|
36
|
+
else:
|
|
37
|
+
print("📖 MCP_READ_KEY not set — read endpoints are OPEN (public).")
|
|
38
|
+
print(" Set MCP_READ_KEY env var to require authentication for reads.")
|
|
39
|
+
|
|
40
|
+
# ── Rate Limiting ───────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
_MAX_ENTITIES_PER_REQUEST = 500
|
|
43
|
+
_MAX_ENTITIES_PER_MINUTE = 2000
|
|
44
|
+
_rate_window: defaultdict[str, list[float]] = defaultdict(list)
|
|
45
|
+
|
|
46
|
+
def _check_rate_limit(key: str) -> bool:
|
|
47
|
+
"""Return True if within limit, False if exceeded."""
|
|
48
|
+
now = time.time()
|
|
49
|
+
window = [t for t in _rate_window[key] if now - t < 60]
|
|
50
|
+
_rate_window[key] = window
|
|
51
|
+
if len(window) >= _MAX_ENTITIES_PER_MINUTE:
|
|
52
|
+
return False
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
def _record_rate(key: str, count: int):
|
|
56
|
+
now = time.time()
|
|
57
|
+
_rate_window[key].extend([now] * count)
|
|
58
|
+
|
|
59
|
+
async def api_key_middleware(request: Request, call_next):
|
|
60
|
+
"""Require API key for write endpoints. Read endpoints are open
|
|
61
|
+
unless MCP_READ_KEY is set.
|
|
62
|
+
|
|
63
|
+
If MCP_API_KEY is not set and not in dev mode, writes are blocked
|
|
64
|
+
with a clear error message rather than silently allowing everything.
|
|
65
|
+
"""
|
|
66
|
+
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
|
|
67
|
+
key = request.headers.get("X-API-Key", "")
|
|
68
|
+
if not MCP_API_KEY:
|
|
69
|
+
# No key configured — block writes unless dev mode
|
|
70
|
+
if MCP_DEV_MODE:
|
|
71
|
+
return await call_next(request)
|
|
72
|
+
return JSONResponse(
|
|
73
|
+
{"error": "server_unconfigured",
|
|
74
|
+
"detail": "MCP_API_KEY not set on server. Set it to enable write operations."},
|
|
75
|
+
status_code=503,
|
|
76
|
+
)
|
|
77
|
+
if key != MCP_API_KEY:
|
|
78
|
+
return JSONResponse(
|
|
79
|
+
{"error": "unauthorized",
|
|
80
|
+
"detail": "Valid X-API-Key required for write operations"},
|
|
81
|
+
status_code=401,
|
|
82
|
+
)
|
|
83
|
+
elif MCP_READ_KEY and request.method == "GET":
|
|
84
|
+
key = request.headers.get("X-API-Key", "")
|
|
85
|
+
if key != MCP_READ_KEY:
|
|
86
|
+
# Also accept the write key for reads
|
|
87
|
+
if MCP_API_KEY and key == MCP_API_KEY:
|
|
88
|
+
return await call_next(request)
|
|
89
|
+
return JSONResponse(
|
|
90
|
+
{"error": "unauthorized",
|
|
91
|
+
"detail": "Valid X-API-Key required for read operations (MCP_READ_KEY is set)"},
|
|
92
|
+
status_code=401,
|
|
93
|
+
)
|
|
94
|
+
return await call_next(request)
|
|
95
|
+
|
|
96
|
+
# ── MCP Protocol Types ─────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
class MCPTool(BaseModel):
|
|
99
|
+
name: str
|
|
100
|
+
description: str
|
|
101
|
+
inputSchema: dict = {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MCPListToolsResponse(BaseModel):
|
|
105
|
+
tools: list[MCPTool]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MCPCallToolRequest(BaseModel):
|
|
109
|
+
name: str
|
|
110
|
+
arguments: dict = {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class MCPCallToolResponse(BaseModel):
|
|
114
|
+
content: list[dict]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── App ─────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
mcp = FastAPI(
|
|
120
|
+
title="Watson MCP Server",
|
|
121
|
+
description="Community OSINT investigation knowledge graph",
|
|
122
|
+
version="0.1.0",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
mcp.add_middleware(
|
|
126
|
+
CORSMiddleware,
|
|
127
|
+
allow_origins=["*"],
|
|
128
|
+
allow_methods=["*"],
|
|
129
|
+
allow_headers=["*"],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# API key auth — read open, write gated
|
|
133
|
+
mcp.middleware("http")(api_key_middleware)
|
|
134
|
+
|
|
135
|
+
graph = KnowledgeGraph()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── MCP Discovery ───────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
@mcp.get("/")
|
|
141
|
+
async def root():
|
|
142
|
+
"""MCP server info."""
|
|
143
|
+
return {
|
|
144
|
+
"name": "Watson MCP Server",
|
|
145
|
+
"version": "0.1.0",
|
|
146
|
+
"description": "Community OSINT investigation knowledge graph",
|
|
147
|
+
"protocol": "mcp",
|
|
148
|
+
"stats": graph.stats(),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@mcp.get("/.well-known/mcp", response_model=MCPListToolsResponse)
|
|
153
|
+
async def list_tools():
|
|
154
|
+
"""MCP tool discovery."""
|
|
155
|
+
return MCPListToolsResponse(tools=[
|
|
156
|
+
MCPTool(
|
|
157
|
+
name="watson_search",
|
|
158
|
+
description="Search the Watson community knowledge graph for entities, cases, and relations",
|
|
159
|
+
inputSchema={
|
|
160
|
+
"type": "object",
|
|
161
|
+
"properties": {
|
|
162
|
+
"query": {"type": "string", "description": "Search term"},
|
|
163
|
+
"limit": {"type": "integer", "default": 20},
|
|
164
|
+
},
|
|
165
|
+
"required": ["query"],
|
|
166
|
+
},
|
|
167
|
+
),
|
|
168
|
+
MCPTool(
|
|
169
|
+
name="watson_traverse",
|
|
170
|
+
description="Explore connections from an entity in the knowledge graph",
|
|
171
|
+
inputSchema={
|
|
172
|
+
"type": "object",
|
|
173
|
+
"properties": {
|
|
174
|
+
"entity_value": {"type": "string", "description": "Entity to traverse from"},
|
|
175
|
+
"entity_type": {"type": "string", "description": "person, domain, company, email, etc."},
|
|
176
|
+
},
|
|
177
|
+
"required": ["entity_value"],
|
|
178
|
+
},
|
|
179
|
+
),
|
|
180
|
+
MCPTool(
|
|
181
|
+
name="watson_case",
|
|
182
|
+
description="Retrieve a published investigation case",
|
|
183
|
+
inputSchema={
|
|
184
|
+
"type": "object",
|
|
185
|
+
"properties": {
|
|
186
|
+
"case_id": {"type": "string", "description": "Case ID (e.g., CASE-ABC12345)"},
|
|
187
|
+
},
|
|
188
|
+
"required": ["case_id"],
|
|
189
|
+
},
|
|
190
|
+
),
|
|
191
|
+
MCPTool(
|
|
192
|
+
name="watson_stats",
|
|
193
|
+
description="Get statistics about the community knowledge graph",
|
|
194
|
+
inputSchema={"type": "object", "properties": {}},
|
|
195
|
+
),
|
|
196
|
+
MCPTool(
|
|
197
|
+
name="watson_context",
|
|
198
|
+
description="Check if an investigation target has prior findings in the graph",
|
|
199
|
+
inputSchema={
|
|
200
|
+
"type": "object",
|
|
201
|
+
"properties": {
|
|
202
|
+
"query": {"type": "string", "description": "Investigation target"},
|
|
203
|
+
},
|
|
204
|
+
"required": ["query"],
|
|
205
|
+
},
|
|
206
|
+
),
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── MCP Tool Calls ──────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
@mcp.post("/mcp/call-tool", response_model=MCPCallToolResponse)
|
|
213
|
+
async def call_tool(request: MCPCallToolRequest):
|
|
214
|
+
"""Handle MCP tool calls."""
|
|
215
|
+
args = request.arguments
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
if request.name == "watson_search":
|
|
219
|
+
entities = graph.search_entities(
|
|
220
|
+
args.get("query", ""),
|
|
221
|
+
limit=args.get("limit", 20),
|
|
222
|
+
)
|
|
223
|
+
return MCPCallToolResponse(content=[{
|
|
224
|
+
"type": "text",
|
|
225
|
+
"text": json.dumps({
|
|
226
|
+
"query": args.get("query"),
|
|
227
|
+
"results": [e.to_dict() for e in entities],
|
|
228
|
+
"count": len(entities),
|
|
229
|
+
}, indent=2),
|
|
230
|
+
}])
|
|
231
|
+
|
|
232
|
+
elif request.name == "watson_traverse":
|
|
233
|
+
result = graph.traverse(
|
|
234
|
+
entity_value=args["entity_value"],
|
|
235
|
+
entity_type=args.get("entity_type"),
|
|
236
|
+
)
|
|
237
|
+
return MCPCallToolResponse(content=[{
|
|
238
|
+
"type": "text",
|
|
239
|
+
"text": json.dumps(result, indent=2),
|
|
240
|
+
}])
|
|
241
|
+
|
|
242
|
+
elif request.name == "watson_case":
|
|
243
|
+
from pathlib import Path
|
|
244
|
+
cases_dir = Path.home() / "watson-cases"
|
|
245
|
+
case_path = cases_dir / f"{args['case_id']}.md"
|
|
246
|
+
if case_path.exists():
|
|
247
|
+
content = case_path.read_text()
|
|
248
|
+
return MCPCallToolResponse(content=[{
|
|
249
|
+
"type": "text",
|
|
250
|
+
"text": content,
|
|
251
|
+
}])
|
|
252
|
+
else:
|
|
253
|
+
return MCPCallToolResponse(content=[{
|
|
254
|
+
"type": "text",
|
|
255
|
+
"text": json.dumps({"error": "Case not found", "case_id": args["case_id"]}),
|
|
256
|
+
}])
|
|
257
|
+
|
|
258
|
+
elif request.name == "watson_stats":
|
|
259
|
+
stats = graph.stats()
|
|
260
|
+
return MCPCallToolResponse(content=[{
|
|
261
|
+
"type": "text",
|
|
262
|
+
"text": json.dumps(stats, indent=2),
|
|
263
|
+
}])
|
|
264
|
+
|
|
265
|
+
elif request.name == "watson_context":
|
|
266
|
+
context = graph.context_for_investigation(args["query"])
|
|
267
|
+
return MCPCallToolResponse(content=[{
|
|
268
|
+
"type": "text",
|
|
269
|
+
"text": json.dumps(context, indent=2),
|
|
270
|
+
}])
|
|
271
|
+
|
|
272
|
+
else:
|
|
273
|
+
raise HTTPException(404, f"Unknown tool: {request.name}")
|
|
274
|
+
|
|
275
|
+
except Exception as e:
|
|
276
|
+
return MCPCallToolResponse(content=[{
|
|
277
|
+
"type": "text",
|
|
278
|
+
"text": json.dumps({"error": str(e)}),
|
|
279
|
+
}])
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ── REST API (for non-MCP clients) ──────────────────────────────
|
|
283
|
+
|
|
284
|
+
@mcp.get("/api/search")
|
|
285
|
+
async def api_search(q: str = Query(...), limit: int = 20):
|
|
286
|
+
"""Search the community graph via REST."""
|
|
287
|
+
entities = graph.search_entities(q, limit=limit)
|
|
288
|
+
return {
|
|
289
|
+
"query": q,
|
|
290
|
+
"results": [e.to_dict() for e in entities],
|
|
291
|
+
"count": len(entities),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@mcp.get("/api/traverse/{entity_value:path}")
|
|
296
|
+
async def api_traverse(entity_value: str, entity_type: Optional[str] = None):
|
|
297
|
+
"""Traverse graph via REST."""
|
|
298
|
+
result = graph.traverse(entity_value, entity_type=entity_type)
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@mcp.get("/api/stats")
|
|
303
|
+
async def api_stats():
|
|
304
|
+
"""Graph stats via REST."""
|
|
305
|
+
return graph.stats()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@mcp.get("/api/cases")
|
|
309
|
+
async def api_cases():
|
|
310
|
+
"""List published cases."""
|
|
311
|
+
from pathlib import Path
|
|
312
|
+
cases_dir = Path.home() / "watson-cases"
|
|
313
|
+
cases = []
|
|
314
|
+
for f in sorted(cases_dir.glob("CASE-*.md"), reverse=True):
|
|
315
|
+
cases.append({
|
|
316
|
+
"id": f.stem,
|
|
317
|
+
"size": f.stat().st_size,
|
|
318
|
+
})
|
|
319
|
+
return {"cases": cases, "count": len(cases)}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@mcp.get("/api/cases/{case_id}")
|
|
323
|
+
async def api_case(case_id: str):
|
|
324
|
+
"""Get a published case."""
|
|
325
|
+
from pathlib import Path
|
|
326
|
+
case_path = Path.home() / "watson-cases" / f"{case_id}.md"
|
|
327
|
+
if case_path.exists():
|
|
328
|
+
return PlainTextResponse(case_path.read_text(), media_type="text/markdown")
|
|
329
|
+
raise HTTPException(404, "Case not found")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ── Publishing / Ingest ─────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
# Valid entity types that can be ingested
|
|
335
|
+
_VALID_ENTITY_TYPES = {
|
|
336
|
+
"person", "organization", "company", "domain", "email",
|
|
337
|
+
"ip", "ipv4", "ipv6", "wallet", "crypto_address", "phone",
|
|
338
|
+
"url", "hash", "md5", "sha256", "cve", "asn", "location",
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
# Max lengths
|
|
342
|
+
_MAX_VALUE_LEN = 500
|
|
343
|
+
_MAX_SOURCE_LEN = 200
|
|
344
|
+
|
|
345
|
+
# Garbage patterns — values that are obviously not real entities
|
|
346
|
+
_GARBAGE_PATTERNS = [
|
|
347
|
+
re.compile(r"^test\d*$", re.I),
|
|
348
|
+
re.compile(r"^asdf+$", re.I),
|
|
349
|
+
re.compile(r"^foo(bar)?$", re.I),
|
|
350
|
+
re.compile(r"^hello\d*$", re.I),
|
|
351
|
+
re.compile(r"^n/?a$", re.I),
|
|
352
|
+
re.compile(r"^unknown$", re.I),
|
|
353
|
+
re.compile(r"^none$", re.I),
|
|
354
|
+
re.compile(r"^example", re.I),
|
|
355
|
+
re.compile(r"^placeholder", re.I),
|
|
356
|
+
re.compile(r"^sample", re.I),
|
|
357
|
+
re.compile(r"^lorem", re.I),
|
|
358
|
+
re.compile(r"^(.)\1{10,}$"), # Same char 10+ times
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
# Type-specific validation regexes
|
|
362
|
+
_TYPE_VALIDATORS: dict[str, re.Pattern] = {
|
|
363
|
+
"email": re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$"),
|
|
364
|
+
"domain": re.compile(r"^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$", re.I),
|
|
365
|
+
"ip": re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$"),
|
|
366
|
+
"ipv4": re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$"),
|
|
367
|
+
"ipv6": re.compile(r"^([0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}$", re.I),
|
|
368
|
+
"url": re.compile(r"^https?://", re.I),
|
|
369
|
+
"wallet": re.compile(r"^(0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{39,59})$"),
|
|
370
|
+
"crypto_address": re.compile(r"^(0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{39,59})$"),
|
|
371
|
+
"md5": re.compile(r"^[a-f0-9]{32}$", re.I),
|
|
372
|
+
"sha256": re.compile(r"^[a-f0-9]{64}$", re.I),
|
|
373
|
+
"cve": re.compile(r"^CVE-\d{4}-\d{4,}$", re.I),
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _validate_entity(entity: dict) -> str | None:
|
|
378
|
+
"""Validate a single entity dict. Returns error string or None if valid.
|
|
379
|
+
|
|
380
|
+
Does NOT trust the caller — every field is checked.
|
|
381
|
+
"""
|
|
382
|
+
value = str(entity.get("value", "")).strip()
|
|
383
|
+
type_ = str(entity.get("type", "")).strip().lower()
|
|
384
|
+
tier = str(entity.get("tier", "PROBABLE")).strip().upper()
|
|
385
|
+
|
|
386
|
+
# ── Value checks ──────────────────────────────
|
|
387
|
+
if not value:
|
|
388
|
+
return "entity.value is empty"
|
|
389
|
+
if len(value) > _MAX_VALUE_LEN:
|
|
390
|
+
return f"entity.value too long ({len(value)} > {_MAX_VALUE_LEN})"
|
|
391
|
+
if value != entity.get("value", ""):
|
|
392
|
+
return "entity.value has leading/trailing whitespace"
|
|
393
|
+
|
|
394
|
+
# ── Garbage detection ─────────────────────────
|
|
395
|
+
for pat in _GARBAGE_PATTERNS:
|
|
396
|
+
if pat.match(value):
|
|
397
|
+
return f"entity.value looks like garbage: '{value}'"
|
|
398
|
+
|
|
399
|
+
# ── Type checks ───────────────────────────────
|
|
400
|
+
if not type_:
|
|
401
|
+
return "entity.type is empty"
|
|
402
|
+
if type_ not in _VALID_ENTITY_TYPES:
|
|
403
|
+
return f"entity.type not recognized: '{type_}'. Valid: {sorted(_VALID_ENTITY_TYPES)}"
|
|
404
|
+
|
|
405
|
+
# ── Type-specific format validation ─────────────
|
|
406
|
+
validator = _TYPE_VALIDATORS.get(type_)
|
|
407
|
+
if validator and not validator.match(value):
|
|
408
|
+
return f"entity.value '{value}' doesn't match expected format for type '{type_}'"
|
|
409
|
+
|
|
410
|
+
# ── Tier checks ────────────────────────────────
|
|
411
|
+
if tier not in ("CONFIRMED", "PROBABLE", "POSSIBLE", "UNVERIFIED"):
|
|
412
|
+
return f"entity.tier not valid: '{tier}'"
|
|
413
|
+
|
|
414
|
+
# ── Source length ──────────────────────────────
|
|
415
|
+
source = str(entity.get("source", ""))
|
|
416
|
+
if len(source) > _MAX_SOURCE_LEN:
|
|
417
|
+
return f"entity.source too long ({len(source)} > {_MAX_SOURCE_LEN})"
|
|
418
|
+
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class IngestEntity(BaseModel):
|
|
423
|
+
value: str
|
|
424
|
+
type: str
|
|
425
|
+
source: str = ""
|
|
426
|
+
tier: str = "PROBABLE"
|
|
427
|
+
case_id: str = ""
|
|
428
|
+
|
|
429
|
+
@field_validator("value")
|
|
430
|
+
@classmethod
|
|
431
|
+
def value_not_empty(cls, v: str) -> str:
|
|
432
|
+
if not v or not v.strip():
|
|
433
|
+
raise ValueError("value cannot be empty")
|
|
434
|
+
if len(v.strip()) > _MAX_VALUE_LEN:
|
|
435
|
+
raise ValueError(f"value too long ({len(v.strip())} > {_MAX_VALUE_LEN})")
|
|
436
|
+
return v.strip()
|
|
437
|
+
|
|
438
|
+
@field_validator("type")
|
|
439
|
+
@classmethod
|
|
440
|
+
def type_valid(cls, v: str) -> str:
|
|
441
|
+
v = v.strip().lower()
|
|
442
|
+
if not v:
|
|
443
|
+
raise ValueError("type cannot be empty")
|
|
444
|
+
if v not in _VALID_ENTITY_TYPES:
|
|
445
|
+
raise ValueError(f"type '{v}' not recognized. Valid: {sorted(_VALID_ENTITY_TYPES)}")
|
|
446
|
+
return v
|
|
447
|
+
|
|
448
|
+
@field_validator("tier")
|
|
449
|
+
@classmethod
|
|
450
|
+
def tier_valid(cls, v: str) -> str:
|
|
451
|
+
v = v.strip().upper()
|
|
452
|
+
if v not in ("CONFIRMED", "PROBABLE", "POSSIBLE", "UNVERIFIED"):
|
|
453
|
+
raise ValueError(f"tier '{v}' not valid")
|
|
454
|
+
return v
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class IngestPayload(BaseModel):
|
|
458
|
+
case_id: str
|
|
459
|
+
target: str
|
|
460
|
+
target_type: str = ""
|
|
461
|
+
findings_count: int = 0
|
|
462
|
+
confirmed_count: int = 0
|
|
463
|
+
verifiability: str = ""
|
|
464
|
+
date: str = ""
|
|
465
|
+
entities: list[IngestEntity] = []
|
|
466
|
+
markdown: str = ""
|
|
467
|
+
|
|
468
|
+
@field_validator("entities")
|
|
469
|
+
@classmethod
|
|
470
|
+
def entities_not_too_many(cls, v: list) -> list:
|
|
471
|
+
if len(v) > _MAX_ENTITIES_PER_REQUEST:
|
|
472
|
+
raise ValueError(f"too many entities ({len(v)} > {_MAX_ENTITIES_PER_REQUEST}) — split into multiple requests")
|
|
473
|
+
return v
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# Track which cases have been published — persisted to JSONL
|
|
477
|
+
PUBLISHED_PATH = Path.home() / ".watson" / "graph" / "published.jsonl"
|
|
478
|
+
PUBLISHED_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
479
|
+
|
|
480
|
+
_published_cases: dict[str, dict] = {}
|
|
481
|
+
|
|
482
|
+
def _load_published():
|
|
483
|
+
"""Load published cases from disk."""
|
|
484
|
+
global _published_cases
|
|
485
|
+
if PUBLISHED_PATH.exists():
|
|
486
|
+
try:
|
|
487
|
+
for line in PUBLISHED_PATH.read_text().splitlines():
|
|
488
|
+
if line.strip():
|
|
489
|
+
d = json.loads(line)
|
|
490
|
+
_published_cases[d["case_id"]] = d
|
|
491
|
+
except Exception:
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
def _save_published():
|
|
495
|
+
"""Persist published cases to disk."""
|
|
496
|
+
try:
|
|
497
|
+
lines = [json.dumps(c) + "\n" for c in _published_cases.values()]
|
|
498
|
+
PUBLISHED_PATH.write_text("".join(lines))
|
|
499
|
+
except Exception:
|
|
500
|
+
pass
|
|
501
|
+
|
|
502
|
+
# Load on startup
|
|
503
|
+
_load_published()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@mcp.post("/api/ingest")
|
|
507
|
+
async def api_ingest(payload: IngestPayload, request: Request):
|
|
508
|
+
"""Ingest a published case into the community knowledge graph.
|
|
509
|
+
|
|
510
|
+
Two-layer validation: Pydantic catches schema errors; _validate_entity()
|
|
511
|
+
catches semantic garbage (test data, malformed values, wrong types).
|
|
512
|
+
Rate-limited per API key.
|
|
513
|
+
"""
|
|
514
|
+
key = request.headers.get("X-API-Key", "anonymous")
|
|
515
|
+
|
|
516
|
+
# ── Rate limit check ─────────────────────────
|
|
517
|
+
if not _check_rate_limit(key):
|
|
518
|
+
raise HTTPException(429, "Rate limit exceeded — max 2000 entities/minute per key")
|
|
519
|
+
|
|
520
|
+
# ── Double-layer entity validation (belt + suspenders) ──
|
|
521
|
+
rejected: list[dict] = []
|
|
522
|
+
valid_entities: list[IngestEntity] = []
|
|
523
|
+
|
|
524
|
+
for ent in payload.entities:
|
|
525
|
+
# Pydantic already validated the model, but convert back to dict
|
|
526
|
+
# for _validate_entity's semantic checks (garbage patterns, format)
|
|
527
|
+
err = _validate_entity(ent.model_dump())
|
|
528
|
+
if err:
|
|
529
|
+
rejected.append({"value": ent.value, "type": ent.type, "reason": err})
|
|
530
|
+
else:
|
|
531
|
+
valid_entities.append(ent)
|
|
532
|
+
|
|
533
|
+
# ── Ingest valid entities ─────────────────────
|
|
534
|
+
ingested = 0
|
|
535
|
+
for ent in valid_entities:
|
|
536
|
+
if ent.tier not in ("CONFIRMED", "PROBABLE"):
|
|
537
|
+
continue
|
|
538
|
+
graph.upsert_entity(
|
|
539
|
+
type_=ent.type,
|
|
540
|
+
value=ent.value,
|
|
541
|
+
case_id=payload.case_id,
|
|
542
|
+
label=ent.value[:200],
|
|
543
|
+
)
|
|
544
|
+
ingested += 1
|
|
545
|
+
|
|
546
|
+
# Track rate
|
|
547
|
+
_record_rate(key, ingested)
|
|
548
|
+
|
|
549
|
+
# Track published case metadata
|
|
550
|
+
_published_cases[payload.case_id] = {
|
|
551
|
+
"case_id": payload.case_id,
|
|
552
|
+
"target": payload.target,
|
|
553
|
+
"target_type": payload.target_type,
|
|
554
|
+
"findings_count": payload.findings_count,
|
|
555
|
+
"confirmed_count": payload.confirmed_count,
|
|
556
|
+
"verifiability": payload.verifiability,
|
|
557
|
+
"date": payload.date,
|
|
558
|
+
"ingested_entities": ingested,
|
|
559
|
+
"rejected_entities": len(rejected),
|
|
560
|
+
}
|
|
561
|
+
_save_published()
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
"status": "ingested",
|
|
565
|
+
"case_id": payload.case_id,
|
|
566
|
+
"entities_ingested": ingested,
|
|
567
|
+
"entities_rejected": len(rejected),
|
|
568
|
+
"rejected": rejected[:10], # First 10 only — don't flood response
|
|
569
|
+
"graph_stats": graph.stats(),
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@mcp.get("/api/published")
|
|
574
|
+
async def api_published():
|
|
575
|
+
"""List all published cases in the community graph."""
|
|
576
|
+
return {
|
|
577
|
+
"cases": list(_published_cases.values()),
|
|
578
|
+
"count": len(_published_cases),
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@mcp.delete("/api/cases/{case_id}")
|
|
583
|
+
async def api_unpublish(case_id: str):
|
|
584
|
+
"""Unpublish a case — remove its entities from the graph and
|
|
585
|
+
its metadata from the published index. Requires write API key."""
|
|
586
|
+
removed_entities = graph.remove_case(case_id)
|
|
587
|
+
was_published = case_id in _published_cases
|
|
588
|
+
|
|
589
|
+
if was_published:
|
|
590
|
+
del _published_cases[case_id]
|
|
591
|
+
_save_published()
|
|
592
|
+
|
|
593
|
+
if removed_entities == 0 and not was_published:
|
|
594
|
+
raise HTTPException(404, f"Case {case_id} not found in published index or graph")
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
"status": "unpublished",
|
|
598
|
+
"case_id": case_id,
|
|
599
|
+
"entities_removed": removed_entities,
|
|
600
|
+
"metadata_removed": was_published,
|
|
601
|
+
}
|