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/reporter.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enterprise-Grade Report Generator
|
|
3
|
+
|
|
4
|
+
Produces standardized investigation reports with:
|
|
5
|
+
- Evidence confidence matrix (5 tiers)
|
|
6
|
+
- Source classification (PRIMARY/SECONDARY/TERTIARY/UNVERIFIED)
|
|
7
|
+
- Structured 10-section format
|
|
8
|
+
- Verifiability scoring
|
|
9
|
+
- Editorial compliance assessment
|
|
10
|
+
|
|
11
|
+
Every finding carries: {confidence, source_class, source_url, timestamp, replicable}
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── Evidence Confidence Tiers ────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
CONFIDENCE_TIERS = {
|
|
27
|
+
"CONFIRMED": (0.90, 1.00),
|
|
28
|
+
"PROBABLE": (0.70, 0.89),
|
|
29
|
+
"POSSIBLE": (0.40, 0.69),
|
|
30
|
+
"UNLIKELY": (0.10, 0.39),
|
|
31
|
+
"UNSUBSTANTIATED": (0.00, 0.09),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
SOURCE_CLASSES = {
|
|
35
|
+
"PRIMARY": "Court docs, SEC filings, certificate transparency logs, blockchain, official registries",
|
|
36
|
+
"SECONDARY": "News articles, academic papers, verified social media accounts, company websites",
|
|
37
|
+
"TERTIARY": "Public registries, encyclopedias, aggregated databases, Wikipedia",
|
|
38
|
+
"UNVERIFIED": "Anonymous sources, unsourced claims, forum posts, unverified social media",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tier_from_confidence(score: float) -> str:
|
|
43
|
+
for tier, (lo, hi) in CONFIDENCE_TIERS.items():
|
|
44
|
+
if lo <= score <= hi:
|
|
45
|
+
return tier
|
|
46
|
+
return "UNSUBSTANTIATED"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def source_class_from_type(source_type: str) -> str:
|
|
50
|
+
# Direct source class labels (from protocol's SourceClass enum)
|
|
51
|
+
if source_type in ("PRIMARY", "SECONDARY", "TERTIARY", "UNVERIFIED"):
|
|
52
|
+
return source_type
|
|
53
|
+
|
|
54
|
+
mapping = {
|
|
55
|
+
# Direct infrastructure queries → PRIMARY
|
|
56
|
+
"blockchain": "PRIMARY",
|
|
57
|
+
"cert_transparency": "PRIMARY",
|
|
58
|
+
"dns_record": "PRIMARY",
|
|
59
|
+
"dns_lookup": "PRIMARY",
|
|
60
|
+
"whois": "PRIMARY",
|
|
61
|
+
"whois_lookup": "PRIMARY",
|
|
62
|
+
"ssl_cert": "PRIMARY",
|
|
63
|
+
"court_filing": "PRIMARY",
|
|
64
|
+
"sec_filing": "PRIMARY",
|
|
65
|
+
"official_registry": "PRIMARY",
|
|
66
|
+
"reverse_dns": "PRIMARY",
|
|
67
|
+
"ip_geolocation": "PRIMARY",
|
|
68
|
+
# Agent-sourced infrastructure → PRIMARY
|
|
69
|
+
"recon": "PRIMARY",
|
|
70
|
+
# Corporate registries → SECONDARY (varied reliability)
|
|
71
|
+
"company_registry": "SECONDARY",
|
|
72
|
+
"corporate": "SECONDARY",
|
|
73
|
+
# Breach data → SECONDARY
|
|
74
|
+
"breach_check": "SECONDARY",
|
|
75
|
+
"breach": "SECONDARY",
|
|
76
|
+
"dark": "SECONDARY",
|
|
77
|
+
# Social / user enumeration
|
|
78
|
+
"social": "SECONDARY",
|
|
79
|
+
"username_enum": "SECONDARY",
|
|
80
|
+
"verified_social": "SECONDARY",
|
|
81
|
+
# News / web / API
|
|
82
|
+
"news": "SECONDARY",
|
|
83
|
+
"academic": "SECONDARY",
|
|
84
|
+
"api": "SECONDARY",
|
|
85
|
+
"web_search": "SECONDARY",
|
|
86
|
+
# Lower reliability
|
|
87
|
+
"wikipedia": "TERTIARY",
|
|
88
|
+
"public_db": "TERTIARY",
|
|
89
|
+
"wayback": "TERTIARY",
|
|
90
|
+
}
|
|
91
|
+
return mapping.get(source_type, "UNVERIFIED")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Report data model ────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class Finding:
|
|
98
|
+
title: str
|
|
99
|
+
description: str
|
|
100
|
+
source_type: str = ""
|
|
101
|
+
source_url: str = ""
|
|
102
|
+
confidence: float = 0.5
|
|
103
|
+
entities: list[str] = field(default_factory=list)
|
|
104
|
+
evidence: list[str] = field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def tier(self) -> str:
|
|
108
|
+
return tier_from_confidence(self.confidence)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def source_class(self) -> str:
|
|
112
|
+
return source_class_from_type(self.source_type)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class InvestigationReport:
|
|
117
|
+
case_id: str
|
|
118
|
+
query: str
|
|
119
|
+
target_type: str = "unknown"
|
|
120
|
+
angles: list[str] = field(default_factory=list)
|
|
121
|
+
findings: list[Finding] = field(default_factory=list)
|
|
122
|
+
entities: list[dict] = field(default_factory=list)
|
|
123
|
+
relations: list[dict] = field(default_factory=list)
|
|
124
|
+
timeline: list[dict] = field(default_factory=list)
|
|
125
|
+
methodology_notes: list[str] = field(default_factory=list)
|
|
126
|
+
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
127
|
+
|
|
128
|
+
def generate_markdown(self) -> str:
|
|
129
|
+
"""Generate an enterprise-grade markdown report with editorial compliance."""
|
|
130
|
+
|
|
131
|
+
# ── Run ethical pre-publication assessment ──
|
|
132
|
+
try:
|
|
133
|
+
from src.watson.ethics import (
|
|
134
|
+
EditorialFramework, generate_compliance_header,
|
|
135
|
+
BELLINGCAT_DATA_ETHICS_APPENDIX,
|
|
136
|
+
)
|
|
137
|
+
except ImportError:
|
|
138
|
+
from watson.ethics import (
|
|
139
|
+
EditorialFramework, generate_compliance_header,
|
|
140
|
+
BELLINGCAT_DATA_ETHICS_APPENDIX,
|
|
141
|
+
)
|
|
142
|
+
framework = EditorialFramework()
|
|
143
|
+
|
|
144
|
+
findings_dicts = [
|
|
145
|
+
{
|
|
146
|
+
"title": f.title,
|
|
147
|
+
"description": f.description,
|
|
148
|
+
"source_type": f.source_type,
|
|
149
|
+
"source_url": f.source_url,
|
|
150
|
+
"confidence": f.confidence,
|
|
151
|
+
"entities": f.entities,
|
|
152
|
+
}
|
|
153
|
+
for f in self.findings
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
# Check if AI was used (LLM analyzed findings = AI used in research)
|
|
157
|
+
ai_used = any(
|
|
158
|
+
f.source_type in ("web_search", "news", "social", "academic", "api")
|
|
159
|
+
for f in self.findings
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
full_narrative = self._build_narrative(
|
|
163
|
+
[f for f in self.findings if f.tier == "CONFIRMED"],
|
|
164
|
+
[f for f in self.findings if f.tier == "PROBABLE"],
|
|
165
|
+
[f for f in self.findings if f.tier == "POSSIBLE"],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
assessment = framework.assess(
|
|
169
|
+
query=self.query,
|
|
170
|
+
findings=findings_dicts,
|
|
171
|
+
narrative=full_narrative,
|
|
172
|
+
ai_used=ai_used,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# ── Build compliance header ──
|
|
176
|
+
compliance_header = generate_compliance_header(assessment)
|
|
177
|
+
|
|
178
|
+
# ── Build report body ──
|
|
179
|
+
findings_sorted = sorted(self.findings, key=lambda f: -f.confidence)
|
|
180
|
+
|
|
181
|
+
# Split: real intelligence vs tool errors/limitations
|
|
182
|
+
intelligence = []
|
|
183
|
+
tool_errors = []
|
|
184
|
+
for f in findings_sorted:
|
|
185
|
+
title_lower = f.title.lower()
|
|
186
|
+
is_error = (
|
|
187
|
+
f.source_type == "error" or
|
|
188
|
+
"⚠️" in f.title or
|
|
189
|
+
"could not read" in title_lower or
|
|
190
|
+
"failed" in title_lower or
|
|
191
|
+
"api key required" in title_lower or
|
|
192
|
+
"authentication failed" in title_lower or
|
|
193
|
+
"bot detection" in title_lower or
|
|
194
|
+
"rate limited" in title_lower or
|
|
195
|
+
"timeout" in title_lower or
|
|
196
|
+
"no module named" in title_lower or
|
|
197
|
+
f.confidence < 0.15
|
|
198
|
+
)
|
|
199
|
+
if is_error:
|
|
200
|
+
tool_errors.append(f)
|
|
201
|
+
else:
|
|
202
|
+
intelligence.append(f)
|
|
203
|
+
|
|
204
|
+
confirmed = [f for f in intelligence if f.tier == "CONFIRMED"]
|
|
205
|
+
probable = [f for f in intelligence if f.tier == "PROBABLE"]
|
|
206
|
+
possible = [f for f in intelligence if f.tier == "POSSIBLE"]
|
|
207
|
+
low_conf = [f for f in intelligence if f.tier in ("UNLIKELY", "UNSUBSTANTIATED")]
|
|
208
|
+
|
|
209
|
+
parts = []
|
|
210
|
+
|
|
211
|
+
# ── 1. Executive Summary ─────────────────────────────
|
|
212
|
+
parts.append(f"# Investigation Report: {self.query}")
|
|
213
|
+
parts.append(f"**Case ID:** {self.case_id}")
|
|
214
|
+
parts.append(f"**Date:** {self.created_at[:10]}")
|
|
215
|
+
parts.append("")
|
|
216
|
+
parts.append("## 1. Executive Summary")
|
|
217
|
+
parts.append("")
|
|
218
|
+
total = len(intelligence)
|
|
219
|
+
high = len(confirmed)
|
|
220
|
+
parts.append(
|
|
221
|
+
f"This investigation examined **{self.query}** across "
|
|
222
|
+
f"{len(self.angles)} angles. "
|
|
223
|
+
f"**{total} findings** were produced: "
|
|
224
|
+
f"**{high} CONFIRMED**, "
|
|
225
|
+
f"**{len(probable)} PROBABLE**, "
|
|
226
|
+
f"**{len(possible)} POSSIBLE**."
|
|
227
|
+
)
|
|
228
|
+
if tool_errors:
|
|
229
|
+
parts.append(f"\n**{len(tool_errors)} tool errors/limitations** were encountered and are listed separately.")
|
|
230
|
+
if confirmed:
|
|
231
|
+
parts.append(f"\n**Key confirmed finding:** {confirmed[0].title}")
|
|
232
|
+
|
|
233
|
+
# ── Narrative summary ──
|
|
234
|
+
parts.append(f"\n### Narrative")
|
|
235
|
+
narrative = self._build_narrative(confirmed, probable, possible)
|
|
236
|
+
parts.append(narrative)
|
|
237
|
+
parts.append("")
|
|
238
|
+
|
|
239
|
+
# ── 2. Key Findings ──────────────────────────────────
|
|
240
|
+
parts.append("## 2. Key Findings (Confidence-Ranked)")
|
|
241
|
+
parts.append("")
|
|
242
|
+
|
|
243
|
+
for i, f in enumerate(intelligence[:20], 1):
|
|
244
|
+
emoji = {"CONFIRMED": "🟢", "PROBABLE": "🟡", "POSSIBLE": "🟠", "UNLIKELY": "🔴", "UNSUBSTANTIATED": "⚪"}[f.tier]
|
|
245
|
+
parts.append(f"### {i}. [{f.tier}] {f.title}")
|
|
246
|
+
parts.append(f"{emoji} **Confidence:** {f.confidence:.0%} | **Source class:** {f.source_class}")
|
|
247
|
+
parts.append(f"\n{f.description}")
|
|
248
|
+
if f.source_url:
|
|
249
|
+
parts.append(f"\n**Source:** {f.source_url}")
|
|
250
|
+
if f.evidence:
|
|
251
|
+
parts.append("\n**Evidence:**")
|
|
252
|
+
for e in f.evidence:
|
|
253
|
+
parts.append(f" - {e}")
|
|
254
|
+
parts.append("")
|
|
255
|
+
|
|
256
|
+
# ── 3. Methodology ───────────────────────────────────
|
|
257
|
+
parts.append("## 3. Methodology")
|
|
258
|
+
parts.append("")
|
|
259
|
+
parts.append(f"**Target type:** {self.target_type}")
|
|
260
|
+
parts.append(f"**Investigation angles ({len(self.angles)}):**")
|
|
261
|
+
for a in self.angles:
|
|
262
|
+
parts.append(f" - {a}")
|
|
263
|
+
if self.methodology_notes:
|
|
264
|
+
parts.append("\n**Tools & techniques:**")
|
|
265
|
+
for note in self.methodology_notes:
|
|
266
|
+
parts.append(f" - {note}")
|
|
267
|
+
parts.append("")
|
|
268
|
+
|
|
269
|
+
# ── 4. Detailed Evidence ─────────────────────────────
|
|
270
|
+
parts.append("## 4. Detailed Evidence")
|
|
271
|
+
parts.append("")
|
|
272
|
+
by_source = {}
|
|
273
|
+
for f in intelligence:
|
|
274
|
+
cls = f.source_class
|
|
275
|
+
by_source.setdefault(cls, []).append(f)
|
|
276
|
+
|
|
277
|
+
for cls_name in ["PRIMARY", "SECONDARY", "TERTIARY", "UNVERIFIED"]:
|
|
278
|
+
items = by_source.get(cls_name, [])
|
|
279
|
+
if items:
|
|
280
|
+
parts.append(f"### {cls_name} Sources")
|
|
281
|
+
parts.append(f"_{SOURCE_CLASSES[cls_name]}_")
|
|
282
|
+
parts.append("")
|
|
283
|
+
for f in items:
|
|
284
|
+
parts.append(f"- **{f.title}** (confidence: {f.confidence:.0%})")
|
|
285
|
+
if f.source_url:
|
|
286
|
+
parts.append(f" {f.source_url}")
|
|
287
|
+
parts.append("")
|
|
288
|
+
|
|
289
|
+
# ── 5. Entity Map ────────────────────────────────────
|
|
290
|
+
parts.append("## 5. Entity Map")
|
|
291
|
+
parts.append("")
|
|
292
|
+
if self.entities:
|
|
293
|
+
parts.append("| Entity | Type | Confidence | Sources |")
|
|
294
|
+
parts.append("|--------|------|------------|---------|")
|
|
295
|
+
for e in self.entities[:30]:
|
|
296
|
+
parts.append(
|
|
297
|
+
f"| {e.get('name', '?')} | {e.get('type', '?')} | "
|
|
298
|
+
f"{e.get('confidence', 0):.0%} | {e.get('source_count', 0)} |"
|
|
299
|
+
)
|
|
300
|
+
parts.append("")
|
|
301
|
+
|
|
302
|
+
if self.relations:
|
|
303
|
+
parts.append("**Relationships:**")
|
|
304
|
+
for r in self.relations[:20]:
|
|
305
|
+
parts.append(
|
|
306
|
+
f" - {r.get('source', '?')} → [{r.get('type', '?')}] → "
|
|
307
|
+
f"{r.get('target', '?')} (Case {r.get('case_id', '?')})"
|
|
308
|
+
)
|
|
309
|
+
parts.append("")
|
|
310
|
+
|
|
311
|
+
# ── 6. Timeline ──────────────────────────────────────
|
|
312
|
+
parts.append("## 6. Timeline")
|
|
313
|
+
parts.append("")
|
|
314
|
+
if self.timeline:
|
|
315
|
+
for t in sorted(self.timeline, key=lambda x: x.get("date", "")):
|
|
316
|
+
parts.append(f"- **{t.get('date', '?')}**: {t.get('event', '?')}")
|
|
317
|
+
else:
|
|
318
|
+
parts.append("(No chronological events identified)")
|
|
319
|
+
parts.append("")
|
|
320
|
+
|
|
321
|
+
# ── 7. Source Appendix ───────────────────────────────
|
|
322
|
+
parts.append("## 7. Source Appendix")
|
|
323
|
+
parts.append("")
|
|
324
|
+
seen = set()
|
|
325
|
+
for f in intelligence + tool_errors:
|
|
326
|
+
if f.source_url and f.source_url not in seen:
|
|
327
|
+
seen.add(f.source_url)
|
|
328
|
+
parts.append(f"- [{f.tier}] {f.source_url}")
|
|
329
|
+
parts.append("")
|
|
330
|
+
|
|
331
|
+
# ── 8. Limitations & Gaps ────────────────────────────
|
|
332
|
+
parts.append("## 8. Limitations & Gaps")
|
|
333
|
+
parts.append("")
|
|
334
|
+
|
|
335
|
+
if tool_errors:
|
|
336
|
+
parts.append(f"### Tool Errors ({len(tool_errors)})")
|
|
337
|
+
parts.append("")
|
|
338
|
+
parts.append("These are infrastructure/API limitations, not intelligence findings:")
|
|
339
|
+
parts.append("")
|
|
340
|
+
for f in tool_errors:
|
|
341
|
+
parts.append(f"- **{f.title}** — {f.description[:200]}")
|
|
342
|
+
parts.append("")
|
|
343
|
+
|
|
344
|
+
if low_conf:
|
|
345
|
+
parts.append(f"### Low-Confidence Findings ({len(low_conf)})")
|
|
346
|
+
parts.append("")
|
|
347
|
+
parts.append("Findings below confidence thresholds that require further verification:")
|
|
348
|
+
parts.append("")
|
|
349
|
+
for f in low_conf[:5]:
|
|
350
|
+
parts.append(f" - {f.title}")
|
|
351
|
+
elif not tool_errors:
|
|
352
|
+
parts.append("(No significant gaps identified — all findings met confidence thresholds)")
|
|
353
|
+
parts.append("")
|
|
354
|
+
|
|
355
|
+
# ── 9. Recommendations ───────────────────────────────
|
|
356
|
+
parts.append("## 9. Recommendations for Further Investigation")
|
|
357
|
+
parts.append("")
|
|
358
|
+
if low_conf:
|
|
359
|
+
for f in low_conf[:3]:
|
|
360
|
+
parts.append(f"- Verify: \"{f.title}\" — current evidence is weak")
|
|
361
|
+
parts.append(f"- Deep-dive on any entities with >2 connections in the entity map")
|
|
362
|
+
parts.append(f"- Set up scheduled monitoring for this target")
|
|
363
|
+
parts.append("")
|
|
364
|
+
|
|
365
|
+
# ── 10. Verifiability Score ──────────────────────────
|
|
366
|
+
parts.append("## 10. Verifiability Score")
|
|
367
|
+
parts.append("")
|
|
368
|
+
primary_count = sum(1 for f in intelligence if f.source_class == "PRIMARY")
|
|
369
|
+
has_urls = sum(1 for f in intelligence if f.source_url)
|
|
370
|
+
verifiability = 0.0
|
|
371
|
+
if total > 0:
|
|
372
|
+
verifiability = (
|
|
373
|
+
0.4 * (primary_count / total) +
|
|
374
|
+
0.4 * (has_urls / total) +
|
|
375
|
+
0.2 * (high / total)
|
|
376
|
+
)
|
|
377
|
+
parts.append(f"**Score: {verifiability:.0%}**")
|
|
378
|
+
parts.append(f" - {primary_count}/{total} findings from PRIMARY sources")
|
|
379
|
+
parts.append(f" - {has_urls}/{total} findings have public URLs")
|
|
380
|
+
parts.append(f" - {high}/{total} findings CONFIRMED")
|
|
381
|
+
parts.append("")
|
|
382
|
+
if verifiability >= 0.7:
|
|
383
|
+
parts.append("✅ **This investigation is independently verifiable.**")
|
|
384
|
+
elif verifiability >= 0.4:
|
|
385
|
+
parts.append("⚠️ **Partially verifiable — some findings lack primary sources.**")
|
|
386
|
+
else:
|
|
387
|
+
parts.append("❌ **Low verifiability — most findings cannot be independently confirmed.**")
|
|
388
|
+
|
|
389
|
+
parts.append("")
|
|
390
|
+
parts.append(BELLINGCAT_DATA_ETHICS_APPENDIX)
|
|
391
|
+
|
|
392
|
+
# Prepend compliance header, append ethics appendix
|
|
393
|
+
return compliance_header + "\n".join(parts)
|
|
394
|
+
|
|
395
|
+
def to_dict(self) -> dict:
|
|
396
|
+
return {
|
|
397
|
+
"case_id": self.case_id,
|
|
398
|
+
"query": self.query,
|
|
399
|
+
"target_type": self.target_type,
|
|
400
|
+
"findings_count": len(self.findings),
|
|
401
|
+
"confirmed": sum(1 for f in self.findings if f.tier == "CONFIRMED"),
|
|
402
|
+
"probable": sum(1 for f in self.findings if f.tier == "PROBABLE"),
|
|
403
|
+
"possible": sum(1 for f in self.findings if f.tier == "POSSIBLE"),
|
|
404
|
+
"verifiability_score": self._verifiability(),
|
|
405
|
+
"created_at": self.created_at,
|
|
406
|
+
"markdown": self.generate_markdown(),
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
def _verifiability(self) -> float:
|
|
410
|
+
total = len(self.findings)
|
|
411
|
+
if total == 0:
|
|
412
|
+
return 0.0
|
|
413
|
+
primary = sum(1 for f in self.findings if f.source_class == "PRIMARY")
|
|
414
|
+
has_urls = sum(1 for f in self.findings if f.source_url)
|
|
415
|
+
high = sum(1 for f in self.findings if f.tier == "CONFIRMED")
|
|
416
|
+
return 0.4 * (primary / total) + 0.4 * (has_urls / total) + 0.2 * (high / total)
|
|
417
|
+
|
|
418
|
+
def _build_narrative(self, confirmed: list, probable: list, possible: list) -> str:
|
|
419
|
+
"""Build a narrative summary connecting the dots."""
|
|
420
|
+
import re
|
|
421
|
+
lines = []
|
|
422
|
+
|
|
423
|
+
# Infrastructure findings
|
|
424
|
+
infra = [f for f in confirmed + probable if any(kw in f.title.lower() for kw in
|
|
425
|
+
("dns", "ip resol", "whois", "ssl", "certificate", "registrar"))]
|
|
426
|
+
if infra:
|
|
427
|
+
ip_info = ""
|
|
428
|
+
geo_info = ""
|
|
429
|
+
registrar_info = ""
|
|
430
|
+
for f in infra:
|
|
431
|
+
desc = f.description
|
|
432
|
+
if "resolves to" in desc.lower():
|
|
433
|
+
ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", desc)
|
|
434
|
+
if ip_match:
|
|
435
|
+
ip_info = f" at IP {ip_match.group(1)}"
|
|
436
|
+
if "Location:" in desc:
|
|
437
|
+
geo_match = re.search(r"Location:\s*(.+)", desc)
|
|
438
|
+
if geo_match:
|
|
439
|
+
geo_info = f" — geolocated to {geo_match.group(1)}"
|
|
440
|
+
if "Organization:" in desc:
|
|
441
|
+
org_match = re.search(r"Organization:\s*(.+)", desc)
|
|
442
|
+
if org_match:
|
|
443
|
+
geo_info += f" ({org_match.group(1)})"
|
|
444
|
+
if "registrar:" in desc.lower():
|
|
445
|
+
reg_match = re.search(r"registrar:\s*(.+)", desc, re.IGNORECASE)
|
|
446
|
+
if reg_match:
|
|
447
|
+
registrar_info = f" Registered through {reg_match.group(1)}."
|
|
448
|
+
|
|
449
|
+
domain = self.query
|
|
450
|
+
lines.append(
|
|
451
|
+
f"The target's primary domain {domain}{ip_info}{geo_info}."
|
|
452
|
+
f"{registrar_info}"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# Registrar intelligence — flag sanction-relevant registrars
|
|
456
|
+
SENSITIVE_REGISTRARS = {
|
|
457
|
+
"ru-center": "Russia (RU-CENTER is a Moscow-based state-adjacent registrar)",
|
|
458
|
+
"nic.ru": "Russia (Russian national NIC)",
|
|
459
|
+
"r01.ru": "Russia",
|
|
460
|
+
"reg.ru": "Russia",
|
|
461
|
+
"beget": "Russia",
|
|
462
|
+
}
|
|
463
|
+
for f in confirmed + probable:
|
|
464
|
+
desc = f.description.lower()
|
|
465
|
+
for key, note in SENSITIVE_REGISTRARS.items():
|
|
466
|
+
if key in desc:
|
|
467
|
+
lines.append(
|
|
468
|
+
f"\n⚠️ **Registrar intelligence:** The domain registrar is {note}. "
|
|
469
|
+
f"For sanctioned entities, registrar jurisdiction is a key investigative lead."
|
|
470
|
+
)
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
# People findings
|
|
474
|
+
people = [f for f in confirmed + probable if any(kw in f.title.lower() for kw in
|
|
475
|
+
("username", "social", "profile", "breach", "email", "ceo", "founder"))]
|
|
476
|
+
if people:
|
|
477
|
+
lines.append(f"\nSocial and people intelligence identified {len(people)} leads, "
|
|
478
|
+
f"including online presence and breach exposure data.")
|
|
479
|
+
|
|
480
|
+
# Gaps
|
|
481
|
+
gaps = []
|
|
482
|
+
if not any("breach" in f.title.lower() or "pwned" in f.title.lower() for f in confirmed + probable):
|
|
483
|
+
gaps.append("no breach database hits were found")
|
|
484
|
+
if not any("corporate" in f.title.lower() or "opencorporates" in f.title.lower() for f in confirmed + probable):
|
|
485
|
+
gaps.append("corporate registry data was not available")
|
|
486
|
+
|
|
487
|
+
if gaps:
|
|
488
|
+
lines.append(f"\nInvestigation gaps: {', '.join(gaps)}. These vectors should be pursued in follow-up investigation.")
|
|
489
|
+
|
|
490
|
+
return "\n".join(lines)
|
|
491
|
+
|
|
492
|
+
def save(self, output_dir: str | Path = "cases") -> Path:
|
|
493
|
+
"""Save the report as a markdown file."""
|
|
494
|
+
output_dir = Path(output_dir)
|
|
495
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
496
|
+
filename = f"{self.case_id}_{self.query[:40].replace(' ', '_').replace('/', '_')}.md"
|
|
497
|
+
path = output_dir / filename
|
|
498
|
+
path.write_text(self.generate_markdown())
|
|
499
|
+
return path
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ── Builder for converting agent findings ────────────────────────
|
|
503
|
+
|
|
504
|
+
def from_agent_findings(
|
|
505
|
+
query: str,
|
|
506
|
+
findings: list[dict],
|
|
507
|
+
target_type: str = "unknown",
|
|
508
|
+
angles: list[str] | None = None,
|
|
509
|
+
entities: list[dict] | None = None,
|
|
510
|
+
relations: list[dict] | None = None,
|
|
511
|
+
methodology_notes: list[str] | None = None,
|
|
512
|
+
) -> InvestigationReport:
|
|
513
|
+
"""Create an enterprise-grade report from agent investigation output."""
|
|
514
|
+
case_id = f"CASE-{uuid.uuid4().hex[:8].upper()}"
|
|
515
|
+
|
|
516
|
+
report_findings = []
|
|
517
|
+
for f in findings:
|
|
518
|
+
report_findings.append(Finding(
|
|
519
|
+
title=f.get("title", "Untitled finding"),
|
|
520
|
+
description=f.get("description", ""),
|
|
521
|
+
source_type=f.get("source_type", f.get("source", "web_search")),
|
|
522
|
+
source_url=f.get("source_url", f.get("url", "")),
|
|
523
|
+
confidence=float(f.get("confidence", 0.5)),
|
|
524
|
+
entities=f.get("entities", []),
|
|
525
|
+
evidence=f.get("evidence", []),
|
|
526
|
+
))
|
|
527
|
+
|
|
528
|
+
return InvestigationReport(
|
|
529
|
+
case_id=case_id,
|
|
530
|
+
query=query,
|
|
531
|
+
target_type=target_type,
|
|
532
|
+
angles=angles or [],
|
|
533
|
+
findings=report_findings,
|
|
534
|
+
entities=entities or [],
|
|
535
|
+
relations=relations or [],
|
|
536
|
+
methodology_notes=methodology_notes or [],
|
|
537
|
+
)
|