Skip to content

Commit 00dd4de

Browse files
Add ThreatGraph pivot/edge intelligence: chains, hover, sizing
1 parent db044ee commit 00dd4de

1 file changed

Lines changed: 200 additions & 5 deletions

File tree

src/App.jsx

Lines changed: 200 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const SESSION_ID = getSessionId();
5555
// onto the /fetch, /parse, and /enrich requests the app already makes for
5656
// functional reasons — SESSION_ID is attached to those, but there is no
5757
// dedicated client-initiated logging call. Invisible to browser DevTools.
58-
const APP_VERSION = "v117";
58+
const APP_VERSION = "v118";
5959

6060
// ============================================================
6161
// IOC Whitelist — exact-match auto-removal from parsed results
@@ -8223,13 +8223,31 @@ class GraphErrorBoundary extends Component {
82238223
}
82248224
}
82258225

8226+
// Human-readable label + swatch color per edge relationship kind — the single
8227+
// source of truth for the legend, hover tooltips, and the connected-node panel,
8228+
// so all three always agree on what a given line color means.
8229+
const EDGE_KIND_META = {
8230+
serves: { label: "Serving IP", color: "#00e5ff" },
8231+
resolved: { label: "Resolved (passive DNS)", color: "#2dd4bf" },
8232+
hosts: { label: "Hosts file", color: "#ff4d6d" },
8233+
scanned: { label: "Scanned URL", color: "#7c9cff" },
8234+
contacted: { label: "Contacted", color: "#fb923c" },
8235+
loads: { label: "Loads resource", color: "#ff4d6d" },
8236+
c2: { label: "C2 communication", color: "#ff4d6d" },
8237+
downloaded_from: { label: "Downloaded from", color: "#ff4d6d" },
8238+
hosted_on: { label: "Hosted on", color: "#00e5ff" },
8239+
related: { label: "Related sample", color: "#ff4d6d" },
8240+
dropped: { label: "Execution parent", color: "#c084fc" },
8241+
};
8242+
82268243
function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addPivotIOC, isPivotAdded, removeIoc, anyEnriched, hashCollapseAnims = [] }) {
82278244
const canvasRef = useRef(null);
82288245
const wrapRef = useRef(null);
82298246
const stateRef = useRef({ nodes: [], edges: [], t: 0 });
82308247
const camRef = useRef({ x: 0, y: 0, zoom: 1, dragNode: null, panning: false, lastX: 0, lastY: 0, hover: null });
82318248
const [dims, setDims] = useState({ w: 900, h: 600 });
82328249
const [hoverInfo, setHoverInfo] = useState(null);
8250+
const [hoverEdgeInfo, setHoverEdgeInfo] = useState(null);
82338251
const [selected, setSelected] = useState(null); // node for the floating action panel
82348252
const [fullscreen, setFullscreen] = useState(false);
82358253
const [hiddenCats, setHiddenCats] = useState({}); // { CAT: true } to hide a type
@@ -8516,12 +8534,39 @@ function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addP
85168534
const connected = new Set();
85178535
edges.forEach((e) => { if (e.kind !== "asn") { connected.add(e.a); connected.add(e.b); } });
85188536
nodeArr.forEach((n) => { n.orphan = !connected.has(n.id); });
8537+
8538+
// Degree (real, non-asn connections) — hub infrastructure should read as
8539+
// more important at a glance, before a single edge is traced by eye.
8540+
const degreeMap = {};
8541+
edges.forEach((e) => {
8542+
if (e.kind === "asn") return;
8543+
degreeMap[e.a] = (degreeMap[e.a] || 0) + 1;
8544+
degreeMap[e.b] = (degreeMap[e.b] || 0) + 1;
8545+
});
8546+
nodeArr.forEach((n) => {
8547+
n.degree = degreeMap[n.id] || 0;
8548+
n.r = (n.derived ? 7 : 11) + Math.min(n.degree * 0.6, 6); // capped bonus, hubs still read but never dominate
8549+
});
8550+
8551+
// Execution-chain (dropper → payload) and C2 links — the two most
8552+
// narratively significant relationship kinds, called out explicitly for
8553+
// the auto-summary headline and the kill-chain edge styling.
8554+
const dropperEdges = edges.filter((e) => e.kind === "dropped");
8555+
const c2Edges = edges.filter((e) => e.kind === "c2");
8556+
const nodeById = new Map(nodeArr.map((n) => [n.id, n]));
8557+
8558+
// Which relationship kinds actually appear in this graph — drives the
8559+
// legend, so it only ever lists kinds a user can actually see right now.
8560+
const edgeKindsPresent = [...new Set(edges.filter((e) => e.kind !== "asn").map((e) => e.kind))];
8561+
85198562
// Per-type counts for the slicer (only categories actually present).
85208563
const typeCountMap = {};
85218564
nodeArr.forEach((n) => { typeCountMap[n.cat] = (typeCountMap[n.cat] || 0) + 1; });
85228565
const typeCounts = Object.entries(typeCountMap).sort((a, b) => b[1] - a[1]);
85238566
return {
8524-
nodes: nodeArr, edges, typeCounts,
8567+
nodes: nodeArr, edges, typeCounts, edgeKindsPresent,
8568+
dropperChains: dropperEdges.map((e) => ({ parent: nodeById.get(e.a)?.label || e.a, child: nodeById.get(e.b)?.label || e.b })),
8569+
c2Links: c2Edges.map((e) => ({ from: nodeById.get(e.a)?.label || e.a, to: nodeById.get(e.b)?.label || e.b })),
85258570
stats: { nodes: nodeArr.length, edges: edges.length, derived: nodeArr.filter((n) => n.derived).length, bridges: bridgeCount, orphans: nodeArr.filter((n) => n.orphan).length },
85268571
};
85278572
}, [iocData, enrichCache]);
@@ -8898,9 +8943,27 @@ function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addP
88988943
const dim = hoverId && !(e.a === hoverId || e.b === hoverId);
88998944
// Bridge edges (touching shared-infrastructure pivot nodes) stand out in gold.
89008945
const edgeColor = e.toBridge ? "rgba(255,209,102,0.75)" : e.color;
8946+
const isDropped = e.kind === "dropped";
8947+
const isHotC2 = e.kind === "c2" && (a.verdict === "Malicious" || b.verdict === "Malicious");
89018948
ctx.strokeStyle = dim ? "rgba(120,160,180,0.06)" : edgeColor;
8902-
ctx.lineWidth = e.kind === "asn" ? 0.6 : (e.toBridge ? 1.8 : 1.1);
8949+
ctx.lineWidth = e.kind === "asn" ? 0.6 : (e.toBridge ? 1.8 : (isDropped || isHotC2 ? 2.2 : 1.1));
89038950
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke();
8951+
// Directional arrowhead for execution-parent (dropper) edges — this is
8952+
// the one relationship where direction is the whole point (parent
8953+
// produced child), so it's worth showing explicitly, not just by color.
8954+
if (isDropped && !dim) {
8955+
const angle = Math.atan2(pb.y - pa.y, pb.x - pa.x);
8956+
const tipOffset = (b.r || 8) * cam.zoom + 3;
8957+
const tipX = pb.x - Math.cos(angle) * tipOffset, tipY = pb.y - Math.sin(angle) * tipOffset;
8958+
const size = 6;
8959+
ctx.fillStyle = edgeColor;
8960+
ctx.beginPath();
8961+
ctx.moveTo(tipX, tipY);
8962+
ctx.lineTo(tipX - size * Math.cos(angle - Math.PI / 6), tipY - size * Math.sin(angle - Math.PI / 6));
8963+
ctx.lineTo(tipX - size * Math.cos(angle + Math.PI / 6), tipY - size * Math.sin(angle + Math.PI / 6));
8964+
ctx.closePath();
8965+
ctx.fill();
8966+
}
89048967
// Flow particle
89058968
if (!dim && e.kind !== "asn") {
89068969
const prog = ((S.t * 0.01) + (e.a.charCodeAt(0) % 10) * 0.1) % 1;
@@ -9080,10 +9143,46 @@ function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addP
90809143
}
90819144
return null;
90829145
};
9146+
// Closest edge to the cursor, within a small pixel threshold — only tried
9147+
// when no node was picked, so edge hover never fights node hover for focus.
9148+
const pickEdge = (mx, my) => {
9149+
const S = stateRef.current, cam = camRef.current;
9150+
const cx = dims.w / 2, cy = dims.h / 2;
9151+
const vis = visibleRef.current;
9152+
const anyFilter = vis && vis.size !== S.nodes.length;
9153+
const nodeById = new Map(S.nodes.map((n) => [n.id, n]));
9154+
let best = null, bestDist = 8;
9155+
for (const e of S.edges) {
9156+
if (e.kind === "asn") continue;
9157+
if (anyFilter && (!vis.has(e.a) || !vis.has(e.b))) continue;
9158+
const a = nodeById.get(e.a), b = nodeById.get(e.b);
9159+
if (!a || !b) continue;
9160+
const ax = cx + (a.x + cam.x) * cam.zoom, ay = cy + (a.y + cam.y) * cam.zoom;
9161+
const bx = cx + (b.x + cam.x) * cam.zoom, by = cy + (b.y + cam.y) * cam.zoom;
9162+
const dx = bx - ax, dy = by - ay;
9163+
const lenSq = dx * dx + dy * dy;
9164+
let t = lenSq === 0 ? 0 : ((mx - ax) * dx + (my - ay) * dy) / lenSq;
9165+
t = Math.max(0, Math.min(1, t));
9166+
const ex = ax + t * dx, ey = ay + t * dy;
9167+
const dist = Math.hypot(mx - ex, my - ey);
9168+
if (dist < bestDist) { bestDist = dist; best = { edge: e, a, b }; }
9169+
}
9170+
return best;
9171+
};
90839172
const relPos = (e) => {
90849173
const rect = canvasRef.current.getBoundingClientRect();
90859174
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
90869175
};
9176+
// Export the current canvas frame as a PNG — analysts routinely need to
9177+
// drop the infra graph straight into a ticket or report.
9178+
const exportGraphImage = () => {
9179+
const canvas = canvasRef.current;
9180+
if (!canvas) return;
9181+
const link = document.createElement("a");
9182+
link.download = `threat-graph-${Date.now()}.png`;
9183+
link.href = canvas.toDataURL("image/png");
9184+
link.click();
9185+
};
90879186
const onDown = (e) => {
90889187
const { x, y } = relPos(e);
90899188
const n = pick(x, y);
@@ -9109,7 +9208,12 @@ function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addP
91099208
if (n) {
91109209
const d = enrichCache[`${n.cat}::${n.id}`]?.data;
91119210
setHoverInfo({ x, y, node: n, verdict: n.verdict, asn: n.asn || d?.whoisASN?.asn || null, country: d?.whoisASN?.country || null });
9112-
} else setHoverInfo(null);
9211+
setHoverEdgeInfo(null);
9212+
} else {
9213+
setHoverInfo(null);
9214+
const eh = pickEdge(x, y);
9215+
setHoverEdgeInfo(eh ? { x, y, ...eh } : null);
9216+
}
91139217
// Grab cursor over draggable nodes; default elsewhere. Copy is via the
91149218
// action panel (canvas text can't be natively selected).
91159219
canvasRef.current.style.cursor = n ? "grab" : "default";
@@ -9210,6 +9314,30 @@ function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addP
92109314
style={fullscreen
92119315
? { position: "fixed", inset: 0, zIndex: 9999, background: "radial-gradient(1200px 600px at 50% 0%, rgba(0,229,255,0.06), transparent 60%), #05070a" }
92129316
: { background: "radial-gradient(1200px 600px at 50% 0%, rgba(0,229,255,0.06), transparent 60%), #05070a", border: "1px solid rgba(120,160,180,0.2)" }}>
9317+
{/* Auto-narrated headline — turns the graph's own bridge/dropper/C2
9318+
detection into one plain-language line instead of requiring a
9319+
manual scan to notice it. Only appears when there's something to say. */}
9320+
{hasGraph && (model.stats.bridges > 0 || model.dropperChains.length > 0 || model.c2Links.length > 0) && (
9321+
<div className="relative z-10 px-3 py-2 text-[11px] leading-relaxed"
9322+
style={{ background: "rgba(10,14,20,0.88)", borderBottom: "1px solid rgba(120,160,180,0.15)", color: "#c8d4da" }}>
9323+
{model.dropperChains.length > 0 && (
9324+
<span>
9325+
<span style={{ color: "#c084fc", fontWeight: 700 }}>⚡ {model.dropperChains.length} execution chain{model.dropperChains.length !== 1 ? "s" : ""}</span>
9326+
{model.dropperChains.length === 1
9327+
? <> — <span style={{ fontFamily: "monospace" }}>{model.dropperChains[0].parent}</span> dropped <span style={{ fontFamily: "monospace" }}>{model.dropperChains[0].child}</span></>
9328+
: " detected — a parent sample produced a tracked payload"}
9329+
</span>
9330+
)}
9331+
{model.dropperChains.length > 0 && (model.stats.bridges > 0 || model.c2Links.length > 0) && <span style={{ color: "#3a4a54" }}> · </span>}
9332+
{model.stats.bridges > 0 && (
9333+
<span><span style={{ color: "#ffd166", fontWeight: 700 }}>🔗 {model.stats.bridges} shared pivot{model.stats.bridges !== 1 ? "s" : ""}</span> link separate indicators through common infrastructure</span>
9334+
)}
9335+
{model.stats.bridges > 0 && model.c2Links.length > 0 && <span style={{ color: "#3a4a54" }}> · </span>}
9336+
{model.c2Links.length > 0 && (
9337+
<span><span style={{ color: "#ff4d6d", fontWeight: 700 }}>📡 {model.c2Links.length} confirmed C2 link{model.c2Links.length !== 1 ? "s" : ""}</span></span>
9338+
)}
9339+
</div>
9340+
)}
92139341
{!hasGraph && (
92149342
<button onClick={() => setFullscreen((v) => !v)}
92159343
className="absolute z-20 flex items-center gap-1 rounded-md px-2.5 py-1 text-[10px] font-semibold"
@@ -9257,6 +9385,13 @@ function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addP
92579385
</>
92589386
)}
92599387
</div>
9388+
{/* Export */}
9389+
<button onClick={exportGraphImage}
9390+
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-[10px] font-semibold shrink-0"
9391+
title="Export the current graph view as a PNG"
9392+
style={{ background: "rgba(0,255,156,0.12)", color: "#00ff9c", border: "1px solid rgba(0,255,156,0.4)", cursor: "pointer" }}>
9393+
<Download size={11} /> Export
9394+
</button>
92609395
{/* Fullscreen */}
92619396
<button onClick={() => setFullscreen((v) => !v)}
92629397
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-[10px] font-semibold shrink-0"
@@ -9357,6 +9492,12 @@ function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addP
93579492
<span className="flex items-center gap-1 text-[10px]" style={{ color: "#ffd166" }}>
93589493
<span style={{ width: 8, height: 8, borderRadius: 99, background: "#ffd166", boxShadow: "0 0 6px #ffd166" }} /> shared pivot
93599494
</span>
9495+
{model.edgeKindsPresent.filter((k) => k !== "contacted").map((k) => (
9496+
<span key={k} className="flex items-center gap-1 text-[10px]" style={{ color: "#9fb3bd" }} title={`Line color for "${EDGE_KIND_META[k]?.label || k}" connections`}>
9497+
<span style={{ width: 12, height: 2, background: EDGE_KIND_META[k]?.color || "#8aa0ad" }} />
9498+
{EDGE_KIND_META[k]?.label || k}
9499+
</span>
9500+
))}
93609501
</div>
93619502
)}
93629503
{!anyEnriched && (
@@ -9373,7 +9514,7 @@ function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addP
93739514
<canvas ref={canvasRef}
93749515
style={{ width: dims.w, height: dims.h, display: "block", touchAction: "none", transition: "height 0.6s cubic-bezier(0.22,1,0.36,1)" }}
93759516
onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp}
9376-
onPointerLeave={() => { const cam = camRef.current; cam.dragNode = null; cam.panning = false; }}
9517+
onPointerLeave={() => { const cam = camRef.current; cam.dragNode = null; cam.panning = false; setHoverInfo(null); setHoverEdgeInfo(null); }}
93779518
onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
93789519
/>
93799520

@@ -9398,6 +9539,29 @@ function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addP
93989539
</div>
93999540
)}
94009541

9542+
{/* Edge hover card — names the relationship a line represents, since
9543+
color alone (the only always-visible signal) isn't self-explanatory. */}
9544+
{hoverEdgeInfo && (() => {
9545+
const meta = EDGE_KIND_META[hoverEdgeInfo.edge.kind];
9546+
const label = meta?.label || hoverEdgeInfo.edge.kind;
9547+
const swatch = meta?.color || "#8aa0ad";
9548+
return (
9549+
<div className="absolute z-20 pointer-events-none rounded-lg px-3 py-2 text-[11px]"
9550+
style={{
9551+
left: Math.min(hoverEdgeInfo.x + 14, dims.w - 240), top: Math.min(hoverEdgeInfo.y + 14, dims.h - 70),
9552+
background: "rgba(10,14,20,0.95)", border: `1px solid ${swatch}66`,
9553+
backdropFilter: "blur(8px)", minWidth: 180, maxWidth: 280,
9554+
}}>
9555+
<div className="font-bold mb-1" style={{ color: swatch }}>{label}</div>
9556+
<div className="break-all" style={{ color: "#c8d4da" }}>
9557+
{hoverEdgeInfo.edge.kind === "dropped"
9558+
? <>{hoverEdgeInfo.a.label} <span style={{ color: "#5d7382" }}>dropped</span> {hoverEdgeInfo.b.label}</>
9559+
: <>{hoverEdgeInfo.a.label} <span style={{ color: "#5d7382" }}>→</span> {hoverEdgeInfo.b.label}</>}
9560+
</div>
9561+
</div>
9562+
);
9563+
})()}
9564+
94019565
{/* Node action panel — click a node to open, click canvas to close */}
94029566
{selected && (() => {
94039567
try {
@@ -9451,6 +9615,37 @@ function ThreatGraph({ iocData, enrichCache, colorFor, enrichIOC, copyText, addP
94519615
</div>
94529616
)}
94539617

9618+
{/* Connected via — names the relationship(s) that link this node
9619+
into the graph, since the canvas itself only shows color. */}
9620+
{(() => {
9621+
const links = model.edges
9622+
.filter((e) => e.kind !== "asn" && (e.a === selected.id || e.b === selected.id))
9623+
.map((e) => {
9624+
const meta = EDGE_KIND_META[e.kind];
9625+
const otherId = e.a === selected.id ? e.b : e.a;
9626+
const other = model.nodes.find((n) => n.id === otherId);
9627+
const isSource = e.kind === "dropped" ? e.a === selected.id : null;
9628+
return { kind: e.kind, label: meta?.label || e.kind, color: meta?.color || "#8aa0ad", other: other?.label || otherId, isSource };
9629+
});
9630+
if (!links.length) return null;
9631+
return (
9632+
<div className="mb-2.5 rounded-lg px-2.5 py-2 flex flex-col gap-1"
9633+
style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(120,160,180,0.15)" }}>
9634+
<div className="text-[9px] uppercase tracking-widest font-bold mb-0.5" style={{ color: "#5d7382" }}>Connected via</div>
9635+
{links.map((l, i) => (
9636+
<div key={i} className="flex items-center gap-1.5 text-[10px]">
9637+
<span style={{ width: 8, height: 2, background: l.color, flexShrink: 0 }} />
9638+
<span style={{ color: l.color, fontWeight: 700 }}>{l.label}</span>
9639+
<span style={{ color: "#5d7382" }}>
9640+
{l.kind === "dropped" ? (l.isSource ? "dropped" : "dropped by") : "↔"}
9641+
</span>
9642+
<span className="truncate" style={{ color: "#c8d6dd" }}>{l.other}</span>
9643+
</div>
9644+
))}
9645+
</div>
9646+
);
9647+
})()}
9648+
94549649
{/* Action buttons */}
94559650
<div className="flex flex-wrap gap-1.5">
94569651
{canEnrich && (

0 commit comments

Comments
 (0)