@@ -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+
82268243function 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