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
|
+
"""Core models for Watson — Pydantic data structures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FindingSeverity(str, Enum):
|
|
13
|
+
CRITICAL = "critical"
|
|
14
|
+
HIGH = "high"
|
|
15
|
+
MEDIUM = "medium"
|
|
16
|
+
LOW = "low"
|
|
17
|
+
INFO = "info"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FindingSource(str, Enum):
|
|
21
|
+
SATELLITE = "satellite"
|
|
22
|
+
GEOLOCATION = "geolocation"
|
|
23
|
+
IMAGE_VIDEO = "image_video"
|
|
24
|
+
SOCIAL_MEDIA = "social_media"
|
|
25
|
+
PEOPLE = "people"
|
|
26
|
+
WEBSITES = "websites"
|
|
27
|
+
CORPORATE = "corporate"
|
|
28
|
+
CONFLICT = "conflict"
|
|
29
|
+
OSINT = "osint"
|
|
30
|
+
BELLINGCAT = "bellingcat"
|
|
31
|
+
SOCMINT = "socmint"
|
|
32
|
+
CROSS_REF = "cross_ref"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Finding(BaseModel):
|
|
36
|
+
"""A single finding from an investigation tool."""
|
|
37
|
+
|
|
38
|
+
id: str = Field(description="Unique finding ID")
|
|
39
|
+
source: FindingSource
|
|
40
|
+
tool: str = Field(description="Tool name that produced this finding")
|
|
41
|
+
title: str
|
|
42
|
+
description: str
|
|
43
|
+
evidence: list[str] = Field(default_factory=list, description="URLs or references")
|
|
44
|
+
severity: FindingSeverity = FindingSeverity.INFO
|
|
45
|
+
confidence: float = Field(default=0.5, ge=0.0, le=1.0)
|
|
46
|
+
timestamp: datetime = Field(default_factory=datetime.now)
|
|
47
|
+
metadata: dict = Field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InvestigationTask(BaseModel):
|
|
51
|
+
"""A decomposed investigation subtask dispatched to a tool."""
|
|
52
|
+
|
|
53
|
+
id: str
|
|
54
|
+
tool_category: FindingSource
|
|
55
|
+
tool_name: str = ""
|
|
56
|
+
query: str
|
|
57
|
+
context: str = ""
|
|
58
|
+
priority: int = 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InvestigationRequest(BaseModel):
|
|
62
|
+
"""Top-level investigation request from user."""
|
|
63
|
+
|
|
64
|
+
query: str
|
|
65
|
+
tools: list[FindingSource] | None = Field(default=None)
|
|
66
|
+
max_findings_per_tool: int = 10
|
|
67
|
+
cross_reference: bool = True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Report(BaseModel):
|
|
71
|
+
"""Final investigation report."""
|
|
72
|
+
|
|
73
|
+
query: str
|
|
74
|
+
generated_at: datetime = Field(default_factory=datetime.now)
|
|
75
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
76
|
+
cross_references: list[Finding] = Field(default_factory=list)
|
|
77
|
+
summary: str = ""
|
|
78
|
+
tool_stats: dict[str, int] = Field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def total_findings(self) -> int:
|
|
82
|
+
return len(self.findings) + len(self.cross_references)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def by_severity(self) -> dict[str, int]:
|
|
86
|
+
counts: dict[str, int] = {}
|
|
87
|
+
for f in self.findings + self.cross_references:
|
|
88
|
+
counts[f.severity.value] = counts.get(f.severity.value, 0) + 1
|
|
89
|
+
return counts
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def by_source(self) -> dict[str, int]:
|
|
93
|
+
counts: dict[str, int] = {}
|
|
94
|
+
for f in self.findings:
|
|
95
|
+
counts[f.source.value] = counts.get(f.source.value, 0) + 1
|
|
96
|
+
return counts
|
src/watson/ethics.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Sidney Ledger — editorial compliance and entity tracking across investigations.
|
|
2
|
+
|
|
3
|
+
Every entity that appears in a Watson investigation is recorded here.
|
|
4
|
+
Cross-case patterns, harm assessments, and editorial gates live here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("watson.ethics")
|
|
15
|
+
|
|
16
|
+
# ── Ledger ─────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
_LEDGER_PATH = Path(os.environ.get("WATSON_LEDGER_PATH",
|
|
19
|
+
os.path.expanduser("~/.watson/ledger.json")))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SidneyLedger:
|
|
23
|
+
"""Cross-case entity ledger with editorial compliance."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, path: Path | None = None):
|
|
26
|
+
self.path = path or _LEDGER_PATH
|
|
27
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self._data = self._load()
|
|
29
|
+
|
|
30
|
+
def _load(self) -> dict:
|
|
31
|
+
if self.path.exists():
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(self.path.read_text())
|
|
34
|
+
except (json.JSONDecodeError, OSError):
|
|
35
|
+
pass
|
|
36
|
+
return {"entities": {}, "cases": {}, "version": "1.0"}
|
|
37
|
+
|
|
38
|
+
def _save(self):
|
|
39
|
+
try:
|
|
40
|
+
self.path.write_text(json.dumps(self._data, indent=2, default=str))
|
|
41
|
+
except OSError as e:
|
|
42
|
+
logger.warning("ledger_save_failed: %s", e)
|
|
43
|
+
|
|
44
|
+
def record_entity(self, name: str, entity_type: str, case_id: str, confidence: float,
|
|
45
|
+
appears_in: list[str] | None = None):
|
|
46
|
+
"""Record an entity found in an investigation."""
|
|
47
|
+
key = name.lower().strip()
|
|
48
|
+
if key not in self._data["entities"]:
|
|
49
|
+
self._data["entities"][key] = {
|
|
50
|
+
"canonical": name,
|
|
51
|
+
"type": entity_type,
|
|
52
|
+
"first_seen": datetime.now(timezone.utc).isoformat(),
|
|
53
|
+
"cases": [],
|
|
54
|
+
"total_appearances": 0,
|
|
55
|
+
"confidence_high": confidence,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
entry = self._data["entities"][key]
|
|
59
|
+
if case_id not in entry["cases"]:
|
|
60
|
+
entry["cases"].append(case_id)
|
|
61
|
+
entry["total_appearances"] = len(entry["cases"])
|
|
62
|
+
entry["confidence_high"] = max(entry["confidence_high"], confidence)
|
|
63
|
+
entry["last_seen"] = datetime.now(timezone.utc).isoformat()
|
|
64
|
+
|
|
65
|
+
if appears_in:
|
|
66
|
+
existing = entry.get("appears_in", [])
|
|
67
|
+
for url in appears_in:
|
|
68
|
+
if url not in existing:
|
|
69
|
+
existing.append(url)
|
|
70
|
+
entry["appears_in"] = existing[:50]
|
|
71
|
+
|
|
72
|
+
self._save()
|
|
73
|
+
|
|
74
|
+
def record_case(self, case_id: str, query: str, finding_count: int,
|
|
75
|
+
confirmed_count: int, entities: list[str]):
|
|
76
|
+
"""Record a completed investigation case."""
|
|
77
|
+
self._data["cases"][case_id] = {
|
|
78
|
+
"query": query,
|
|
79
|
+
"completed_at": datetime.now(timezone.utc).isoformat(),
|
|
80
|
+
"finding_count": finding_count,
|
|
81
|
+
"confirmed_count": confirmed_count,
|
|
82
|
+
"entities": entities,
|
|
83
|
+
}
|
|
84
|
+
self._save()
|
|
85
|
+
|
|
86
|
+
def get_entity(self, name: str) -> dict | None:
|
|
87
|
+
"""Look up an entity's cross-case history."""
|
|
88
|
+
return self._data["entities"].get(name.lower().strip())
|
|
89
|
+
|
|
90
|
+
def get_case(self, case_id: str) -> dict | None:
|
|
91
|
+
"""Look up a case."""
|
|
92
|
+
return self._data["cases"].get(case_id)
|
|
93
|
+
|
|
94
|
+
def entity_appears_across_cases(self, name: str) -> list[str]:
|
|
95
|
+
"""List all cases where this entity appears."""
|
|
96
|
+
entry = self.get_entity(name)
|
|
97
|
+
return entry["cases"] if entry else []
|
|
98
|
+
|
|
99
|
+
def stats(self) -> dict:
|
|
100
|
+
return {
|
|
101
|
+
"total_entities": len(self._data["entities"]),
|
|
102
|
+
"total_cases": len(self._data["cases"]),
|
|
103
|
+
"cross_case_entities": sum(
|
|
104
|
+
1 for e in self._data["entities"].values()
|
|
105
|
+
if len(e.get("cases", [])) > 1
|
|
106
|
+
),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def get_stats(self) -> dict:
|
|
110
|
+
"""Alias for stats() — used by health endpoint."""
|
|
111
|
+
return self.stats()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── Editorial checks ───────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def apply_editorial_checks(findings: list, query: str) -> dict:
|
|
117
|
+
"""Apply editorial compliance checks to findings. Returns assessment dict."""
|
|
118
|
+
|
|
119
|
+
# Harm indicators — terms that suggest sensitive content
|
|
120
|
+
harm_indicators = {
|
|
121
|
+
"victim": "VULNERABLE ENTITIES DETECTED",
|
|
122
|
+
"minor": "MINOR DETECTED",
|
|
123
|
+
"child": "MINOR DETECTED",
|
|
124
|
+
"survivor": "VULNERABLE ENTITIES DETECTED",
|
|
125
|
+
"phone_number": "PII DETECTED",
|
|
126
|
+
"email": "PII DETECTED",
|
|
127
|
+
"address": "PII DETECTED",
|
|
128
|
+
"passport": "PII DETECTED",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
checks = {
|
|
132
|
+
"publication_assessment": "RECOMMENDED",
|
|
133
|
+
"harm_level": "LOW",
|
|
134
|
+
"public_interest": True,
|
|
135
|
+
"warnings": [],
|
|
136
|
+
"redactions_needed": 0,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
all_text = " ".join(
|
|
140
|
+
f"{getattr(f, 'title', '')} {getattr(f, 'description', '')}"
|
|
141
|
+
for f in findings
|
|
142
|
+
).lower()
|
|
143
|
+
|
|
144
|
+
for indicator, warning in harm_indicators.items():
|
|
145
|
+
if indicator in all_text:
|
|
146
|
+
checks["warnings"].append(warning)
|
|
147
|
+
|
|
148
|
+
if checks["warnings"]:
|
|
149
|
+
checks["harm_level"] = "HIGH" if len(checks["warnings"]) > 2 else "MEDIUM"
|
|
150
|
+
checks["redactions_needed"] = len(checks["warnings"])
|
|
151
|
+
|
|
152
|
+
return checks
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ── Singleton ──────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
_ledger: SidneyLedger | None = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_ledger() -> SidneyLedger:
|
|
161
|
+
global _ledger
|
|
162
|
+
if _ledger is None:
|
|
163
|
+
_ledger = SidneyLedger()
|
|
164
|
+
return _ledger
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── Editorial Framework (reporter integration) ──────────────────
|
|
168
|
+
# These are lightweight stubs — the real editorial pipeline lives in
|
|
169
|
+
# watson/reporter.py with the harm indicators in src/watson/ethics.py.
|
|
170
|
+
|
|
171
|
+
BELLINGCAT_DATA_ETHICS_APPENDIX = """
|
|
172
|
+
## Data Ethics & Methodology
|
|
173
|
+
|
|
174
|
+
This investigation follows the Bellingcat Digital Research Ethics Framework:
|
|
175
|
+
|
|
176
|
+
- **Open Source**: All data sourced from publicly available information
|
|
177
|
+
- **Verification**: Cross-referenced across multiple independent sources
|
|
178
|
+
- **Privacy**: Personal identifying information redacted unless clearly in the public interest
|
|
179
|
+
- **Attribution**: Sources documented for reproducibility
|
|
180
|
+
- **Correction**: Errors will be promptly corrected when identified
|
|
181
|
+
- **Proportionality**: Only information relevant to the investigation is included
|
|
182
|
+
|
|
183
|
+
Methodology: Multi-agent OSINT pipeline using web scraping, API queries,
|
|
184
|
+
DNS/WHOIS lookups, certificate transparency logs, and dark web monitoring.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def generate_compliance_header(assessment: dict) -> str:
|
|
189
|
+
"""Generate a markdown compliance header from the assessment."""
|
|
190
|
+
warnings = assessment.get("warnings", [])
|
|
191
|
+
if not warnings:
|
|
192
|
+
return "✅ **Editorial Compliance**: No concerns detected.\n"
|
|
193
|
+
|
|
194
|
+
lines = [f"⚠️ **Editorial Compliance** — {len(warnings)} concern(s):"]
|
|
195
|
+
for w in warnings:
|
|
196
|
+
lines.append(f"- {w}")
|
|
197
|
+
return "\n".join(lines) + "\n"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class EditorialFramework:
|
|
201
|
+
"""Editorial compliance framework for pre-publication assessment."""
|
|
202
|
+
|
|
203
|
+
def assess(self, query: str, findings: list, narrative: str, ai_used: bool) -> dict:
|
|
204
|
+
"""Assess a report for editorial compliance before publication."""
|
|
205
|
+
return apply_editorial_checks(findings, query)
|
src/watson/exports.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Export — generate reports in multiple formats.
|
|
2
|
+
|
|
3
|
+
BellingcatReport class for structured OSINT report generation.
|
|
4
|
+
Delegates to watson.reporter for core report logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BellingcatReport:
|
|
15
|
+
"""Bellingcat-style structured OSINT report with multi-format export.
|
|
16
|
+
|
|
17
|
+
Supports: JSON, STIX, MISP, PDF, Markdown.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
query: str = "",
|
|
23
|
+
investigation_id: str = "",
|
|
24
|
+
target_type: str = "unknown",
|
|
25
|
+
target_value: str = "",
|
|
26
|
+
):
|
|
27
|
+
self.query = query
|
|
28
|
+
self.investigation_id = investigation_id
|
|
29
|
+
self.target_type = target_type
|
|
30
|
+
self.target_value = target_value
|
|
31
|
+
self.findings: List[Dict[str, Any]] = []
|
|
32
|
+
self.cross_references: List[Dict[str, Any]] = []
|
|
33
|
+
self.hops: int = 0
|
|
34
|
+
|
|
35
|
+
def add_findings(self, findings: List[Dict[str, Any]]):
|
|
36
|
+
self.findings.extend(findings)
|
|
37
|
+
|
|
38
|
+
def add_cross_references(self, refs: List[Dict[str, Any]]):
|
|
39
|
+
self.cross_references.extend(refs)
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
42
|
+
"""Return full report as dict."""
|
|
43
|
+
return {
|
|
44
|
+
"case_id": self.investigation_id,
|
|
45
|
+
"query": self.query,
|
|
46
|
+
"target_type": self.target_type,
|
|
47
|
+
"target_value": self.target_value,
|
|
48
|
+
"hops": self.hops,
|
|
49
|
+
"total_findings": len(self.findings),
|
|
50
|
+
"confirmed": sum(1 for f in self.findings if f.get("tier") == "CONFIRMED"),
|
|
51
|
+
"findings": self.findings,
|
|
52
|
+
"cross_references": self.cross_references,
|
|
53
|
+
"verifiability": self._verifiability(),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def to_json(self, path: Path | str) -> Path:
|
|
57
|
+
"""Export as JSON."""
|
|
58
|
+
path = Path(path)
|
|
59
|
+
path.write_text(json.dumps(self.to_dict(), indent=2, default=str))
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
def to_stix(self, path: Path | str) -> Path:
|
|
63
|
+
"""Export as STIX 2.1 bundle."""
|
|
64
|
+
import uuid
|
|
65
|
+
from datetime import datetime, timezone
|
|
66
|
+
|
|
67
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
68
|
+
bundle = {
|
|
69
|
+
"type": "bundle",
|
|
70
|
+
"id": f"bundle--{uuid.uuid4()}",
|
|
71
|
+
"spec_version": "2.1",
|
|
72
|
+
"objects": [
|
|
73
|
+
{
|
|
74
|
+
"type": "report",
|
|
75
|
+
"id": f"report--{uuid.uuid4()}",
|
|
76
|
+
"created": now,
|
|
77
|
+
"modified": now,
|
|
78
|
+
"name": f"Watson Investigation: {self.query[:80]}",
|
|
79
|
+
"description": f"OSINT investigation of {self.target_value}",
|
|
80
|
+
"report_types": ["investigation"],
|
|
81
|
+
"object_refs": [],
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
}
|
|
85
|
+
path = Path(path)
|
|
86
|
+
path.write_text(json.dumps(bundle, indent=2))
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
def to_misp(self, path: Path | str) -> Path:
|
|
90
|
+
"""Export as MISP event JSON."""
|
|
91
|
+
import uuid
|
|
92
|
+
|
|
93
|
+
event = {
|
|
94
|
+
"Event": {
|
|
95
|
+
"uuid": str(uuid.uuid4()),
|
|
96
|
+
"info": f"Watson Investigation: {self.query[:80]}",
|
|
97
|
+
"analysis": "2", # Completed
|
|
98
|
+
"threat_level_id": "3", # Low
|
|
99
|
+
"published": False,
|
|
100
|
+
"Attribute": [
|
|
101
|
+
{
|
|
102
|
+
"type": "text",
|
|
103
|
+
"category": "External analysis",
|
|
104
|
+
"value": f["title"],
|
|
105
|
+
"comment": f["description"],
|
|
106
|
+
}
|
|
107
|
+
for f in self.findings[:50]
|
|
108
|
+
],
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
path = Path(path)
|
|
112
|
+
path.write_text(json.dumps(event, indent=2))
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
def to_pdf(self, path: Path | str) -> Path:
|
|
116
|
+
"""Export as PDF (saves as JSON with .pdf extension if fpdf unavailable)."""
|
|
117
|
+
path = Path(path)
|
|
118
|
+
try:
|
|
119
|
+
from fpdf import FPDF
|
|
120
|
+
pdf = FPDF()
|
|
121
|
+
pdf.add_page()
|
|
122
|
+
pdf.set_font("Helvetica", size=12)
|
|
123
|
+
pdf.cell(200, 10, text=f"Watson OSINT Report: {self.query[:80]}", align="C")
|
|
124
|
+
pdf.ln(10)
|
|
125
|
+
for f in self.findings:
|
|
126
|
+
pdf.set_font("Helvetica", "B", size=10)
|
|
127
|
+
pdf.cell(200, 8, text=f["title"][:100])
|
|
128
|
+
pdf.ln(8)
|
|
129
|
+
pdf.set_font("Helvetica", size=9)
|
|
130
|
+
pdf.multi_cell(0, 6, text=f.get("description", "")[:300])
|
|
131
|
+
pdf.ln(4)
|
|
132
|
+
pdf.output(str(path))
|
|
133
|
+
except ImportError:
|
|
134
|
+
# Fallback: save as markdown with .pdf extension
|
|
135
|
+
path.write_text(self.to_markdown())
|
|
136
|
+
return path
|
|
137
|
+
|
|
138
|
+
def to_markdown(self) -> str:
|
|
139
|
+
"""Export as Markdown."""
|
|
140
|
+
lines = [
|
|
141
|
+
f"# Watson OSINT Investigation Report",
|
|
142
|
+
f"",
|
|
143
|
+
f"**Case ID:** {self.investigation_id}",
|
|
144
|
+
f"**Query:** {self.query}",
|
|
145
|
+
f"**Target Type:** {self.target_type}",
|
|
146
|
+
f"**Target:** {self.target_value}",
|
|
147
|
+
f"**Hops:** {self.hops}",
|
|
148
|
+
f"**Findings:** {len(self.findings)}",
|
|
149
|
+
f"**Verifiability:** {self._verifiability():.0%}",
|
|
150
|
+
f"",
|
|
151
|
+
f"## Findings",
|
|
152
|
+
f"",
|
|
153
|
+
]
|
|
154
|
+
for i, f in enumerate(self.findings, 1):
|
|
155
|
+
tier = f.get("tier", "UNSUBSTANTIATED")
|
|
156
|
+
lines.append(f"### Finding {i}: {f.get('title', 'Untitled')}")
|
|
157
|
+
lines.append(f"")
|
|
158
|
+
lines.append(f"- **Tier:** {tier}")
|
|
159
|
+
lines.append(f"- **Confidence:** {f.get('confidence', 0):.1%}")
|
|
160
|
+
lines.append(f"- **Source:** {f.get('source_type', 'unknown')}")
|
|
161
|
+
if f.get("source_url"):
|
|
162
|
+
lines.append(f"- **URL:** {f.get('source_url')}")
|
|
163
|
+
lines.append(f"")
|
|
164
|
+
lines.append(f.get("description", ""))
|
|
165
|
+
lines.append(f"")
|
|
166
|
+
|
|
167
|
+
if self.cross_references:
|
|
168
|
+
lines.append(f"## Cross-References")
|
|
169
|
+
lines.append(f"")
|
|
170
|
+
for cr in self.cross_references:
|
|
171
|
+
lines.append(f"- {cr}")
|
|
172
|
+
|
|
173
|
+
return "\n".join(lines)
|
|
174
|
+
|
|
175
|
+
def _verifiability(self) -> float:
|
|
176
|
+
"""Calculate the verifiability score (0-1)."""
|
|
177
|
+
if not self.findings:
|
|
178
|
+
return 0.0
|
|
179
|
+
sources = sum(1 for f in self.findings if f.get("source_url"))
|
|
180
|
+
primary = sum(1 for f in self.findings if f.get("source_type") == "PRIMARY")
|
|
181
|
+
score = (sources / len(self.findings) * 0.5 + primary / max(len(self.findings), 1) * 0.5)
|
|
182
|
+
return min(score, 1.0)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Watson Entity Graph — typed entities, relationships, and transform engine.
|
|
2
|
+
|
|
3
|
+
This module provides Maltego-style graph-based OSINT discovery:
|
|
4
|
+
- Typed entities (Domain, IPAddress, Email, Person, Website, etc.)
|
|
5
|
+
- Typed relationships (RESOLVES_TO, HAS_SUBDOMAIN, LOCATED_IN, etc.)
|
|
6
|
+
- EntityGraph: a networkx-backed graph with type-safe operations
|
|
7
|
+
- TransformEngine: recursive entity→entity transform chaining
|
|
8
|
+
|
|
9
|
+
Integration: the graph engine runs as an enrichment layer AFTER the surface
|
|
10
|
+
phase of the existing pipeline. It does NOT modify the pipeline — it adds
|
|
11
|
+
new findings derived from the graph transforms, while all existing phases
|
|
12
|
+
continue unchanged.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
from watson.graph import EntityGraph, TransformEngine
|
|
16
|
+
g = EntityGraph()
|
|
17
|
+
g.add_entity(Domain("automattic.com"))
|
|
18
|
+
engine = TransformEngine(g)
|
|
19
|
+
await engine.run_transforms(max_depth=3)
|
|
20
|
+
findings = g.to_findings()
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .entities import (
|
|
24
|
+
Entity,
|
|
25
|
+
Domain,
|
|
26
|
+
IPAddress,
|
|
27
|
+
Email,
|
|
28
|
+
Person,
|
|
29
|
+
Organization,
|
|
30
|
+
Website,
|
|
31
|
+
Location,
|
|
32
|
+
Document,
|
|
33
|
+
)
|
|
34
|
+
from .relationships import (
|
|
35
|
+
Relationship,
|
|
36
|
+
RESOLVES_TO,
|
|
37
|
+
HAS_SUBDOMAIN,
|
|
38
|
+
LOCATED_IN,
|
|
39
|
+
HAS_EMAIL,
|
|
40
|
+
MENTIONS,
|
|
41
|
+
CONTAINS,
|
|
42
|
+
OWNS,
|
|
43
|
+
RELATED_TO,
|
|
44
|
+
)
|
|
45
|
+
from .graph import EntityGraph
|
|
46
|
+
from .transforms import TransformEngine
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Entity",
|
|
50
|
+
"Domain",
|
|
51
|
+
"IPAddress",
|
|
52
|
+
"Email",
|
|
53
|
+
"Person",
|
|
54
|
+
"Organization",
|
|
55
|
+
"Website",
|
|
56
|
+
"Location",
|
|
57
|
+
"Document",
|
|
58
|
+
"Relationship",
|
|
59
|
+
"RESOLVES_TO",
|
|
60
|
+
"HAS_SUBDOMAIN",
|
|
61
|
+
"LOCATED_IN",
|
|
62
|
+
"HAS_EMAIL",
|
|
63
|
+
"MENTIONS",
|
|
64
|
+
"CONTAINS",
|
|
65
|
+
"OWNS",
|
|
66
|
+
"RELATED_TO",
|
|
67
|
+
"EntityGraph",
|
|
68
|
+
"TransformEngine",
|
|
69
|
+
]
|