add web UI: HTTP server, templates, dark theme CSS, D3.js graph visualization

This commit is contained in:
authentik Default Admin 2026-02-21 01:30:00 +00:00
parent 2456047f8c
commit 0ee2f7da9a
11 changed files with 1376 additions and 0 deletions

View file

287
eirescope/web/app.py Normal file
View file

@ -0,0 +1,287 @@
"""EireScope Web Server — Lightweight HTTP server with Jinja2 templates."""
import os
import sys
import json
import logging
import mimetypes
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
from jinja2 import Environment, FileSystemLoader
# Add project root to path
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from eirescope.core.engine import InvestigationEngine
from eirescope.core.results import summarize_investigation
from eirescope.db.database import Database
logger = logging.getLogger("eirescope.web")
# Paths
WEB_DIR = os.path.dirname(os.path.abspath(__file__))
TEMPLATE_DIR = os.path.join(WEB_DIR, "templates")
STATIC_DIR = os.path.join(WEB_DIR, "static")
DATA_DIR = os.path.join(os.path.dirname(WEB_DIR), "data")
# Initialize Jinja2
jinja_env = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
autoescape=True,
)
# Initialize core components — always use /tmp for SQLite (avoids filesystem restrictions)
import tempfile
_db_path = os.path.join(tempfile.gettempdir(), "eirescope_investigations.db")
db = Database(_db_path)
engine = InvestigationEngine()
class EireScopeHandler(BaseHTTPRequestHandler):
"""HTTP Request handler for EireScope web interface."""
def log_message(self, format, *args):
logger.info(f"{self.client_address[0]} - {format % args}")
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
query = urllib.parse.parse_qs(parsed.query)
# Static files
if path.startswith("/static/"):
self._serve_static(path[8:])
return
# Routes
if path == "/" or path == "":
self._handle_index()
elif path.startswith("/investigation/"):
inv_id = path.split("/investigation/")[1].strip("/")
self._handle_investigation(inv_id)
elif path == "/history":
self._handle_history()
elif path == "/api/modules":
self._json_response(engine.get_available_modules())
elif path.startswith("/api/investigation/"):
inv_id = path.split("/api/investigation/")[1].strip("/")
self._handle_api_investigation(inv_id)
elif path == "/api/history":
self._json_response(db.list_investigations())
elif path.startswith("/export/"):
inv_id = path.split("/export/")[1].strip("/")
self._handle_export(inv_id)
else:
self._send_error(404, "Page not found")
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == "/api/search":
self._handle_search()
elif path == "/search":
self._handle_search_form()
else:
self._send_error(404, "Endpoint not found")
def _handle_index(self):
"""Render the search landing page."""
modules = engine.get_available_modules()
recent = db.list_investigations(limit=10)
template = jinja_env.get_template("index.html")
html = template.render(
modules=modules,
recent_investigations=recent,
supported_types=engine.get_supported_types(),
)
self._html_response(html)
def _handle_search_form(self):
"""Handle form-based search submission."""
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
params = urllib.parse.parse_qs(body)
query = params.get("query", [""])[0].strip()
entity_type = params.get("entity_type", [None])[0]
if entity_type == "auto":
entity_type = None
if not query:
self._redirect("/")
return
try:
investigation = engine.investigate(query, entity_type)
db.save_investigation(investigation)
self._redirect(f"/investigation/{investigation.id}")
except Exception as e:
logger.error(f"Search failed: {e}")
template = jinja_env.get_template("index.html")
html = template.render(
error=str(e),
modules=engine.get_available_modules(),
recent_investigations=db.list_investigations(limit=10),
supported_types=engine.get_supported_types(),
query=query,
)
self._html_response(html)
def _handle_search(self):
"""Handle API search (JSON)."""
content_length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(content_length).decode("utf-8"))
query = body.get("query", "").strip()
entity_type = body.get("entity_type")
module_filter = body.get("modules")
if not query:
self._json_response({"error": "Query is required"}, status=400)
return
try:
investigation = engine.investigate(query, entity_type, module_filter)
db.save_investigation(investigation)
summary = summarize_investigation(investigation)
self._json_response(summary)
except Exception as e:
self._json_response({"error": str(e)}, status=400)
def _handle_investigation(self, inv_id: str):
"""Render investigation results page."""
investigation = db.load_investigation(inv_id)
if not investigation:
self._send_error(404, "Investigation not found")
return
summary = summarize_investigation(investigation)
template = jinja_env.get_template("investigation.html")
html = template.render(inv=summary, json_data=json.dumps(summary))
self._html_response(html)
def _handle_api_investigation(self, inv_id: str):
"""Return investigation data as JSON."""
investigation = db.load_investigation(inv_id)
if not investigation:
self._json_response({"error": "Not found"}, status=404)
return
self._json_response(summarize_investigation(investigation))
def _handle_history(self):
"""Render investigation history page."""
investigations = db.list_investigations(limit=50)
template = jinja_env.get_template("history.html")
html = template.render(investigations=investigations)
self._html_response(html)
def _handle_export(self, inv_id: str):
"""Export investigation as HTML report."""
investigation = db.load_investigation(inv_id)
if not investigation:
self._send_error(404, "Investigation not found")
return
summary = summarize_investigation(investigation)
report_template_path = os.path.join(
os.path.dirname(WEB_DIR), "reporting", "templates", "report.html"
)
if os.path.exists(report_template_path):
report_env = Environment(
loader=FileSystemLoader(os.path.dirname(report_template_path)),
autoescape=True,
)
template = report_env.get_template("report.html")
else:
template = jinja_env.get_template("report.html")
html = template.render(inv=summary, json_data=json.dumps(summary))
self._html_response(html, headers={
"Content-Disposition": f'attachment; filename="eirescope-report-{inv_id[:8]}.html"'
})
def _serve_static(self, filepath: str):
"""Serve static files (CSS, JS, images)."""
full_path = os.path.join(STATIC_DIR, filepath)
if not os.path.isfile(full_path):
self._send_error(404, "File not found")
return
mime_type, _ = mimetypes.guess_type(full_path)
mime_type = mime_type or "application/octet-stream"
with open(full_path, "rb") as f:
content = f.read()
self.send_response(200)
self.send_header("Content-Type", mime_type)
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "public, max-age=3600")
self.end_headers()
self.wfile.write(content)
def _html_response(self, html: str, status: int = 200, headers: dict = None):
content = html.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
if headers:
for k, v in headers.items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(content)
def _json_response(self, data, status: int = 200):
content = json.dumps(data, indent=2, default=str).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
def _redirect(self, location: str):
self.send_response(302)
self.send_header("Location", location)
self.end_headers()
def _send_error(self, code: int, message: str):
try:
template = jinja_env.get_template("error.html")
html = template.render(code=code, message=message)
self._html_response(html, status=code)
except Exception:
self._html_response(f"<h1>{code}</h1><p>{message}</p>", status=code)
def create_server(host: str = "0.0.0.0", port: int = 5000) -> HTTPServer:
"""Create and return the EireScope HTTP server."""
server = HTTPServer((host, port), EireScopeHandler)
logger.info(f"EireScope server ready at http://{host}:{port}")
return server
def run_server(host: str = "0.0.0.0", port: int = 5000):
"""Start the EireScope web server."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
server = create_server(host, port)
print(f"""
🔍 EireScope OSINT v0.1.0
Open-Source Intelligence Dashboard
Server running at:
http://localhost:{port}
Press Ctrl+C to stop
""")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down EireScope...")
server.shutdown()

View file

@ -0,0 +1,340 @@
/* EireScope — OSINT Dashboard Stylesheet */
/* Dark theme inspired by intelligence/law enforcement interfaces */
:root {
--bg-primary: #0a0e17;
--bg-secondary: #111827;
--bg-card: #1a2332;
--bg-input: #0d1421;
--border: #1e2d3d;
--border-focus: #3b82f6;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-glow: rgba(59, 130, 246, 0.3);
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #06b6d4;
/* Entity type colors */
--color-email: #8b5cf6;
--color-username: #3b82f6;
--color-phone: #10b981;
--color-ip_address: #f59e0b;
--color-domain: #06b6d4;
--color-social_profile: #ec4899;
--color-breach: #ef4444;
--color-whois_info: #a78bfa;
--color-geo_location: #34d399;
--color-carrier_info: #fbbf24;
--color-dns_record: #67e8f9;
--color-company: #f472b6;
--color-person: #c084fc;
--color-url: #38bdf8;
--color-hash: #fb923c;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.mono { font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace; font-size: 0.9em; }
.accent { color: var(--accent); }
/* Navbar */
.navbar {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 30px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 100;
}
.nav-brand a {
display: flex; align-items: center; gap: 8px;
color: var(--text-primary); text-decoration: none; font-weight: 700; font-size: 18px;
}
.brand-icon { font-size: 22px; }
.brand-tag {
font-size: 10px; background: var(--accent); color: white;
padding: 1px 6px; border-radius: 4px; text-transform: uppercase;
letter-spacing: 1px; font-weight: 600;
}
.nav-links { display: flex; gap: 20px; }
.nav-link {
color: var(--text-secondary); font-size: 14px; font-weight: 500;
padding: 6px 12px; border-radius: 6px; transition: all 0.2s;
}
.nav-link:hover { color: var(--text-primary); background: var(--bg-card); text-decoration: none; }
/* Container */
.container { max-width: 1300px; margin: 0 auto; padding: 30px; }
/* Hero */
.hero { text-align: center; padding: 40px 0 20px; }
.hero h1 { font-size: 42px; font-weight: 800; letter-spacing: -1px; }
.hero-sub { color: var(--text-secondary); font-size: 16px; margin-top: 6px; }
/* Search */
.search-container { max-width: 800px; margin: 25px auto 40px; }
.search-form { width: 100%; }
.search-input-group {
display: flex; gap: 8px;
background: var(--bg-card); padding: 8px;
border-radius: 12px; border: 1px solid var(--border);
transition: border-color 0.3s;
}
.search-input-group:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.search-input {
flex: 1; padding: 12px 16px;
background: var(--bg-input); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 8px;
font-size: 15px; outline: none;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.search-input::placeholder { color: var(--text-muted); }
.search-input:focus { border-color: var(--accent); }
.type-select {
padding: 10px 14px;
background: var(--bg-input); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 8px;
font-size: 13px; cursor: pointer; outline: none;
}
.type-select:focus { border-color: var(--accent); }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 10px 20px; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
border: 1px solid transparent; transition: all 0.2s;
text-decoration: none;
}
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); text-decoration: none; }
.btn-secondary { background: var(--bg-card); color: var(--text-primary); border-color: var(--border); }
.btn-secondary:hover { border-color: var(--accent); text-decoration: none; }
.btn-sm { padding: 5px 12px; font-size: 12px; border-radius: 6px; }
.btn-ghost { background: transparent; color: var(--accent); border: none; padding: 4px 8px; }
.search-btn { padding: 12px 28px; font-size: 15px; white-space: nowrap; }
/* Info Cards */
.info-cards {
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px; margin: 30px 0;
}
.card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 24px; text-align: center;
transition: border-color 0.2s, transform 0.2s;
}
.card:hover { border-color: var(--accent); transform: translateY(-2px); }
.card-icon { font-size: 32px; margin-bottom: 10px; }
.card h3 { font-size: 16px; margin-bottom: 6px; }
.card p { color: var(--text-secondary); font-size: 13px; }
/* Stats Grid */
.stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px; margin: 20px 0;
}
.stat-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 20px; text-align: center;
}
.stat-value { font-size: 36px; font-weight: 800; color: var(--accent); }
.stat-label { color: var(--text-secondary); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
/* Tables */
.data-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
.data-table th {
background: var(--bg-card); color: var(--text-secondary);
padding: 10px 14px; text-align: left; font-size: 12px;
text-transform: uppercase; letter-spacing: 0.5px;
border-bottom: 2px solid var(--border);
}
.data-table td {
padding: 10px 14px; border-bottom: 1px solid var(--border);
font-size: 13px; vertical-align: middle;
}
.data-table tr:hover { background: rgba(59, 130, 246, 0.05); }
.entity-value { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Badges */
.badge {
display: inline-block; padding: 2px 10px; border-radius: 6px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-email { background: rgba(139,92,246,0.15); color: var(--color-email); }
.badge-username { background: rgba(59,130,246,0.15); color: var(--color-username); }
.badge-phone { background: rgba(16,185,129,0.15); color: var(--color-phone); }
.badge-ip_address { background: rgba(245,158,11,0.15); color: var(--color-ip_address); }
.badge-domain { background: rgba(6,182,212,0.15); color: var(--color-domain); }
.badge-social_profile { background: rgba(236,72,153,0.15); color: var(--color-social_profile); }
.badge-breach { background: rgba(239,68,68,0.15); color: var(--color-breach); }
.badge-whois_info { background: rgba(167,139,250,0.15); color: var(--color-whois_info); }
.badge-geo_location { background: rgba(52,211,153,0.15); color: var(--color-geo_location); }
.badge-carrier_info { background: rgba(251,191,36,0.15); color: var(--color-carrier_info); }
.badge-dns_record { background: rgba(103,232,249,0.15); color: var(--color-dns_record); }
.badge-url { background: rgba(56,189,248,0.15); color: var(--color-url); }
.badge-hash { background: rgba(251,146,60,0.15); color: var(--color-hash); }
.badge-warning { background: rgba(245,158,11,0.15); color: var(--warning); }
/* Status */
.status { font-size: 12px; font-weight: 600; }
.status-completed { color: var(--success); }
.status-running { color: var(--warning); }
.status-failed { color: var(--danger); }
.status-pending { color: var(--text-muted); }
/* Confidence bar */
.confidence-bar {
display: flex; align-items: center; gap: 8px;
background: var(--bg-input); border-radius: 4px; height: 20px;
position: relative; min-width: 80px; overflow: hidden;
}
.confidence-fill {
height: 100%; background: var(--accent); border-radius: 4px;
transition: width 0.3s;
}
.confidence-bar span {
position: absolute; right: 6px; font-size: 11px; font-weight: 600; color: var(--text-primary);
}
/* Tabs */
.tabs { display: flex; gap: 4px; margin: 25px 0 0; border-bottom: 2px solid var(--border); }
.tab {
padding: 10px 20px; background: none; border: none;
color: var(--text-secondary); font-size: 14px; font-weight: 500;
cursor: pointer; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: all 0.2s;
}
.tab:hover { color: var(--text-primary); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-content { display: none; padding: 20px 0; }
.tab-content.active { display: block; }
/* Graph */
.graph-container {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; height: 500px; position: relative; overflow: hidden;
}
.graph-legend {
display: flex; flex-wrap: wrap; gap: 14px;
padding: 12px 0; justify-content: center;
}
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-secondary); }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.dot-email { background: var(--color-email); }
.dot-username { background: var(--color-username); }
.dot-domain { background: var(--color-domain); }
.dot-ip_address { background: var(--color-ip_address); }
.dot-social_profile { background: var(--color-social_profile); }
.dot-phone { background: var(--color-phone); }
.dot-breach { background: var(--color-breach); }
.dot-whois_info { background: var(--color-whois_info); }
.dot-geo_location { background: var(--color-geo_location); }
/* Filters */
.entity-filters { display: flex; gap: 10px; margin-bottom: 15px; }
.filter-input {
flex: 1; padding: 8px 14px;
background: var(--bg-input); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 8px;
font-size: 13px; outline: none;
}
.filter-input:focus { border-color: var(--accent); }
/* Entity details */
.entity-details { margin-top: 8px; }
.entity-details pre {
background: var(--bg-input); padding: 10px;
border-radius: 6px; font-size: 11px;
color: var(--text-secondary); overflow-x: auto;
max-height: 200px; overflow-y: auto;
white-space: pre-wrap; word-break: break-all;
}
.hidden { display: none; }
/* Distribution bars */
.type-distribution { margin: 10px 0 20px; }
.dist-row { display: flex; align-items: center; gap: 12px; margin: 6px 0; }
.dist-bar-container { flex: 1; background: var(--bg-input); height: 20px; border-radius: 4px; overflow: hidden; }
.dist-bar { height: 100%; background: var(--accent); border-radius: 4px; min-width: 4px; transition: width 0.5s; }
.dist-bar-rel { background: var(--info); }
.dist-count { font-size: 13px; font-weight: 600; min-width: 30px; text-align: right; }
.rel-type { font-size: 12px; color: var(--text-secondary); min-width: 200px; font-family: monospace; }
/* Module list */
.module-list { list-style: none; }
.module-item { padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 14px; }
.module-failed { color: var(--danger); }
.module-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
.module-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 18px;
}
.module-card h4 { margin-bottom: 6px; }
.module-card p { color: var(--text-secondary); font-size: 13px; margin-bottom: 10px; }
.module-types { display: flex; flex-wrap: wrap; gap: 4px; }
/* Investigation header */
.investigation-header {
display: flex; justify-content: space-between; align-items: flex-start;
flex-wrap: wrap; gap: 15px; margin-bottom: 20px;
}
.inv-info h1 { font-size: 24px; }
.inv-meta { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 8px; }
.meta-item { color: var(--text-muted); font-size: 12px; }
.inv-actions { display: flex; gap: 8px; }
/* Alerts */
.alert {
padding: 14px 18px; border-radius: 10px; margin: 15px 0;
font-size: 14px; border: 1px solid;
}
.alert-error { background: rgba(239,68,68,0.1); border-color: var(--danger); color: var(--danger); }
/* Section */
.section { margin: 35px 0; }
.section h2 { font-size: 20px; margin-bottom: 15px; }
/* Error page */
.error-page { text-align: center; padding: 80px 0; }
.error-code { font-size: 72px; font-weight: 800; color: var(--accent); }
.error-message { color: var(--text-secondary); font-size: 18px; margin: 10px 0 30px; }
/* Empty state */
.empty-state { text-align: center; padding: 60px 0; color: var(--text-secondary); }
/* Footer */
.footer {
text-align: center; padding: 20px 30px; margin-top: 40px;
border-top: 1px solid var(--border); color: var(--text-muted); font-size: 12px;
}
.footer-note { margin-top: 4px; font-size: 11px; }
/* Responsive */
@media (max-width: 768px) {
.container { padding: 15px; }
.search-input-group { flex-direction: column; }
.hero h1 { font-size: 28px; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.investigation-header { flex-direction: column; }
.tabs { overflow-x: auto; }
.graph-container { height: 350px; }
}

View file

@ -0,0 +1,42 @@
/* EireScope — Main application JavaScript */
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const target = document.getElementById('tab-' + tab.dataset.tab);
if (target) target.classList.add('active');
});
});
// Toggle entity details
function toggleDetails(btn) {
const details = btn.nextElementSibling;
if (details) {
details.classList.toggle('hidden');
btn.textContent = details.classList.contains('hidden') ? 'Show' : 'Hide';
}
}
// Entity filtering
const searchInput = document.getElementById('entity-search');
const typeFilter = document.getElementById('entity-type-filter');
function filterEntities() {
const search = (searchInput ? searchInput.value : '').toLowerCase();
const type = typeFilter ? typeFilter.value : 'all';
const rows = document.querySelectorAll('#entities-table tbody tr');
rows.forEach(row => {
const rowType = row.dataset.type || '';
const rowValue = row.dataset.value || '';
const matchesType = type === 'all' || rowType === type;
const matchesSearch = !search || rowValue.includes(search) || rowType.includes(search);
row.style.display = matchesType && matchesSearch ? '' : 'none';
});
}
if (searchInput) searchInput.addEventListener('input', filterEntities);
if (typeFilter) typeFilter.addEventListener('change', filterEntities);

View file

@ -0,0 +1,197 @@
/* EireScope — D3.js Entity Relationship Graph Visualization */
(function() {
if (typeof investigationData === 'undefined' || !investigationData.graph) return;
const graph = investigationData.graph;
if (!graph.nodes.length) return;
const container = document.getElementById('entity-graph');
if (!container) return;
const width = container.clientWidth;
const height = container.clientHeight || 500;
// Color mapping for entity types
const typeColors = {
email: '#8b5cf6',
username: '#3b82f6',
phone: '#10b981',
ip_address: '#f59e0b',
domain: '#06b6d4',
social_profile: '#ec4899',
breach: '#ef4444',
whois_info: '#a78bfa',
geo_location: '#34d399',
carrier_info: '#fbbf24',
dns_record: '#67e8f9',
company: '#f472b6',
person: '#c084fc',
url: '#38bdf8',
hash: '#fb923c'
};
// Node size by type importance
const typeSizes = {
email: 14, username: 14, phone: 14, ip_address: 12,
domain: 12, social_profile: 8, breach: 10, whois_info: 8,
geo_location: 9, carrier_info: 8, dns_record: 7, url: 7
};
const svg = d3.select(container)
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height]);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', '#1a2332');
// Zoom behavior
const g = svg.append('g');
const zoom = d3.zoom()
.scaleExtent([0.3, 5])
.on('zoom', (event) => g.attr('transform', event.transform));
svg.call(zoom);
// Build simulation
const simulation = d3.forceSimulation(graph.nodes)
.force('link', d3.forceLink(graph.links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(d => (typeSizes[d.type] || 8) + 5));
// Links
const link = g.append('g')
.selectAll('line')
.data(graph.links)
.join('line')
.attr('stroke', '#2d3748')
.attr('stroke-width', d => Math.max(1, d.confidence * 2))
.attr('stroke-opacity', 0.6);
// Link labels
const linkLabel = g.append('g')
.selectAll('text')
.data(graph.links)
.join('text')
.text(d => d.type.replace(/_/g, ' '))
.attr('font-size', '8px')
.attr('fill', '#4a5568')
.attr('text-anchor', 'middle')
.attr('dy', -4);
// Nodes
const node = g.append('g')
.selectAll('g')
.data(graph.nodes)
.join('g')
.call(d3.drag()
.on('start', dragStarted)
.on('drag', dragged)
.on('end', dragEnded));
// Node circles
node.append('circle')
.attr('r', d => typeSizes[d.type] || 8)
.attr('fill', d => typeColors[d.type] || '#666')
.attr('stroke', '#0a0e17')
.attr('stroke-width', 2)
.attr('opacity', 0.9);
// Glow effect for seed entity (first node)
node.filter((d, i) => i === 0)
.select('circle')
.attr('stroke', '#fff')
.attr('stroke-width', 3)
.attr('r', 18);
// Node labels
node.append('text')
.text(d => d.label.length > 25 ? d.label.substring(0, 25) + '...' : d.label)
.attr('x', d => (typeSizes[d.type] || 8) + 6)
.attr('y', 4)
.attr('font-size', '11px')
.attr('fill', '#cbd5e1')
.attr('font-family', "'JetBrains Mono', monospace");
// Tooltip
const tooltip = d3.select(container)
.append('div')
.style('position', 'absolute')
.style('background', '#1e293b')
.style('border', '1px solid #334155')
.style('border-radius', '8px')
.style('padding', '10px 14px')
.style('font-size', '12px')
.style('color', '#e2e8f0')
.style('pointer-events', 'none')
.style('opacity', 0)
.style('z-index', 10)
.style('max-width', '300px');
node.on('mouseover', function(event, d) {
tooltip.transition().duration(200).style('opacity', 1);
tooltip.html(`
<div style="font-weight:700;margin-bottom:4px;color:${typeColors[d.type] || '#fff'}">${d.type.toUpperCase()}</div>
<div style="font-family:monospace;word-break:break-all">${d.label}</div>
<div style="margin-top:4px;color:#94a3b8">Source: ${d.source}</div>
<div style="color:#94a3b8">Confidence: ${Math.round(d.confidence * 100)}%</div>
`)
.style('left', (event.offsetX + 15) + 'px')
.style('top', (event.offsetY - 10) + 'px');
d3.select(this).select('circle')
.transition().duration(200)
.attr('r', (typeSizes[d.type] || 8) * 1.4);
})
.on('mouseout', function(event, d) {
tooltip.transition().duration(200).style('opacity', 0);
const isFirst = graph.nodes.indexOf(d) === 0;
d3.select(this).select('circle')
.transition().duration(200)
.attr('r', isFirst ? 18 : (typeSizes[d.type] || 8));
});
// Simulation tick
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
linkLabel
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
// Drag functions
function dragStarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x; d.fy = event.y;
}
function dragEnded(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null; d.fy = null;
}
// Fit to view on load
setTimeout(() => {
const bounds = g.node().getBBox();
const dx = bounds.width, dy = bounds.height;
const x = bounds.x + dx / 2, y = bounds.y + dy / 2;
const scale = 0.85 / Math.max(dx / width, dy / height);
const translate = [width / 2 - scale * x, height / 2 - scale * y];
svg.transition().duration(750)
.call(zoom.transform, d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale));
}, 2000);
})();

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}EireScope{% endblock %} — OSINT Dashboard</title>
<link rel="stylesheet" href="/static/css/style.css">
{% block head %}{% endblock %}
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<a href="/">
<span class="brand-icon">🔍</span>
<span class="brand-text">EireScope</span>
<span class="brand-tag">OSINT</span>
</a>
</div>
<div class="nav-links">
<a href="/" class="nav-link">Search</a>
<a href="/history" class="nav-link">History</a>
<a href="/api/modules" class="nav-link">Modules</a>
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<p>EireScope v0.1.0 — Open-Source Intelligence Dashboard</p>
<p class="footer-note">All data from publicly available sources.</p>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Error {{ code }}{% endblock %}
{% block content %}
<div class="error-page">
<h1 class="error-code">{{ code }}</h1>
<p class="error-message">{{ message }}</p>
<a href="/" class="btn btn-primary">Back to Search</a>
</div>
{% endblock %}

View file

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Investigation History{% endblock %}
{% block content %}
<h1>Investigation History</h1>
{% if investigations %}
<table class="data-table">
<thead>
<tr>
<th>Query</th>
<th>Type</th>
<th>Entities</th>
<th>Status</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{% for inv in investigations %}
<tr>
<td class="mono">{{ inv.initial_query }}</td>
<td><span class="badge badge-{{ inv.initial_type }}">{{ inv.initial_type }}</span></td>
<td>{{ inv.entity_count }}</td>
<td><span class="status status-{{ inv.status }}">{{ inv.status }}</span></td>
<td>{{ inv.created_at[:16] }}</td>
<td>
<a href="/investigation/{{ inv.id }}" class="btn btn-sm">View</a>
<a href="/export/{{ inv.id }}" class="btn btn-sm btn-secondary">Export</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>No investigations yet. <a href="/">Start your first investigation</a>.</p>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}Search{% endblock %}
{% block content %}
<div class="hero">
<h1>EireScope <span class="accent">OSINT</span></h1>
<p class="hero-sub">Open-Source Intelligence Investigation Dashboard</p>
</div>
{% if error %}
<div class="alert alert-error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<div class="search-container">
<form action="/search" method="POST" class="search-form">
<div class="search-input-group">
<input
type="text"
name="query"
class="search-input"
placeholder="Enter email, username, phone, IP address, or domain..."
value="{{ query or '' }}"
autofocus
required
>
<select name="entity_type" class="type-select">
<option value="auto">Auto-detect</option>
<option value="email">Email</option>
<option value="username">Username</option>
<option value="phone">Phone Number</option>
<option value="ip_address">IP Address</option>
<option value="domain">Domain</option>
</select>
<button type="submit" class="btn btn-primary search-btn">
Investigate
</button>
</div>
</form>
</div>
<div class="info-cards">
<div class="card">
<div class="card-icon">👤</div>
<h3>Username Search</h3>
<p>Scan 40+ platforms for matching profiles</p>
</div>
<div class="card">
<div class="card-icon">📧</div>
<h3>Email Enrichment</h3>
<p>Breach checks, domain analysis, provider detection</p>
</div>
<div class="card">
<div class="card-icon">📱</div>
<h3>Phone Analysis</h3>
<p>Carrier detection, Irish number classification</p>
</div>
<div class="card">
<div class="card-icon">🌐</div>
<h3>IP/Domain Recon</h3>
<p>WHOIS, GeoIP, DNS records, subdomain enumeration</p>
</div>
</div>
{% if recent_investigations %}
<div class="section">
<h2>Recent Investigations</h2>
<table class="data-table">
<thead>
<tr>
<th>Query</th>
<th>Type</th>
<th>Entities</th>
<th>Status</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{% for inv in recent_investigations %}
<tr>
<td class="mono">{{ inv.initial_query }}</td>
<td><span class="badge badge-{{ inv.initial_type }}">{{ inv.initial_type }}</span></td>
<td>{{ inv.entity_count }}</td>
<td><span class="status status-{{ inv.status }}">{{ inv.status }}</span></td>
<td>{{ inv.created_at[:16] }}</td>
<td><a href="/investigation/{{ inv.id }}" class="btn btn-sm">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="section modules-section">
<h2>Available Modules</h2>
<div class="module-grid">
{% for module in modules %}
<div class="module-card">
<h4>{{ module.name }}</h4>
<p>{{ module.description }}</p>
<div class="module-types">
{% for t in module.supported_types %}
<span class="badge badge-{{ t }}">{{ t }}</span>
{% endfor %}
</div>
{% if module.requires_api_key %}
<span class="badge badge-warning">API Key Required</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}Investigation: {{ inv.query }}{% endblock %}
{% block head %}
<script src="https://d3js.org/d3.v7.min.js"></script>
{% endblock %}
{% block content %}
<div class="investigation-header">
<div class="inv-info">
<h1>Investigation: <span class="mono accent">{{ inv.query }}</span></h1>
<div class="inv-meta">
<span class="badge badge-{{ inv.query_type }}">{{ inv.query_type }}</span>
<span class="status status-{{ inv.status }}">{{ inv.status }}</span>
<span class="meta-item">Started: {{ inv.created_at[:19] }}</span>
{% if inv.completed_at %}
<span class="meta-item">Completed: {{ inv.completed_at[:19] }}</span>
{% endif %}
</div>
</div>
<div class="inv-actions">
<a href="/export/{{ inv.id }}" class="btn btn-secondary">Export Report</a>
<a href="/" class="btn btn-primary">New Search</a>
</div>
</div>
<!-- Stats cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ inv.total_entities }}</div>
<div class="stat-label">Entities Found</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ inv.total_relationships }}</div>
<div class="stat-label">Relationships</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ inv.modules_run|length }}</div>
<div class="stat-label">Modules Run</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ inv.entity_counts|length }}</div>
<div class="stat-label">Entity Types</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="graph">Relationship Graph</button>
<button class="tab" data-tab="entities">Entities ({{ inv.total_entities }})</button>
<button class="tab" data-tab="details">Details</button>
<button class="tab" data-tab="modules">Modules</button>
</div>
<!-- Graph Tab -->
<div class="tab-content active" id="tab-graph">
<div class="graph-container" id="entity-graph"></div>
<div class="graph-legend">
<span class="legend-item"><span class="dot dot-email"></span> Email</span>
<span class="legend-item"><span class="dot dot-username"></span> Username</span>
<span class="legend-item"><span class="dot dot-domain"></span> Domain</span>
<span class="legend-item"><span class="dot dot-ip_address"></span> IP Address</span>
<span class="legend-item"><span class="dot dot-social_profile"></span> Social Profile</span>
<span class="legend-item"><span class="dot dot-phone"></span> Phone</span>
<span class="legend-item"><span class="dot dot-breach"></span> Breach</span>
<span class="legend-item"><span class="dot dot-whois_info"></span> WHOIS</span>
<span class="legend-item"><span class="dot dot-geo_location"></span> Location</span>
</div>
</div>
<!-- Entities Tab -->
<div class="tab-content" id="tab-entities">
<div class="entity-filters">
<input type="text" id="entity-search" placeholder="Filter entities..." class="filter-input">
<select id="entity-type-filter" class="type-select">
<option value="all">All Types</option>
{% for type_name, count in inv.entity_counts.items() %}
<option value="{{ type_name }}">{{ type_name }} ({{ count }})</option>
{% endfor %}
</select>
</div>
<table class="data-table" id="entities-table">
<thead>
<tr>
<th>Type</th>
<th>Value</th>
<th>Source</th>
<th>Confidence</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for entity in inv.entities %}
<tr data-type="{{ entity.entity_type }}" data-value="{{ entity.value|lower }}">
<td><span class="badge badge-{{ entity.entity_type }}">{{ entity.entity_type }}</span></td>
<td class="mono entity-value">
{% if entity.value.startswith('http') %}
<a href="{{ entity.value }}" target="_blank" rel="noopener">{{ entity.value }}</a>
{% else %}
{{ entity.value }}
{% endif %}
</td>
<td>{{ entity.source_module }}</td>
<td>
<div class="confidence-bar">
<div class="confidence-fill" style="width: {{ (entity.confidence * 100)|int }}%"></div>
<span>{{ (entity.confidence * 100)|int }}%</span>
</div>
</td>
<td>
{% if entity.metadata %}
<button class="btn btn-sm btn-ghost" onclick="toggleDetails(this)">Show</button>
<div class="entity-details hidden">
<pre>{{ entity.metadata|tojson(indent=2) }}</pre>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Details Tab -->
<div class="tab-content" id="tab-details">
<h3>Entity Type Distribution</h3>
<div class="type-distribution">
{% for type_name, count in inv.entity_counts.items() %}
<div class="dist-row">
<span class="badge badge-{{ type_name }}">{{ type_name }}</span>
<div class="dist-bar-container">
<div class="dist-bar" style="width: {{ (count / inv.total_entities * 100)|int }}%"></div>
</div>
<span class="dist-count">{{ count }}</span>
</div>
{% endfor %}
</div>
<h3>Relationship Types</h3>
<div class="type-distribution">
{% for rel_type, count in inv.relationship_counts.items() %}
<div class="dist-row">
<span class="rel-type">{{ rel_type }}</span>
<div class="dist-bar-container">
<div class="dist-bar dist-bar-rel" style="width: {{ (count / inv.total_relationships * 100)|int }}%"></div>
</div>
<span class="dist-count">{{ count }}</span>
</div>
{% endfor %}
</div>
</div>
<!-- Modules Tab -->
<div class="tab-content" id="tab-modules">
<h3>Modules Executed</h3>
<ul class="module-list">
{% for mod in inv.modules_run %}
<li class="module-item {% if 'FAILED' in mod %}module-failed{% endif %}">
{% if 'FAILED' in mod %}❌{% else %}✅{% endif %}
{{ mod }}
</li>
{% endfor %}
</ul>
</div>
{% endblock %}
{% block scripts %}
<script>
const investigationData = {{ json_data|safe }};
</script>
<script src="/static/js/app.js"></script>
<script src="/static/js/graph.js"></script>
{% endblock %}

View file

@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>EireScope Report — {{ inv.query }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: #1a1a2e; line-height: 1.6; padding: 40px; max-width: 1000px; margin: 0 auto; }
.report-header { border-bottom: 3px solid #16213e; padding-bottom: 20px; margin-bottom: 30px; }
.report-header h1 { font-size: 28px; color: #16213e; }
.report-header .subtitle { color: #666; font-size: 14px; margin-top: 5px; }
.report-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 15px; font-size: 13px; }
.report-meta dt { font-weight: bold; color: #16213e; }
.report-meta dd { color: #444; }
h2 { color: #16213e; border-bottom: 1px solid #ddd; padding-bottom: 8px; margin: 25px 0 15px; font-size: 20px; }
h3 { color: #333; margin: 20px 0 10px; font-size: 16px; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin: 20px 0; }
.stat { background: #f5f6fa; padding: 15px; border-radius: 8px; text-align: center; }
.stat-val { font-size: 28px; font-weight: bold; color: #0f3460; }
.stat-label { font-size: 12px; color: #666; text-transform: uppercase; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; font-size: 13px; }
th { background: #16213e; color: white; padding: 10px 12px; text-align: left; }
td { padding: 8px 12px; border-bottom: 1px solid #eee; }
tr:nth-child(even) { background: #f9f9fa; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: bold; background: #e0e7ff; color: #1e3a5f; }
.confidence { display: inline-block; min-width: 40px; text-align: center; }
.mono { font-family: 'Courier New', monospace; font-size: 12px; }
.disclaimer { margin-top: 40px; padding: 15px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; font-size: 12px; }
.footer-report { margin-top: 30px; text-align: center; color: #999; font-size: 11px; border-top: 1px solid #eee; padding-top: 15px; }
@media print { body { padding: 20px; } .stats { page-break-inside: avoid; } }
</style>
</head>
<body>
<div class="report-header">
<h1>EireScope Investigation Report</h1>
<p class="subtitle">Open-Source Intelligence Analysis</p>
<dl class="report-meta">
<dt>Investigation ID</dt>
<dd class="mono">{{ inv.id[:12] }}...</dd>
<dt>Target Query</dt>
<dd class="mono">{{ inv.query }}</dd>
<dt>Entity Type</dt>
<dd>{{ inv.query_type }}</dd>
<dt>Status</dt>
<dd>{{ inv.status }}</dd>
<dt>Started</dt>
<dd>{{ inv.created_at }}</dd>
<dt>Completed</dt>
<dd>{{ inv.completed_at or 'N/A' }}</dd>
</dl>
</div>
<h2>Summary</h2>
<div class="stats">
<div class="stat">
<div class="stat-val">{{ inv.total_entities }}</div>
<div class="stat-label">Entities</div>
</div>
<div class="stat">
<div class="stat-val">{{ inv.total_relationships }}</div>
<div class="stat-label">Relationships</div>
</div>
<div class="stat">
<div class="stat-val">{{ inv.modules_run|length }}</div>
<div class="stat-label">Modules Run</div>
</div>
<div class="stat">
<div class="stat-val">{{ inv.entity_counts|length }}</div>
<div class="stat-label">Entity Types</div>
</div>
</div>
<h2>Modules Executed</h2>
<ul>
{% for mod in inv.modules_run %}
<li>{% if 'FAILED' in mod %}&#10060;{% else %}&#9989;{% endif %} {{ mod }}</li>
{% endfor %}
</ul>
<h2>Discovered Entities</h2>
<table>
<thead>
<tr>
<th>Type</th>
<th>Value</th>
<th>Source Module</th>
<th>Confidence</th>
</tr>
</thead>
<tbody>
{% for entity in inv.entities %}
<tr>
<td><span class="badge">{{ entity.entity_type }}</span></td>
<td class="mono">{{ entity.value }}</td>
<td>{{ entity.source_module }}</td>
<td class="confidence">{{ (entity.confidence * 100)|int }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2>Entity Relationships</h2>
<table>
<thead>
<tr>
<th>Source</th>
<th>Relationship</th>
<th>Target</th>
<th>Confidence</th>
</tr>
</thead>
<tbody>
{% for rel in inv.relationships %}
<tr>
<td class="mono">{{ rel.source_entity_id[:8] }}...</td>
<td>{{ rel.relationship_type }}</td>
<td class="mono">{{ rel.target_entity_id[:8] }}...</td>
<td class="confidence">{{ (rel.confidence * 100)|int }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="disclaimer">
<strong>Disclaimer:</strong> This report contains information gathered from publicly available
sources only. The accuracy of this data depends on the reliability of external sources.
All intelligence should be verified through independent means before use in any legal proceedings.
</div>
<div class="footer-report">
<p>Generated by EireScope v0.1.0 — Open-Source Intelligence Dashboard</p>
<p>Report generated at {{ inv.completed_at or inv.created_at }}</p>
</div>
</body>
</html>