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
@@ -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