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,489 @@
1
+ """EntityGraph — type-safe graph of OSINT entities and relationships.
2
+
3
+ Pure Python implementation (no networkx dependency). Supports:
4
+ - Adding/querying entities by type, value, or ID
5
+ - Adding typed relationships between entities
6
+ - Traversal queries (neighbors, paths, subgraphs)
7
+ - Export to findings for pipeline integration
8
+ - Import from existing findings (reverse extraction)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from collections import defaultdict
15
+ from datetime import datetime, timezone
16
+ from typing import Iterator, Optional
17
+
18
+ from .entities import (
19
+ Entity,
20
+ EntityType,
21
+ Domain,
22
+ IPAddress,
23
+ Email,
24
+ Person,
25
+ Organization,
26
+ Website,
27
+ Location,
28
+ Document,
29
+ make_entity,
30
+ )
31
+ from .relationships import (
32
+ Relationship,
33
+ RelationshipType,
34
+ )
35
+
36
+ logger = logging.getLogger("watson.graph")
37
+
38
+
39
+ class EntityGraph:
40
+ """A directed, typed OSINT entity graph.
41
+
42
+ Entities are nodes. Relationships are directed edges with a type label.
43
+ The graph supports traversal queries and can export to the existing
44
+ Finding model for pipeline integration.
45
+ """
46
+
47
+ def __init__(self):
48
+ # Primary storage
49
+ self._entities: dict[str, Entity] = {} # id → Entity
50
+ self._relationships: dict[str, Relationship] = {} # id → Relationship
51
+
52
+ # Indexes for fast lookup
53
+ self._by_type: dict[EntityType, set[str]] = defaultdict(set) # type → {entity_id, ...}
54
+ self._by_value: dict[str, str] = {} # "type:value" → entity_id
55
+ self._adj_out: dict[str, set[str]] = defaultdict(set) # source_id → {rel_id, ...}
56
+ self._adj_in: dict[str, set[str]] = defaultdict(set) # target_id → {rel_id, ...}
57
+
58
+ # ── CRUD ─────────────────────────────────────────────────────
59
+
60
+ @property
61
+ def entity_count(self) -> int:
62
+ return len(self._entities)
63
+
64
+ @property
65
+ def relationship_count(self) -> int:
66
+ return len(self._relationships)
67
+
68
+ def has_entity(self, entity_id: str) -> bool:
69
+ return entity_id in self._entities
70
+
71
+ def get_entity(self, entity_id: str) -> Optional[Entity]:
72
+ return self._entities.get(entity_id)
73
+
74
+ def get_entity_by_value(self, entity_type: EntityType, value: str) -> Optional[Entity]:
75
+ """Look up an entity by type + value (deterministic)."""
76
+ key = f"{entity_type.value}:{value.lower().strip()}"
77
+ entity_id = self._by_value.get(key)
78
+ if entity_id:
79
+ return self._entities.get(entity_id)
80
+ return None
81
+
82
+ def add_entity(self, entity: Entity) -> Entity:
83
+ """Add an entity. If it already exists (same ID), merge confidence and properties."""
84
+ existing = self._entities.get(entity.id)
85
+ if existing:
86
+ # Merge: take the higher confidence, merge properties
87
+ existing.confidence = max(existing.confidence, entity.confidence)
88
+ existing.properties.update(entity.properties)
89
+ if entity.source and not existing.source:
90
+ existing.source = entity.source
91
+ return existing
92
+
93
+ self._entities[entity.id] = entity
94
+ self._by_type[entity.entity_type].add(entity.id)
95
+ key = f"{entity.entity_type.value}:{entity.value.lower().strip()}"
96
+ self._by_value[key] = entity.id
97
+ return entity
98
+
99
+ def add_relationship(self, rel: Relationship) -> Relationship:
100
+ """Add a relationship between two entities. Both must exist."""
101
+ if rel.id in self._relationships:
102
+ return self._relationships[rel.id]
103
+
104
+ if rel.source_id not in self._entities:
105
+ raise ValueError(f"Source entity {rel.source_id} not in graph")
106
+ if rel.target_id not in self._entities:
107
+ raise ValueError(f"Target entity {rel.target_id} not in graph")
108
+
109
+ self._relationships[rel.id] = rel
110
+ self._adj_out[rel.source_id].add(rel.id)
111
+ self._adj_in[rel.target_id].add(rel.id)
112
+ return rel
113
+
114
+ def add_or_get(self, entity_type: EntityType, value: str, **kwargs) -> Entity:
115
+ """Convenience: get existing entity or create + add a new one."""
116
+ existing = self.get_entity_by_value(entity_type, value)
117
+ if existing:
118
+ return existing
119
+ entity = make_entity(entity_type, value, **kwargs)
120
+ return self.add_entity(entity)
121
+
122
+ # ── Traversal ─────────────────────────────────────────────────
123
+
124
+ def get_outgoing(self, entity_id: str) -> list[Relationship]:
125
+ """Get all relationships where entity is the source."""
126
+ rel_ids = self._adj_out.get(entity_id, set())
127
+ return [self._relationships[rid] for rid in rel_ids if rid in self._relationships]
128
+
129
+ def get_incoming(self, entity_id: str) -> list[Relationship]:
130
+ """Get all relationships where entity is the target."""
131
+ rel_ids = self._adj_in.get(entity_id, set())
132
+ return [self._relationships[rid] for rid in rel_ids if rid in self._relationships]
133
+
134
+ def get_neighbors(self, entity_id: str) -> list[Entity]:
135
+ """Get all entities directly connected to this one."""
136
+ neighbors: dict[str, Entity] = {}
137
+ for rel in self.get_outgoing(entity_id):
138
+ if rel.target_id in self._entities:
139
+ neighbors[rel.target_id] = self._entities[rel.target_id]
140
+ for rel in self.get_incoming(entity_id):
141
+ if rel.source_id in self._entities:
142
+ neighbors[rel.source_id] = self._entities[rel.source_id]
143
+ return list(neighbors.values())
144
+
145
+ def get_neighbors_by_type(
146
+ self, entity_id: str, entity_type: EntityType
147
+ ) -> list[Entity]:
148
+ """Get neighbors of a specific type."""
149
+ return [
150
+ e for e in self.get_neighbors(entity_id)
151
+ if e.entity_type == entity_type
152
+ ]
153
+
154
+ def entities_of_type(self, entity_type: EntityType) -> list[Entity]:
155
+ """Get all entities of a given type."""
156
+ ids = self._by_type.get(entity_type, set())
157
+ return [self._entities[eid] for eid in ids if eid in self._entities]
158
+
159
+ def iter_entities(self) -> Iterator[Entity]:
160
+ """Iterate over all entities."""
161
+ yield from self._entities.values()
162
+
163
+ def iter_relationships(self) -> Iterator[Relationship]:
164
+ """Iterate over all relationships."""
165
+ yield from self._relationships.values()
166
+
167
+ # ── Import from findings ──────────────────────────────────────
168
+
169
+ def ingest_findings(
170
+ self,
171
+ findings: list,
172
+ source_transform: str = "surface_ingest",
173
+ ) -> int:
174
+ """Extract entities from existing investigation findings.
175
+
176
+ Parses finding titles/descriptions for domains, IPs, emails, and
177
+ adds them to the graph. Returns number of entities added.
178
+
179
+ This is the bridge between the existing pipeline and the graph engine.
180
+ It does NOT modify the pipeline — it reads findings and populates the
181
+ graph for further enrichment.
182
+ """
183
+ import re
184
+ added = 0
185
+
186
+ # Extraction patterns
187
+ _domain_re = re.compile(
188
+ r'\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+'
189
+ r'[a-zA-Z]{2,}\b'
190
+ )
191
+ _ip_re = re.compile(
192
+ r'\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}'
193
+ r'(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b'
194
+ )
195
+ _email_re = re.compile(r'\b[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}\b')
196
+
197
+ # Known publisher/platform domains to skip during graph ingestion.
198
+ # These are news outlets, social media, blogging platforms, and other
199
+ # domains that appear in findings as *sources* rather than as the
200
+ # subject of investigation. Kept broad to minimize noise while
201
+ # allowing real domains (openai.com, gratitudamerica.org, etc.) through.
202
+ _skip_domains = {
203
+ # ── Social media / platforms ──
204
+ "linkedin.com", "twitter.com", "x.com", "facebook.com", "fb.com",
205
+ "instagram.com", "github.com", "youtube.com", "youtu.be",
206
+ "reddit.com", "t.me", "telegram.org", "whatsapp.com",
207
+ "tiktok.com", "snapchat.com", "pinterest.com", "discord.com",
208
+ "twitch.tv", "vimeo.com", "dailymotion.com",
209
+ # ── Reference / wiki ──
210
+ "wikipedia.org", "wikidata.org", "wikimedia.org",
211
+ "web.archive.org", "archive.org", "archive.is", "archive.today",
212
+ "snopes.com", "politifact.com", "factcheck.org",
213
+ # ── Search / email / infra (never targets) ──
214
+ "google.com", "bing.com", "yahoo.com", "duckduckgo.com",
215
+ "amazon.com", # IP WHOIS noise
216
+ "gmail.com", "outlook.com", "hotmail.com", "protonmail.com",
217
+ "live.com", "icloud.com", "mail.com", "yandex.com",
218
+ "googlemail.com", # MX record noise
219
+ # ── Blogging / newsletter platforms ──
220
+ "medium.com", "substack.com", "wordpress.com", "blogspot.com",
221
+ "blogger.com", "tumblr.com", "ghost.io", "typepad.com",
222
+ # ── Major US/UK news ──
223
+ "nytimes.com", "washingtonpost.com", "wsj.com", "bloomberg.com",
224
+ "reuters.com", "apnews.com", "bbc.com", "bbc.co.uk",
225
+ "cnn.com", "foxnews.com", "nbcnews.com", "cbsnews.com",
226
+ "abcnews.go.com", "usatoday.com", "latimes.com", "chicagotribune.com",
227
+ "theguardian.com", "independent.co.uk", "telegraph.co.uk",
228
+ "dailymail.co.uk", "mirror.co.uk", "nypost.com", "newsweek.com",
229
+ "time.com", "politico.com", "axios.com", "thehill.com",
230
+ # ── European / international news (publisher domains from OSINT findings) ──
231
+ "digi24.ro", "adevarul.ro", "knews.media", "monitorulcj.ro",
232
+ "informat.ro", "hotnews.ro", "stirileprotv.ro", "mediafax.ro",
233
+ "ziare.com", "gandul.ro", "libertatea.ro", "evz.ro",
234
+ "spiegel.de", "zeit.de", "faz.net", "sueddeutsche.de",
235
+ "dw.com", "bild.de", "welt.de", "tagesschau.de",
236
+ "lemonde.fr", "lefigaro.fr", "liberation.fr", "france24.com",
237
+ "elpais.com", "elmundo.es", "abc.es", "lavanguardia.com",
238
+ "corriere.it", "repubblica.it", "lastampa.it", "ilsole24ore.com",
239
+ "ansa.it", "rainews.it", "ilfattoquotidiano.it",
240
+ "rferl.org", "euractiv.com", "euronews.com",
241
+ # ── Business / tech news ──
242
+ "forbes.com", "fortune.com", "inc.com", "entrepreneur.com",
243
+ "businessinsider.com", "insider.com", "fastcompany.com",
244
+ "techcrunch.com", "theverge.com", "arstechnica.com", "wired.com",
245
+ "engadget.com", "gizmodo.com", "mashable.com", "thenextweb.com",
246
+ "zdnet.com", "cnet.com",
247
+ # ── General-interest / long-form ──
248
+ "vox.com", "slate.com", "salon.com", "thedailybeast.com",
249
+ "buzzfeed.com", "buzzfeednews.com", "vice.com",
250
+ "huffpost.com", "huffingtonpost.com", "theatlantic.com",
251
+ "newyorker.com", "vanityfair.com", "rollingstone.com",
252
+ "esquire.com", "qz.com", "recode.net",
253
+ "motherjones.com", "prospect.org", "newrepublic.com",
254
+ "thenation.com", "nationalreview.com",
255
+ # ── AOL / legacy portals ──
256
+ "aol.com", "msn.com",
257
+ # ── Legal/crime news (publishers, not registries) ──
258
+ "npr.org", "legalclarity.org", "ukcolumn.org",
259
+ # ── OSINT / investigative tools (not targets) ──
260
+ "opensanctions.org", "crt.sh", "shodan.io",
261
+ "censys.io", "zoomeye.org", "fofa.info",
262
+ "urlscan.io", "virustotal.com", "abuseipdb.com",
263
+ "opencorporates.com", # corporate registry tool
264
+ # ── Financial / legal reference sites (publishers, not targets) ──
265
+ "gurufocus.com", "natlawreview.com",
266
+ "cornerstone.com", # legal research firm
267
+ "johnsonfistel.com", # law firm
268
+ # ── Profile / platform domains (NOT targets — profile hosts) ──
269
+ "happenstance.ai", # AI profile aggregator
270
+ "authortrends.com", # author directory platform
271
+ "jailexchange.com", # jail inmate lookup platform
272
+ "courtcasefinder.com", # court case aggregator
273
+ "volza.com", # B2B trade platform
274
+ "rocketreach.co", "rocketreach.com", # email finders
275
+ "signalhire.com", # recruiting platform
276
+ "zoominfo.com", # B2B contact database
277
+ "marketscreener.com", # financial profile aggregator
278
+ "researchgate.net", # academic profile host
279
+ # ── Aggregators / link shorteners ──
280
+ "apple.news", "news.google.com", "flipboard.com",
281
+ "bit.ly", "tinyurl.com", "ow.ly", "buff.ly", "t.co",
282
+ # ── Political / activism platforms (source URLs, not targets) ──
283
+ "abgeordnetenwatch.de", "bewegung.social", "change.org",
284
+ "petition.org.uk", "avaaz.org", "openpetition.de",
285
+ # ── GitHub proxy / mirror sites (never targets) ──
286
+ "githubhosts.xuanyuan.me",
287
+ # ── CDN / image hosts / file hosting ──
288
+ "iconarchive.com", "flaticon.com", "shutterstock.com",
289
+ "gettyimages.com", "istockphoto.com", "unsplash.com",
290
+ "pexels.com", "pixabay.com", "freepik.com", "vecteezy.com",
291
+ "cloudfront.net", "akamai.net", "akamaized.net",
292
+ "fastly.net", "jsdelivr.net", "cdn.jsdelivr.net",
293
+ "unpkg.com", "cdnjs.com", "googleapis.com",
294
+ "gstatic.com", "googleusercontent.com",
295
+ "dropbox.com", "box.com", "drive.google.com",
296
+ "onedrive.live.com", "mega.nz", "mediafire.com",
297
+ "zippyshare.com", "wetransfer.com",
298
+ "s123-cdn-static.com", "s123-cdn-static-c.com",
299
+ "shopify.com", "myshopify.com",
300
+ # ── Known spam / parked domains ──
301
+ # None hardcoded; the heuristics below catch .top, .xyz, etc.
302
+ }
303
+
304
+ # TLDs overwhelmingly used for spam/parked domains — skip all
305
+ _skip_tlds = {".top", ".xyz", ".tk", ".ml", ".ga", ".cf", ".gq"}
306
+
307
+ def _should_skip_domain(domain: str) -> bool:
308
+ """Check if a domain should be skipped based on blocklist + TLD."""
309
+ if domain in _skip_domains:
310
+ return True
311
+ # Parent domain check
312
+ parts = domain.split(".")
313
+ for i in range(1, len(parts) - 1):
314
+ if ".".join(parts[i:]) in _skip_domains:
315
+ return True
316
+ # AWS DNS / infrastructure noise (ns-N.awsdns-M.*)
317
+ if any("awsdns" in part for part in parts):
318
+ return True
319
+ # Spam TLD check
320
+ if any(domain.endswith(tld) for tld in _skip_tlds):
321
+ return True
322
+ return False
323
+
324
+ for f in findings:
325
+ title = getattr(f, "title", "") or ""
326
+ desc = getattr(f, "description", "") or ""
327
+ text = f"{title} {desc}"
328
+ source_url = getattr(f, "source_url", "") or ""
329
+
330
+ # Extract domains
331
+ for match in _domain_re.finditer(text):
332
+ domain = match.group(0).lower().rstrip(".")
333
+ # Normalize: strip 'www.' prefix
334
+ if domain.startswith("www."):
335
+ domain = domain[4:]
336
+ if _should_skip_domain(domain):
337
+ continue
338
+ entity = self.add_or_get(
339
+ EntityType.DOMAIN, domain,
340
+ source=source_transform,
341
+ confidence=0.75,
342
+ )
343
+ if entity.source == source_transform:
344
+ added += 1
345
+
346
+ # Extract IPs
347
+ for match in _ip_re.finditer(text):
348
+ ip = match.group(0)
349
+ entity = self.add_or_get(
350
+ EntityType.IP_ADDRESS, ip,
351
+ source=source_transform,
352
+ confidence=0.85,
353
+ )
354
+ if entity.source == source_transform:
355
+ added += 1
356
+
357
+ # Extract emails
358
+ for match in _email_re.finditer(text):
359
+ email = match.group(0).lower()
360
+ if any(skip in email for skip in _skip_domains):
361
+ continue
362
+ entity = self.add_or_get(
363
+ EntityType.EMAIL, email,
364
+ source=source_transform,
365
+ confidence=0.70,
366
+ )
367
+ if entity.source == source_transform:
368
+ added += 1
369
+
370
+ logger.info(
371
+ "graph_ingest: %d entities from %d findings → graph now has %d entities",
372
+ added, len(findings), self.entity_count,
373
+ )
374
+ return added
375
+
376
+ # ── Export ────────────────────────────────────────────────────
377
+
378
+ def to_findings(self, source_transform: str = "graph_enrichment") -> list:
379
+ """Convert graph entities and relationships into Finding objects.
380
+
381
+ Returns a list compatible with the existing pipeline's Finding model.
382
+ Each entity becomes a finding, and significant relationships become
383
+ findings as well.
384
+ """
385
+ findings = []
386
+
387
+ for entity in self._entities.values():
388
+ # Skip entities that came from the original pipeline (already findings)
389
+ if entity.source and entity.source not in (
390
+ "dns_resolution", "ip_geolocation", "email_extraction",
391
+ "subdomain_enum", "graph_enrichment", "transform_chain",
392
+ ):
393
+ continue
394
+
395
+ # Create a finding for each graph-discovered entity
396
+ f = self._entity_to_finding(entity, source_transform)
397
+ if f:
398
+ findings.append(f)
399
+
400
+ # Add relationship findings for significant connections
401
+ for rel in self._relationships.values():
402
+ if rel.source_transform and rel.source_transform not in (
403
+ "surface_ingest", "initial",
404
+ ):
405
+ f = self._rel_to_finding(rel)
406
+ if f:
407
+ findings.append(f)
408
+
409
+ return findings
410
+
411
+ def _entity_to_finding(self, entity: Entity, source_transform: str):
412
+ """Convert a single entity to a Finding."""
413
+ from uuid import uuid4
414
+
415
+ type_labels = {
416
+ EntityType.DOMAIN: "🌐",
417
+ EntityType.IP_ADDRESS: "📍",
418
+ EntityType.EMAIL: "✉️",
419
+ EntityType.PERSON: "👤",
420
+ EntityType.ORGANIZATION: "🏢",
421
+ EntityType.WEBSITE: "🔗",
422
+ EntityType.LOCATION: "🗺️",
423
+ EntityType.DOCUMENT: "📄",
424
+ }
425
+ icon = type_labels.get(entity.entity_type, "🔍")
426
+
427
+ return {
428
+ "id": f"graph-{uuid4().hex[:12]}",
429
+ "source": "graph_enrichment",
430
+ "tool": source_transform,
431
+ "title": f"{icon} {entity.display_name}",
432
+ "description": (
433
+ f"Discovered {entity.entity_type.value}: **{entity.value}**\n"
434
+ f"Confidence: {entity.confidence:.0%} | Source: {entity.source or 'graph transform'}"
435
+ ),
436
+ "evidence": entity.properties.get("evidence", []),
437
+ "severity": "info",
438
+ "confidence": entity.confidence,
439
+ "timestamp": entity.discovered_at.isoformat(),
440
+ "metadata": entity.to_dict(),
441
+ }
442
+
443
+ def _rel_to_finding(self, rel: Relationship):
444
+ """Convert a significant relationship to a Finding."""
445
+ from uuid import uuid4
446
+
447
+ source = self._entities.get(rel.source_id)
448
+ target = self._entities.get(rel.target_id)
449
+ if not source or not target:
450
+ return None
451
+
452
+ return {
453
+ "id": f"graph-rel-{uuid4().hex[:12]}",
454
+ "source": "graph_enrichment",
455
+ "tool": rel.source_transform or "graph_relationship",
456
+ "title": f"🔗 {source.display_name} → {rel.rel_type.value} → {target.display_name}",
457
+ "description": (
458
+ f"Connection discovered: **{source.value}** "
459
+ f"({source.entity_type.value}) is linked to "
460
+ f"**{target.value}** ({target.entity_type.value}) "
461
+ f"via **{rel.rel_type.value}**.\n"
462
+ f"Confidence: {rel.confidence:.0%}"
463
+ ),
464
+ "evidence": rel.evidence,
465
+ "severity": "info",
466
+ "confidence": rel.confidence,
467
+ "timestamp": rel.created_at.isoformat(),
468
+ "metadata": rel.to_dict(),
469
+ }
470
+
471
+ # ── Stats ─────────────────────────────────────────────────────
472
+
473
+ def stats(self) -> dict:
474
+ """Return summary statistics."""
475
+ return {
476
+ "entity_count": self.entity_count,
477
+ "relationship_count": self.relationship_count,
478
+ "by_type": {
479
+ et.value: len(ids)
480
+ for et, ids in self._by_type.items()
481
+ if ids
482
+ },
483
+ }
484
+
485
+ def __repr__(self) -> str:
486
+ return (
487
+ f"<EntityGraph {self.entity_count} entities, "
488
+ f"{self.relationship_count} relationships>"
489
+ )