Build interactive access topology map
This commit is contained in:
@@ -5,7 +5,7 @@ import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg
|
||||
from flask import Flask, abort, redirect, render_template, request, url_for
|
||||
from flask import Flask, abort, jsonify, redirect, render_template, request, url_for
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -245,8 +245,7 @@ def organization(organization_id):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/network")
|
||||
def network():
|
||||
def load_network_flows():
|
||||
with db_connection() as connection, connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
@@ -286,13 +285,52 @@ def network():
|
||||
max_count = max([entry["count"] for entry in flow_rows] or [1])
|
||||
source_count = len({entry["source_ip"] for entry in flow_rows})
|
||||
target_count = len({entry["machine_name"] for entry in flow_rows})
|
||||
return flow_rows, max_count, source_count, target_count
|
||||
|
||||
|
||||
@app.get("/network")
|
||||
def network():
|
||||
flows, _, source_count, target_count = load_network_flows()
|
||||
return render_template(
|
||||
"network.html",
|
||||
flows=flow_rows,
|
||||
max_count=max_count,
|
||||
source_count=source_count,
|
||||
target_count=target_count,
|
||||
total_events=sum(entry["count"] for entry in flow_rows),
|
||||
total_events=sum(entry["count"] for entry in flows),
|
||||
path_count=len(flows),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/network")
|
||||
def network_api():
|
||||
flows, max_count, source_count, target_count = load_network_flows()
|
||||
nodes = {}
|
||||
edges = []
|
||||
for index, flow in enumerate(flows):
|
||||
source_id = f"source:{flow['source_ip']}"
|
||||
target_id = f"target:{flow['machine_name']}"
|
||||
nodes[source_id] = {"data": {"id": source_id, "label": flow["source_ip"], "kind": "source"}}
|
||||
nodes[target_id] = {"data": {"id": target_id, "label": flow["machine_name"], "kind": "target"}}
|
||||
edges.append(
|
||||
{
|
||||
"data": {
|
||||
"id": f"flow:{index}",
|
||||
"source": source_id,
|
||||
"target": target_id,
|
||||
"count": flow["count"],
|
||||
"account": flow["account"],
|
||||
"event_type": flow["target"],
|
||||
"last_seen": str(flow["last_seen"] or "-"),
|
||||
"machine_name": flow["machine_name"],
|
||||
}
|
||||
}
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"elements": {"nodes": list(nodes.values()), "edges": edges},
|
||||
"max_count": max_count,
|
||||
"source_count": source_count,
|
||||
"target_count": target_count,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -29,3 +29,4 @@ table { width:100%; border-collapse:collapse; font-family:'Roboto',sans-serif; f
|
||||
.compact-metrics { grid-template-columns:repeat(4,1fr); }.event-summary-panel { margin-top:8px; }.event-summary-panel .panel-heading h2,.raw-export-panel .panel-heading h2 { margin:7px 0 8px; }.event-summary-panel .panel-heading p,.raw-export-panel .panel-heading p { margin:0 0 20px; }.event-count { display:inline-grid; min-width:28px; min-height:28px; place-items:center; border-radius:14px; background:#fff0d7; color:var(--amber); font:700 12px 'Roboto',sans-serif; }.raw-export-panel { margin-top:8px; }.raw-json { margin-top:18px; border-top:1px solid var(--line); }.raw-json summary { padding:14px 0; color:#245a85; cursor:pointer; font:700 12px 'Roboto',sans-serif; }.raw-json pre { margin-bottom:0; } @media (max-width:850px) { .compact-metrics { grid-template-columns:repeat(2,1fr); }.compact-metrics article:last-child { grid-column:span 2; } }
|
||||
.trend-panel { overflow:hidden; }.trend-chart { display:grid; grid-template-columns:repeat(auto-fit,minmax(48px,1fr)); align-items:end; min-height:210px; gap:10px; padding:18px 4px 0; border-bottom:1px solid var(--line); }.trend-day { display:grid; grid-template-rows:154px auto auto; gap:5px; min-width:0; text-align:center; }.trend-bar { position:relative; align-self:end; height:max(7px,var(--bar)); border-radius:5px 5px 0 0; background:#bfd9eb; transition:height .25s ease; }.trend-critical,.trend-warning { position:absolute; right:0; left:0; bottom:0; display:block; }.trend-critical { height:var(--critical); background:var(--red); }.trend-warning { bottom:var(--critical); height:var(--warning); background:var(--amber); }.trend-day strong { font-size:13px; }.trend-day small { color:var(--muted); font-size:10px; }.chart-note { margin:15px 0 0; color:var(--muted); font-size:11px; }.legend { display:inline-block; width:8px; height:8px; margin:0 4px 0 12px; border-radius:2px; }.legend:first-child { margin-left:0; }.legend.critical { background:var(--red); }.legend.warning { background:var(--amber); }.legend.neutral { background:#bfd9eb; }.organization-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(245px,1fr)); gap:12px; }.organization-card { display:grid; gap:11px; min-height:150px; padding:18px; border:1px solid #d7e3ec; border-radius:8px; background:linear-gradient(145deg,#fff,#f3f8fb); color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease; }.organization-card:hover { border-color:#8fb7d0; box-shadow:0 14px 26px rgba(24,59,89,.12); transform:translateY(-2px); }.organization-card strong { font-size:19px; letter-spacing:-.035em; }.organization-card div { display:flex; flex-wrap:wrap; align-items:center; gap:5px; color:var(--muted); font-size:12px; }.organization-card small { color:var(--muted); font-size:10px; }.ransomware-panel { border-left:5px solid #8aa3b4; }.ransomware-panel.warning { border-left-color:var(--amber); }.ransomware-panel.critical { border-left-color:var(--red); }.ransomware-panel .panel-heading p { margin:0 0 18px; color:var(--muted); }
|
||||
.network-panel { overflow:hidden; }.network-flows { display:grid; gap:8px; }.network-flow { display:grid; grid-template-columns:minmax(150px,.9fr) minmax(130px,1.25fr) minmax(210px,1.2fr); align-items:center; gap:16px; padding:13px 14px; border:1px solid #dce6ee; border-radius:7px; background:#fbfdff; color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease; }.network-flow:hover { transform:translateX(3px); box-shadow:0 8px 18px rgba(31,68,99,.1); }.flow-endpoint { display:grid; gap:3px; }.flow-endpoint span { color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.flow-endpoint strong { font-size:14px; }.flow-endpoint small { color:var(--muted); font-size:11px; }.flow-line { position:relative; display:flex; align-items:center; gap:7px; min-height:22px; }.flow-line:before { position:absolute; right:0; left:0; height:3px; background:#d7e4ed; content:''; }.flow-line i { z-index:1; width:max(5%,var(--flow)); height:7px; border-radius:6px; background:linear-gradient(90deg,#245a85,#b8e36a); }.flow-line small { z-index:1; margin-left:auto; padding:2px 5px; border-radius:8px; background:#fff; color:#456174; font:700 10px 'Roboto',sans-serif; } @media (max-width:850px) { .network-flow { grid-template-columns:1fr; gap:9px; }.flow-line { order:3; }.flow-endpoint.target { order:2; } }
|
||||
.network-map-panel { overflow:hidden; }.map-toolbar { display:flex; flex-wrap:wrap; gap:8px; margin:0 0 16px; }.map-toolbar label { flex:1 1 240px; display:grid; gap:5px; color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.map-toolbar input,.map-toolbar button { min-height:38px; padding:8px 10px; border:1px solid var(--line); border-radius:5px; background:#fff; color:var(--ink); font:700 12px 'Roboto',sans-serif; }.map-toolbar button { cursor:pointer; background:#f5f9fc; }.network-map-layout { display:grid; grid-template-columns:minmax(0,1fr) 260px; min-height:560px; overflow:hidden; border:1px solid #d7e4ed; border-radius:9px; background:radial-gradient(circle at 18% 12%,#f5fbff,transparent 28rem),#edf4f8; }.network-map-layout #network-map { min-height:560px; background-image:linear-gradient(rgba(36,90,133,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(36,90,133,.05) 1px,transparent 1px); background-size:32px 32px; }.network-map-layout aside { padding:22px; border-left:1px solid #d7e4ed; background:#fff; }.network-map-layout aside strong { display:block; margin:8px 0 12px; font-size:18px; line-height:1.15; letter-spacing:-.035em; }.network-map-layout aside p { color:var(--muted); font-size:13px; line-height:1.5; }.network-map-layout dl { display:grid; grid-template-columns:1fr; gap:4px; margin:18px 0 0; }.network-map-layout dt { color:var(--muted); font-size:10px; font-weight:700; text-transform:uppercase; }.network-map-layout dd { margin:0 0 10px; font-size:13px; overflow-wrap:anywhere; }.inspector-arrow { color:var(--green); font-size:13px; }.legend.source { background:#245a85; }.legend.target { background:#14735b; } @media (max-width:850px) { .network-map-layout { grid-template-columns:1fr; }.network-map-layout aside { border-top:1px solid #d7e4ed; border-left:0; }.network-map-layout #network-map { min-height:460px; } }
|
||||
|
||||
18
infra/debug-dashboard/static/vendor/cytoscape-LICENSE.txt
vendored
Normal file
18
infra/debug-dashboard/static/vendor/cytoscape-LICENSE.txt
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
Cytoscape.js 3.34.0
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016-2026, The Cytoscape Consortium
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
31
infra/debug-dashboard/static/vendor/cytoscape.min.js
vendored
Normal file
31
infra/debug-dashboard/static/vendor/cytoscape.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -2,6 +2,31 @@
|
||||
{% block title %}Zugriffswege - OfficeCom Sentinel{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero compact"><span class="eyebrow">Letzte 14 Tage</span><h1>Zugriffswege im Blick</h1><p>Verdichtete fehlgeschlagene Anmeldungen. Jeder Pfad verbindet Quell-IP, Zielgeraet und betroffenes Konto.</p></section>
|
||||
<section class="metrics compact-metrics"><article><span>Quell-IPs</span><strong>{{ source_count }}</strong></article><article><span>Zielgeraete</span><strong>{{ target_count }}</strong></article><article><span>Beobachtete Versuche</span><strong>{{ total_events }}</strong></article><article><span>Anzeige</span><strong class="timestamp">Top {{ flows|length }} Pfade</strong></article></section>
|
||||
<section class="panel network-panel"><div class="panel-heading"><span class="eyebrow">Flusskarte</span><h2>Quell-IP zu Zielgeraet <small>Balkenbreite entspricht der Anzahl der Versuche</small></h2></div><div class="network-flows">{% for flow in flows %}<a class="network-flow" href="{{ url_for('device', machine_name=flow.machine_name) }}"><div class="flow-endpoint source"><span>Quelle</span><strong>{{ flow.source_ip }}</strong></div><div class="flow-line"><i style="--flow: {{ (flow.count * 100 / max_count)|round(0, 'floor') }}%"></i><small>{{ flow.count }}x</small></div><div class="flow-endpoint target"><span>Ziel</span><strong>{{ flow.machine_name }}</strong><small>{{ flow.target }} | {{ flow.account }}</small></div></a>{% else %}<p class="empty-state">Keine externen Zugriffswege aus fehlgeschlagenen Anmeldungen im Zeitraum.</p>{% endfor %}</div></section>
|
||||
<section class="metrics compact-metrics"><article><span>Quell-IPs</span><strong>{{ source_count }}</strong></article><article><span>Zielgeraete</span><strong>{{ target_count }}</strong></article><article><span>Beobachtete Versuche</span><strong>{{ total_events }}</strong></article><article><span>Anzeige</span><strong class="timestamp">{{ path_count }} Pfade</strong></article></section>
|
||||
<section class="panel network-map-panel"><div class="panel-heading"><span class="eyebrow">Interaktive Karte</span><h2>Zugriffs-Topologie <small>Zoomen, verschieben oder einen Knoten bzw. Pfad auswaehlen</small></h2></div><div class="map-toolbar"><label>Filtern<input id="map-filter" type="search" placeholder="IP oder Geraet suchen"></label><button type="button" id="map-fit">Gesamtansicht</button><button type="button" id="map-clear">Auswahl loeschen</button></div><div class="network-map-layout"><div id="network-map" aria-label="Interaktive Netzwerk- und Zugriffskarte"></div><aside id="network-inspector"><span class="eyebrow">Details</span><strong>Kein Element ausgewaehlt</strong><p>Waehle eine Quell-IP, ein Zielgeraet oder eine Verbindung in der Karte.</p></aside></div><p class="chart-note"><span class="legend source"></span>Quell-IP <span class="legend target"></span>Zielgeraet <span class="legend critical"></span>Verbindung mit hoher Aktivitaet</p></section>
|
||||
<script src="{{ url_for('static', filename='vendor/cytoscape.min.js') }}"></script>
|
||||
<script>
|
||||
(() => {
|
||||
const inspector = document.getElementById('network-inspector');
|
||||
const filter = document.getElementById('map-filter');
|
||||
fetch('{{ url_for("network_api") }}').then(response => response.json()).then(graph => {
|
||||
const cy = cytoscape({ container: document.getElementById('network-map'), elements: graph.elements, minZoom: 0.3, maxZoom: 2.4,
|
||||
style: [
|
||||
{ selector: 'node', style: { 'label': 'data(label)', 'font-family': 'Roboto', 'font-size': 11, 'font-weight': 700, 'color': '#132a3d', 'text-valign': 'bottom', 'text-margin-y': 7, 'width': 46, 'height': 46, 'border-width': 3, 'border-color': '#fff' } },
|
||||
{ selector: 'node[kind = "source"]', style: { 'background-color': '#245a85', 'shape': 'ellipse' } },
|
||||
{ selector: 'node[kind = "target"]', style: { 'background-color': '#14735b', 'shape': 'round-rectangle' } },
|
||||
{ selector: 'edge', style: { 'width': 'mapData(count, 1, ' + graph.max_count + ', 2, 9)', 'line-color': '#91b5ca', 'target-arrow-color': '#91b5ca', 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', 'opacity': .72 } },
|
||||
{ selector: 'edge[count >= 5]', style: { 'line-color': '#a55a0a', 'target-arrow-color': '#a55a0a' } },
|
||||
{ selector: '.selected', style: { 'border-color': '#b8e36a', 'border-width': 6, 'line-color': '#b8e36a', 'target-arrow-color': '#b8e36a', 'opacity': 1, 'z-index': 20 } },
|
||||
{ selector: '.muted', style: { 'opacity': .12 } }
|
||||
], layout: { name: 'cose', animate: false, padding: 38, nodeRepulsion: 8200, idealEdgeLength: 130, gravity: .22 } });
|
||||
const show = element => { const data = element.data(); if (element.isEdge()) { inspector.innerHTML = '<span class="eyebrow">Zugriffsweg</span><strong>' + data.source.replace('source:', '') + ' <span class="inspector-arrow">to</span> ' + data.machine_name + '</strong><dl><dt>Versuche</dt><dd>' + data.count + '</dd><dt>Konto</dt><dd>' + data.account + '</dd><dt>Vorfall</dt><dd>' + data.event_type + '</dd><dt>Letzter Scan</dt><dd>' + data.last_seen + '</dd></dl>'; } else { const connected = element.connectedEdges(); inspector.innerHTML = '<span class="eyebrow">' + (data.kind === 'source' ? 'Quell-IP' : 'Zielgeraet') + '</span><strong>' + data.label + '</strong><p>' + connected.length + ' beobachtete Zugriffswege im Zeitraum.</p>'; } };
|
||||
cy.on('tap', 'node, edge', event => { cy.elements().removeClass('selected'); event.target.addClass('selected'); if (event.target.isNode()) { event.target.connectedEdges().addClass('selected'); } show(event.target); });
|
||||
cy.on('tap', event => { if (event.target === cy) { cy.elements().removeClass('selected'); inspector.innerHTML = '<span class="eyebrow">Details</span><strong>Kein Element ausgewaehlt</strong><p>Waehle eine Quell-IP, ein Zielgeraet oder eine Verbindung in der Karte.</p>'; } });
|
||||
filter.addEventListener('input', () => { const value = filter.value.trim().toLowerCase(); cy.elements().removeClass('muted'); if (!value) return; cy.elements().addClass('muted'); const hits = cy.nodes().filter(node => node.data('label').toLowerCase().includes(value)); hits.removeClass('muted'); hits.connectedEdges().removeClass('muted'); hits.connectedNodes().removeClass('muted'); });
|
||||
document.getElementById('map-fit').addEventListener('click', () => cy.fit(cy.elements(), 35));
|
||||
document.getElementById('map-clear').addEventListener('click', () => { filter.value = ''; cy.elements().removeClass('muted selected'); });
|
||||
}).catch(() => { document.getElementById('network-map').textContent = 'Die Netzwerkdaten konnten nicht geladen werden.'; });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user