osintengine 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. watson/web/templates/investigation-map.html +429 -0
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
+ )