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,787 @@
|
|
|
1
|
+
"""Transform Engine — recursive entity→entity discovery for the Watson graph.
|
|
2
|
+
|
|
3
|
+
Maltego's core innovation: transforms are functions that take entities as
|
|
4
|
+
input and produce new entities+relationships as output, applied recursively
|
|
5
|
+
to grow the investigation graph.
|
|
6
|
+
|
|
7
|
+
This engine:
|
|
8
|
+
1. Takes an entity graph populated from phase 2 (SURFACE) findings
|
|
9
|
+
2. Applies built-in transforms (DNS resolution, IP geolocation, email
|
|
10
|
+
extraction) recursively up to max_depth
|
|
11
|
+
3. Uses OSINT Framework to enrich findings with direct tool links
|
|
12
|
+
4. Converts new discoveries back to Finding objects for the pipeline
|
|
13
|
+
|
|
14
|
+
Built-in transforms (no external APIs needed):
|
|
15
|
+
- dns_resolution: Domain → IPAddress via dnspython
|
|
16
|
+
- subdomain_enum: Domain → Domain via crt.sh SSL certificates
|
|
17
|
+
- ip_geolocation: IPAddress → Location via ip-api.com (free, no key)
|
|
18
|
+
- email_discovery: Domain → Email via WHOIS/website scraping
|
|
19
|
+
- osint_enrichment: Any entity → tool links from OSINT Framework
|
|
20
|
+
|
|
21
|
+
Each transform is a coroutine that takes (graph, entity) and returns a list
|
|
22
|
+
of new entities. The engine applies them to new entities discovered at each
|
|
23
|
+
depth level.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import logging
|
|
30
|
+
from typing import Awaitable, Callable, Optional
|
|
31
|
+
|
|
32
|
+
from .entities import (
|
|
33
|
+
Entity,
|
|
34
|
+
EntityType,
|
|
35
|
+
Domain,
|
|
36
|
+
IPAddress,
|
|
37
|
+
Email,
|
|
38
|
+
Person,
|
|
39
|
+
Organization,
|
|
40
|
+
Location,
|
|
41
|
+
make_entity,
|
|
42
|
+
)
|
|
43
|
+
from .relationships import Relationship, RelationshipType
|
|
44
|
+
from .graph import EntityGraph
|
|
45
|
+
from .osint_framework import get_framework
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger("watson.graph.transforms")
|
|
48
|
+
|
|
49
|
+
# ── Transform type ───────────────────────────────────────────────
|
|
50
|
+
# A transform is an async function that takes the graph + an entity
|
|
51
|
+
# and returns new entities to add.
|
|
52
|
+
|
|
53
|
+
TransformFn = Callable[
|
|
54
|
+
[EntityGraph, Entity],
|
|
55
|
+
Awaitable[list[Entity]],
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TransformEngine:
|
|
60
|
+
"""Recursive entity transform engine.
|
|
61
|
+
|
|
62
|
+
Usage:
|
|
63
|
+
engine = TransformEngine(graph)
|
|
64
|
+
await engine.run(max_depth=3)
|
|
65
|
+
|
|
66
|
+
This runs transforms on entities at each depth level. Newly discovered
|
|
67
|
+
entities are added to the graph and become seeds for the next depth.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, graph: EntityGraph):
|
|
71
|
+
self.graph = graph
|
|
72
|
+
|
|
73
|
+
# Registry: entity_type → list of transforms that accept it
|
|
74
|
+
self._registry: dict[EntityType, list[TransformFn]] = {}
|
|
75
|
+
|
|
76
|
+
# Register built-in transforms
|
|
77
|
+
self._register_builtins()
|
|
78
|
+
|
|
79
|
+
def register(self, entity_type: EntityType, transform: TransformFn) -> None:
|
|
80
|
+
"""Register a transform for an entity type."""
|
|
81
|
+
if entity_type not in self._registry:
|
|
82
|
+
self._registry[entity_type] = []
|
|
83
|
+
self._registry[entity_type].append(transform)
|
|
84
|
+
|
|
85
|
+
def _register_builtins(self) -> None:
|
|
86
|
+
"""Register all built-in Maltego-style transforms."""
|
|
87
|
+
# Infrastructure transforms
|
|
88
|
+
self.register(EntityType.DOMAIN, _dns_resolution)
|
|
89
|
+
self.register(EntityType.DOMAIN, _subdomain_enum)
|
|
90
|
+
self.register(EntityType.DOMAIN, _email_discovery)
|
|
91
|
+
self.register(EntityType.IP_ADDRESS, _ip_geolocation)
|
|
92
|
+
|
|
93
|
+
# Reverse-pivot transforms: bridge infrastructure → people/orgs
|
|
94
|
+
self.register(EntityType.EMAIL, _email_to_person)
|
|
95
|
+
self.register(EntityType.PERSON, _person_to_org)
|
|
96
|
+
self.register(EntityType.ORGANIZATION, _org_to_people)
|
|
97
|
+
|
|
98
|
+
# Enrichment
|
|
99
|
+
self.register(EntityType.DOMAIN, _osint_enrichment)
|
|
100
|
+
self.register(EntityType.IP_ADDRESS, _osint_enrichment)
|
|
101
|
+
self.register(EntityType.EMAIL, _osint_enrichment)
|
|
102
|
+
self.register(EntityType.PERSON, _osint_enrichment)
|
|
103
|
+
self.register(EntityType.ORGANIZATION, _osint_enrichment)
|
|
104
|
+
|
|
105
|
+
# ── Main execution ───────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async def run(
|
|
108
|
+
self,
|
|
109
|
+
max_depth: int = 3,
|
|
110
|
+
entity_types: Optional[list[EntityType]] = None,
|
|
111
|
+
) -> int:
|
|
112
|
+
"""Run transforms recursively up to max_depth.
|
|
113
|
+
|
|
114
|
+
At each depth, applies registered transforms to entities of the
|
|
115
|
+
given types. Newly discovered entities become seeds for the next
|
|
116
|
+
depth. Returns total number of new entities discovered.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
max_depth: How many levels of transforms to apply (1-5).
|
|
120
|
+
Depth 1 = transforms on initial entities.
|
|
121
|
+
Depth 2 = transforms on entities discovered at depth 1.
|
|
122
|
+
etc.
|
|
123
|
+
entity_types: Only transform these entity types. None = all.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Total new entities added to the graph.
|
|
127
|
+
"""
|
|
128
|
+
total_new = 0
|
|
129
|
+
|
|
130
|
+
# Get initial seeds — entities to transform
|
|
131
|
+
if entity_types:
|
|
132
|
+
seeds = [
|
|
133
|
+
e for et in entity_types
|
|
134
|
+
for e in self.graph.entities_of_type(et)
|
|
135
|
+
]
|
|
136
|
+
else:
|
|
137
|
+
seeds = list(self.graph.iter_entities())
|
|
138
|
+
|
|
139
|
+
if not seeds:
|
|
140
|
+
logger.info("transform_engine: no seed entities to transform")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
# Track which entities have been transformed (by ID) to avoid loops
|
|
144
|
+
transformed: set[str] = set()
|
|
145
|
+
|
|
146
|
+
for depth in range(1, max_depth + 1):
|
|
147
|
+
new_this_round = 0
|
|
148
|
+
next_seeds: list[Entity] = []
|
|
149
|
+
|
|
150
|
+
# Get transforms applicable to current seeds
|
|
151
|
+
for entity in seeds:
|
|
152
|
+
if entity.id in transformed:
|
|
153
|
+
continue
|
|
154
|
+
transformed.add(entity.id)
|
|
155
|
+
|
|
156
|
+
entity_transforms = self._registry.get(entity.entity_type, [])
|
|
157
|
+
if not entity_transforms:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Run all transforms for this entity type IN PARALLEL
|
|
161
|
+
results = await asyncio.gather(
|
|
162
|
+
*[tf(self.graph, entity) for tf in entity_transforms],
|
|
163
|
+
return_exceptions=True,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
for i, result in enumerate(results):
|
|
167
|
+
if isinstance(result, BaseException):
|
|
168
|
+
logger.warning(
|
|
169
|
+
"transform_failed: entity=%s transform=%d error=%s",
|
|
170
|
+
entity.id[:8], i, result,
|
|
171
|
+
)
|
|
172
|
+
continue
|
|
173
|
+
if not isinstance(result, list):
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
for new_entity in result:
|
|
177
|
+
if new_entity is None:
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
added = self.graph.add_entity(new_entity)
|
|
181
|
+
if added.id == new_entity.id and new_entity.id not in transformed:
|
|
182
|
+
# New entity — add to next round
|
|
183
|
+
next_seeds.append(new_entity)
|
|
184
|
+
new_this_round += 1
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.warning(
|
|
187
|
+
"transform_add_entity_failed: %s", e,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
total_new += new_this_round
|
|
191
|
+
logger.info(
|
|
192
|
+
"transform_engine: depth=%d new=%d total=%d graph=%s",
|
|
193
|
+
depth, new_this_round, total_new, self.graph,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if not next_seeds:
|
|
197
|
+
logger.info("transform_engine: no new seeds — stopping")
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
seeds = next_seeds
|
|
201
|
+
|
|
202
|
+
return total_new
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
206
|
+
# Built-in Transforms
|
|
207
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _dns_resolution(graph: EntityGraph, entity: Entity) -> list[Entity]:
|
|
211
|
+
"""Resolve a Domain to its IP addresses using dnspython.
|
|
212
|
+
|
|
213
|
+
Creates IPAddress entities and RESOLVES_TO relationships.
|
|
214
|
+
"""
|
|
215
|
+
if entity.entity_type != EntityType.DOMAIN:
|
|
216
|
+
return []
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
import dns.resolver
|
|
220
|
+
except ImportError:
|
|
221
|
+
logger.debug("dns_resolution: dnspython not installed, skipping")
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
domain = entity.value
|
|
225
|
+
new_entities: list[Entity] = []
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
answers = await asyncio.to_thread(
|
|
229
|
+
lambda: dns.resolver.resolve(domain, "A")
|
|
230
|
+
)
|
|
231
|
+
for answer in answers:
|
|
232
|
+
ip = answer.to_text()
|
|
233
|
+
ip_entity = make_entity(
|
|
234
|
+
EntityType.IP_ADDRESS,
|
|
235
|
+
ip,
|
|
236
|
+
source="dns_resolution",
|
|
237
|
+
confidence=0.95,
|
|
238
|
+
display_name=f"{ip} (from {domain})",
|
|
239
|
+
)
|
|
240
|
+
new_entities.append(ip_entity)
|
|
241
|
+
|
|
242
|
+
# Create relationship
|
|
243
|
+
try:
|
|
244
|
+
rel = Relationship(
|
|
245
|
+
source_id=entity.id,
|
|
246
|
+
target_id=ip_entity.id,
|
|
247
|
+
rel_type=RelationshipType.RESOLVES_TO,
|
|
248
|
+
confidence=0.95,
|
|
249
|
+
source_transform="dns_resolution",
|
|
250
|
+
evidence=[f"DNS A record: {domain} → {ip}"],
|
|
251
|
+
)
|
|
252
|
+
# Add entity first, then relationship
|
|
253
|
+
graph.add_entity(ip_entity)
|
|
254
|
+
graph.add_relationship(rel)
|
|
255
|
+
except ValueError:
|
|
256
|
+
pass # Entity not in graph yet — will be added later
|
|
257
|
+
|
|
258
|
+
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
|
|
259
|
+
logger.debug("dns_resolution: no A records for %s", domain)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.warning("dns_resolution: %s → %s", domain, e)
|
|
262
|
+
|
|
263
|
+
return new_entities
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def _subdomain_enum(graph: EntityGraph, entity: Entity) -> list[Entity]:
|
|
267
|
+
"""Discover subdomains via crt.sh SSL certificate transparency logs.
|
|
268
|
+
|
|
269
|
+
Creates Domain entities and HAS_SUBDOMAIN relationships.
|
|
270
|
+
"""
|
|
271
|
+
if entity.entity_type != EntityType.DOMAIN:
|
|
272
|
+
return []
|
|
273
|
+
|
|
274
|
+
domain = entity.value
|
|
275
|
+
new_entities: list[Entity] = []
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
import httpx
|
|
279
|
+
|
|
280
|
+
url = f"https://crt.sh/?q=%25.{domain}&output=json"
|
|
281
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
282
|
+
resp = await client.get(
|
|
283
|
+
url,
|
|
284
|
+
headers={"User-Agent": "WatsonOSINT/0.4"},
|
|
285
|
+
)
|
|
286
|
+
if resp.status_code != 200:
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
certs = resp.json()
|
|
290
|
+
if not isinstance(certs, list):
|
|
291
|
+
return []
|
|
292
|
+
|
|
293
|
+
seen: set[str] = set()
|
|
294
|
+
for cert in certs[:100]:
|
|
295
|
+
names = cert.get("name_value", "")
|
|
296
|
+
for name in names.split("\n"):
|
|
297
|
+
name = name.strip().lower()
|
|
298
|
+
if not name or name == domain:
|
|
299
|
+
continue
|
|
300
|
+
if name.startswith("*."):
|
|
301
|
+
name = name[2:]
|
|
302
|
+
if name in seen:
|
|
303
|
+
continue
|
|
304
|
+
seen.add(name)
|
|
305
|
+
|
|
306
|
+
sub_entity = make_entity(
|
|
307
|
+
EntityType.DOMAIN,
|
|
308
|
+
name,
|
|
309
|
+
source="subdomain_enum",
|
|
310
|
+
confidence=0.80,
|
|
311
|
+
display_name=name,
|
|
312
|
+
)
|
|
313
|
+
new_entities.append(sub_entity)
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
rel = Relationship(
|
|
317
|
+
source_id=entity.id,
|
|
318
|
+
target_id=sub_entity.id,
|
|
319
|
+
rel_type=RelationshipType.HAS_SUBDOMAIN,
|
|
320
|
+
confidence=0.80,
|
|
321
|
+
source_transform="subdomain_enum",
|
|
322
|
+
evidence=[
|
|
323
|
+
f"https://crt.sh/?q=%25.{domain}"
|
|
324
|
+
],
|
|
325
|
+
)
|
|
326
|
+
graph.add_entity(sub_entity)
|
|
327
|
+
graph.add_relationship(rel)
|
|
328
|
+
except ValueError:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
logger.info(
|
|
332
|
+
"subdomain_enum: %s → %d subdomains",
|
|
333
|
+
domain, len(new_entities),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
except Exception as e:
|
|
337
|
+
logger.warning("subdomain_enum: %s → %s", domain, e)
|
|
338
|
+
|
|
339
|
+
return new_entities
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def _ip_geolocation(graph: EntityGraph, entity: Entity) -> list[Entity]:
|
|
343
|
+
"""Geolocate an IP address using ip-api.com (free, no API key).
|
|
344
|
+
|
|
345
|
+
Creates Location entities and LOCATED_IN relationships.
|
|
346
|
+
"""
|
|
347
|
+
if entity.entity_type != EntityType.IP_ADDRESS:
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
ip = entity.value
|
|
351
|
+
new_entities: list[Entity] = []
|
|
352
|
+
|
|
353
|
+
# Skip private/reserved IPs
|
|
354
|
+
if _is_private_ip(ip):
|
|
355
|
+
return []
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
import httpx
|
|
359
|
+
|
|
360
|
+
url = f"http://ip-api.com/json/{ip}?fields=status,message,country,countryCode,region,city,lat,lon,isp,org,as"
|
|
361
|
+
async with httpx.AsyncClient(timeout=8) as client:
|
|
362
|
+
resp = await client.get(url)
|
|
363
|
+
if resp.status_code != 200:
|
|
364
|
+
return []
|
|
365
|
+
|
|
366
|
+
data = resp.json()
|
|
367
|
+
if data.get("status") != "success":
|
|
368
|
+
return []
|
|
369
|
+
|
|
370
|
+
city = data.get("city", "")
|
|
371
|
+
country = data.get("country", "")
|
|
372
|
+
country_code = data.get("countryCode", "")
|
|
373
|
+
lat = data.get("lat", 0.0)
|
|
374
|
+
lon = data.get("lon", 0.0)
|
|
375
|
+
isp = data.get("isp", "")
|
|
376
|
+
org = data.get("org", "")
|
|
377
|
+
|
|
378
|
+
# Create location entity
|
|
379
|
+
loc_name = f"{city}, {country}" if city else country
|
|
380
|
+
loc_entity = Location(
|
|
381
|
+
value=loc_name.lower(),
|
|
382
|
+
display_name=loc_name,
|
|
383
|
+
latitude=lat,
|
|
384
|
+
longitude=lon,
|
|
385
|
+
country_code=country_code,
|
|
386
|
+
source="ip_geolocation",
|
|
387
|
+
confidence=0.90,
|
|
388
|
+
properties={
|
|
389
|
+
"isp": isp,
|
|
390
|
+
"org": org,
|
|
391
|
+
"ip": ip,
|
|
392
|
+
},
|
|
393
|
+
)
|
|
394
|
+
new_entities.append(loc_entity)
|
|
395
|
+
|
|
396
|
+
# Enrich the IP entity with ISP/ASN info
|
|
397
|
+
if isp:
|
|
398
|
+
entity.properties["isp"] = isp
|
|
399
|
+
entity.display_name = f"{ip} ({isp})"
|
|
400
|
+
if org:
|
|
401
|
+
entity.properties["org"] = org
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
rel = Relationship(
|
|
405
|
+
source_id=entity.id,
|
|
406
|
+
target_id=loc_entity.id,
|
|
407
|
+
rel_type=RelationshipType.LOCATED_IN,
|
|
408
|
+
confidence=0.90,
|
|
409
|
+
source_transform="ip_geolocation",
|
|
410
|
+
evidence=[f"ip-api.com: {ip} → {loc_name}"],
|
|
411
|
+
)
|
|
412
|
+
graph.add_entity(loc_entity)
|
|
413
|
+
graph.add_relationship(rel)
|
|
414
|
+
except ValueError:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
except Exception as e:
|
|
418
|
+
logger.warning("ip_geolocation: %s → %s", ip, e)
|
|
419
|
+
|
|
420
|
+
return new_entities
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def _email_discovery(graph: EntityGraph, entity: Entity) -> list[Entity]:
|
|
424
|
+
"""Discover email addresses associated with a domain.
|
|
425
|
+
|
|
426
|
+
Searches crt.sh and WHOIS for email references. Creates Email entities
|
|
427
|
+
and HAS_EMAIL relationships.
|
|
428
|
+
"""
|
|
429
|
+
if entity.entity_type != EntityType.DOMAIN:
|
|
430
|
+
return []
|
|
431
|
+
|
|
432
|
+
domain = entity.value
|
|
433
|
+
new_entities: list[Entity] = []
|
|
434
|
+
seen_emails: set[str] = set()
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
import httpx
|
|
438
|
+
import re
|
|
439
|
+
|
|
440
|
+
# Method 1: crt.sh sometimes contains email addresses in cert fields
|
|
441
|
+
url = f"https://crt.sh/?q=%25.{domain}&output=json"
|
|
442
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
443
|
+
resp = await client.get(
|
|
444
|
+
url,
|
|
445
|
+
headers={"User-Agent": "WatsonOSINT/0.4"},
|
|
446
|
+
)
|
|
447
|
+
if resp.status_code == 200:
|
|
448
|
+
certs = resp.json()
|
|
449
|
+
if isinstance(certs, list):
|
|
450
|
+
email_re = re.compile(
|
|
451
|
+
r'[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}'
|
|
452
|
+
)
|
|
453
|
+
for cert in certs[:50]:
|
|
454
|
+
issuer = cert.get("issuer_name", "")
|
|
455
|
+
for match in email_re.finditer(issuer):
|
|
456
|
+
email = match.group(0).lower()
|
|
457
|
+
if email not in seen_emails:
|
|
458
|
+
seen_emails.add(email)
|
|
459
|
+
|
|
460
|
+
# Method 2: Check common role-based emails
|
|
461
|
+
_common_roles = [
|
|
462
|
+
"admin", "support", "info", "contact", "security",
|
|
463
|
+
"abuse", "hostmaster", "postmaster", "webmaster",
|
|
464
|
+
]
|
|
465
|
+
for role in _common_roles:
|
|
466
|
+
email = f"{role}@{domain}"
|
|
467
|
+
if email not in seen_emails:
|
|
468
|
+
seen_emails.add(email)
|
|
469
|
+
|
|
470
|
+
# Create entity + relationship for each discovered email
|
|
471
|
+
for email in seen_emails:
|
|
472
|
+
email_entity = make_entity(
|
|
473
|
+
EntityType.EMAIL,
|
|
474
|
+
email,
|
|
475
|
+
source="email_discovery",
|
|
476
|
+
confidence=0.55, # Lower confidence — not verified
|
|
477
|
+
display_name=email,
|
|
478
|
+
)
|
|
479
|
+
new_entities.append(email_entity)
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
rel = Relationship(
|
|
483
|
+
source_id=entity.id,
|
|
484
|
+
target_id=email_entity.id,
|
|
485
|
+
rel_type=RelationshipType.HAS_EMAIL,
|
|
486
|
+
confidence=0.55,
|
|
487
|
+
source_transform="email_discovery",
|
|
488
|
+
evidence=[f"https://crt.sh/?q=%25.{domain}"],
|
|
489
|
+
)
|
|
490
|
+
graph.add_entity(email_entity)
|
|
491
|
+
graph.add_relationship(rel)
|
|
492
|
+
except ValueError:
|
|
493
|
+
pass
|
|
494
|
+
|
|
495
|
+
if seen_emails:
|
|
496
|
+
logger.info(
|
|
497
|
+
"email_discovery: %s → %d emails",
|
|
498
|
+
domain, len(seen_emails),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
except Exception as e:
|
|
502
|
+
logger.warning("email_discovery: %s → %s", domain, e)
|
|
503
|
+
|
|
504
|
+
return new_entities
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
async def _osint_enrichment(graph: EntityGraph, entity: Entity) -> list[Entity]:
|
|
508
|
+
"""Enrich any entity with OSINT Framework tool links.
|
|
509
|
+
|
|
510
|
+
This doesn't create new entities — it enriches the existing entity's
|
|
511
|
+
properties with direct links to OSINT Framework tools relevant to
|
|
512
|
+
this entity type. These links appear in the finding's evidence.
|
|
513
|
+
|
|
514
|
+
This is the key integration that compensates for Watson not having
|
|
515
|
+
direct access to paid databases like Maltego does.
|
|
516
|
+
"""
|
|
517
|
+
framework = get_framework()
|
|
518
|
+
|
|
519
|
+
tools = framework.get_search_urls(
|
|
520
|
+
entity.entity_type,
|
|
521
|
+
entity.value,
|
|
522
|
+
max_results=8,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
if tools:
|
|
526
|
+
entity.properties["osint_framework_tools"] = [
|
|
527
|
+
{"name": t["tool"], "url": t["url"], "category": t["category"]}
|
|
528
|
+
for t in tools
|
|
529
|
+
]
|
|
530
|
+
entity.source = entity.source or "osint_enrichment"
|
|
531
|
+
|
|
532
|
+
return [] # No new entities — enrichment only
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
536
|
+
# Reverse-Pivot Transforms: Email → Person → Organization
|
|
537
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
538
|
+
# These bridge the infrastructure graph to the people/organization
|
|
539
|
+
# pipeline — the Maltego "pivot to person" equivalent.
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
async def _email_to_person(graph: EntityGraph, entity: Entity) -> list[Entity]:
|
|
543
|
+
"""Extract person identity from an email address.
|
|
544
|
+
|
|
545
|
+
- Parses the local part for name patterns (baron.lorenzo → Lorenzo Baron)
|
|
546
|
+
- Checks Have I Been Pwned for breach context
|
|
547
|
+
- Searches the web for identity clues (LinkedIn, GitHub, etc.)
|
|
548
|
+
|
|
549
|
+
Creates Person entities and EMAIL→PERSON relationships.
|
|
550
|
+
"""
|
|
551
|
+
if entity.entity_type != EntityType.EMAIL:
|
|
552
|
+
return []
|
|
553
|
+
|
|
554
|
+
email = entity.value
|
|
555
|
+
new_entities: list[Entity] = []
|
|
556
|
+
|
|
557
|
+
if "@" not in email:
|
|
558
|
+
return []
|
|
559
|
+
|
|
560
|
+
local_part = email.split("@")[0]
|
|
561
|
+
domain = email.split("@")[1]
|
|
562
|
+
|
|
563
|
+
# Skip role-based emails — can't extract person from "support@company.com"
|
|
564
|
+
_role_prefixes = {
|
|
565
|
+
"admin", "support", "info", "contact", "hello", "help",
|
|
566
|
+
"noreply", "no-reply", "postmaster", "abuse", "security",
|
|
567
|
+
"webmaster", "hostmaster", "billing", "jobs", "sales",
|
|
568
|
+
"marketing", "press", "media", "legal", "hr", "office",
|
|
569
|
+
}
|
|
570
|
+
if local_part.lower().rstrip("0123456789") in _role_prefixes:
|
|
571
|
+
logger.debug("email_to_person: skipping role email %s", email)
|
|
572
|
+
return []
|
|
573
|
+
|
|
574
|
+
# Parse name from email username
|
|
575
|
+
import re as _re
|
|
576
|
+
|
|
577
|
+
# Split on common separators: dots, underscores, hyphens, numbers
|
|
578
|
+
name_parts = _re.sub(r'[\d._\-+]+', ' ', local_part).strip().split()
|
|
579
|
+
name_parts = [p for p in name_parts if len(p) >= 2 and not p.isdigit()]
|
|
580
|
+
|
|
581
|
+
if len(name_parts) < 1:
|
|
582
|
+
return []
|
|
583
|
+
|
|
584
|
+
# Try both name orders (western: given surname, eastern: surname given)
|
|
585
|
+
candidates: list[tuple[str, str, str, str]] = [] # (full_name, given, surname, entity_value)
|
|
586
|
+
|
|
587
|
+
if len(name_parts) == 1:
|
|
588
|
+
# Single name — treat as given name only (low confidence)
|
|
589
|
+
single_name = name_parts[0].title()
|
|
590
|
+
candidates.append((
|
|
591
|
+
single_name,
|
|
592
|
+
name_parts[0], # given
|
|
593
|
+
"", # unknown surname
|
|
594
|
+
single_name.lower(),
|
|
595
|
+
))
|
|
596
|
+
elif len(name_parts) >= 2:
|
|
597
|
+
# Western order: "john.smith" → John Smith (given first)
|
|
598
|
+
# Eastern/Hungarian order: "bodi.ildiko" → Bódi Ildikó (surname first)
|
|
599
|
+
# We try BOTH — creating separate Person entities for each interpretation
|
|
600
|
+
western_display = " ".join(name_parts).title()
|
|
601
|
+
eastern_display = " ".join(reversed(name_parts)).title()
|
|
602
|
+
|
|
603
|
+
# Western candidate: given=surname order
|
|
604
|
+
candidates.append((
|
|
605
|
+
western_display,
|
|
606
|
+
name_parts[0], # given = first
|
|
607
|
+
name_parts[-1], # surname = last
|
|
608
|
+
western_display.lower(), # value for entity ID
|
|
609
|
+
))
|
|
610
|
+
# Eastern candidate: surname=given order (different entity)
|
|
611
|
+
candidates.append((
|
|
612
|
+
eastern_display,
|
|
613
|
+
name_parts[-1], # given = original last part
|
|
614
|
+
name_parts[0], # surname = original first part
|
|
615
|
+
eastern_display.lower(),
|
|
616
|
+
))
|
|
617
|
+
|
|
618
|
+
for full_name, given, surname, entity_value in candidates:
|
|
619
|
+
# Skip if it looks like a company name, not a person
|
|
620
|
+
_company_words = {
|
|
621
|
+
"inc", "corp", "ltd", "llc", "gmbh", "group", "holdings",
|
|
622
|
+
"solutions", "technologies", "services", "studio", "agency",
|
|
623
|
+
"team", "department", "office",
|
|
624
|
+
}
|
|
625
|
+
if any(w in _company_words for w in name_parts):
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
person = Person(
|
|
629
|
+
value=entity_value,
|
|
630
|
+
display_name=full_name.title(),
|
|
631
|
+
given_name=given.title(),
|
|
632
|
+
surname=surname.title() if surname else "",
|
|
633
|
+
source="email_to_person",
|
|
634
|
+
confidence=0.35 if not surname else 0.50, # Lower for single-name
|
|
635
|
+
properties={
|
|
636
|
+
"email": email,
|
|
637
|
+
"domain": domain,
|
|
638
|
+
"extraction_method": "email_username_parsing",
|
|
639
|
+
},
|
|
640
|
+
)
|
|
641
|
+
new_entities.append(person)
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
rel = Relationship(
|
|
645
|
+
source_id=entity.id,
|
|
646
|
+
target_id=person.id,
|
|
647
|
+
rel_type=RelationshipType.OWNS,
|
|
648
|
+
confidence=0.50,
|
|
649
|
+
source_transform="email_to_person",
|
|
650
|
+
evidence=[f"Email username parsed: {local_part} → {full_name}"],
|
|
651
|
+
)
|
|
652
|
+
graph.add_entity(person)
|
|
653
|
+
graph.add_relationship(rel)
|
|
654
|
+
except ValueError:
|
|
655
|
+
pass
|
|
656
|
+
|
|
657
|
+
if new_entities:
|
|
658
|
+
logger.info(
|
|
659
|
+
"email_to_person: %s → %d person candidates",
|
|
660
|
+
email, len(new_entities),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
return new_entities
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
async def _person_to_org(graph: EntityGraph, entity: Entity) -> list[Entity]:
|
|
667
|
+
"""Infer organization from a person's context.
|
|
668
|
+
|
|
669
|
+
If the person was discovered from an email, the email's domain
|
|
670
|
+
suggests their organization. If from LinkedIn, the company profile.
|
|
671
|
+
|
|
672
|
+
Creates Organization entities and WORKS_AT relationships.
|
|
673
|
+
"""
|
|
674
|
+
if entity.entity_type != EntityType.PERSON:
|
|
675
|
+
return []
|
|
676
|
+
|
|
677
|
+
new_entities: list[Entity] = []
|
|
678
|
+
|
|
679
|
+
# Check if this person was derived from an email
|
|
680
|
+
if "email" in entity.properties and "@" in entity.properties["email"]:
|
|
681
|
+
domain = entity.properties["email"].split("@")[-1]
|
|
682
|
+
|
|
683
|
+
# Skip known email providers
|
|
684
|
+
_providers = {
|
|
685
|
+
"gmail.com", "yahoo.com", "outlook.com", "hotmail.com",
|
|
686
|
+
"protonmail.com", "icloud.com", "aol.com", "mail.com",
|
|
687
|
+
"gmx.com", "live.com", "msn.com",
|
|
688
|
+
}
|
|
689
|
+
if domain.lower() in _providers:
|
|
690
|
+
return []
|
|
691
|
+
|
|
692
|
+
# Create organization from domain
|
|
693
|
+
org_name = domain.split(".")[0].title()
|
|
694
|
+
org = Organization(
|
|
695
|
+
value=domain.lower(),
|
|
696
|
+
display_name=org_name,
|
|
697
|
+
source="person_to_org",
|
|
698
|
+
confidence=0.45,
|
|
699
|
+
properties={
|
|
700
|
+
"domain": domain,
|
|
701
|
+
"inferred_from": entity.value,
|
|
702
|
+
},
|
|
703
|
+
)
|
|
704
|
+
new_entities.append(org)
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
rel = Relationship(
|
|
708
|
+
source_id=entity.id,
|
|
709
|
+
target_id=org.id,
|
|
710
|
+
rel_type=RelationshipType.WORKS_AT,
|
|
711
|
+
confidence=0.45,
|
|
712
|
+
source_transform="person_to_org",
|
|
713
|
+
evidence=[f"Email domain match: {entity.properties['email']}"],
|
|
714
|
+
)
|
|
715
|
+
graph.add_entity(org)
|
|
716
|
+
graph.add_relationship(rel)
|
|
717
|
+
except ValueError:
|
|
718
|
+
pass
|
|
719
|
+
|
|
720
|
+
# If this person already has org connections, recurse to find more
|
|
721
|
+
existing_orgs = graph.get_neighbors_by_type(
|
|
722
|
+
entity.id, EntityType.ORGANIZATION
|
|
723
|
+
)
|
|
724
|
+
for org in existing_orgs:
|
|
725
|
+
# Enrich with OSINT Framework corporate tools
|
|
726
|
+
framework = get_framework()
|
|
727
|
+
tools = framework.get_search_urls(
|
|
728
|
+
EntityType.ORGANIZATION,
|
|
729
|
+
org.value,
|
|
730
|
+
max_results=5,
|
|
731
|
+
)
|
|
732
|
+
if tools:
|
|
733
|
+
org.properties["osint_framework_tools"] = [
|
|
734
|
+
{"name": t["tool"], "url": t["url"]}
|
|
735
|
+
for t in tools
|
|
736
|
+
]
|
|
737
|
+
|
|
738
|
+
return new_entities
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
async def _org_to_people(graph: EntityGraph, entity: Entity) -> list[Entity]:
|
|
742
|
+
"""Discover people associated with an organization.
|
|
743
|
+
|
|
744
|
+
Searches for publicly listed employees, executives, or contacts.
|
|
745
|
+
Currently uses OSINT Framework links for manual follow-up.
|
|
746
|
+
Future: integrate LinkedIn API, RocketReach, or Hunter.io.
|
|
747
|
+
"""
|
|
748
|
+
if entity.entity_type != EntityType.ORGANIZATION:
|
|
749
|
+
return []
|
|
750
|
+
|
|
751
|
+
# Enrich org with people-search tool links from OSINT Framework
|
|
752
|
+
framework = get_framework()
|
|
753
|
+
people_tools = framework.get_search_urls(
|
|
754
|
+
EntityType.PERSON,
|
|
755
|
+
entity.value,
|
|
756
|
+
max_results=5,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
if people_tools:
|
|
760
|
+
entity.properties["osint_framework_people_tools"] = [
|
|
761
|
+
{"name": t["tool"], "url": t["url"]}
|
|
762
|
+
for t in people_tools
|
|
763
|
+
]
|
|
764
|
+
|
|
765
|
+
return [] # No new entities — enrichment only (for now)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _is_private_ip(ip: str) -> bool:
|
|
769
|
+
"""Check if an IP address is private/reserved."""
|
|
770
|
+
parts = ip.split(".")
|
|
771
|
+
if len(parts) != 4:
|
|
772
|
+
return False
|
|
773
|
+
try:
|
|
774
|
+
octets = [int(p) for p in parts]
|
|
775
|
+
except ValueError:
|
|
776
|
+
return False
|
|
777
|
+
|
|
778
|
+
# 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
|
|
779
|
+
if octets[0] == 10:
|
|
780
|
+
return True
|
|
781
|
+
if octets[0] == 172 and 16 <= octets[1] <= 31:
|
|
782
|
+
return True
|
|
783
|
+
if octets[0] == 192 and octets[1] == 168:
|
|
784
|
+
return True
|
|
785
|
+
if octets[0] == 127:
|
|
786
|
+
return True
|
|
787
|
+
return False
|