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,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
|
+
)
|