From bc04c51b29ceb5be41bd2893dc50137abd4c47c0 Mon Sep 17 00:00:00 2001 From: BadgerOps Date: Thu, 11 Jun 2026 18:53:45 -0600 Subject: [PATCH] Polish topology evidence actions --- agent/CHANGELOG.md | 4 + agent/README.md | 2 + agent/VERSION | 2 +- agent/grapheon_agent.py | 27 ++- agent/tests/test_grapheon_agent.py | 58 +++++- backend/CHANGELOG.md | 4 + backend/VERSION | 2 +- backend/network/edges.py | 89 ++++++--- backend/tests/test_agents.py | 42 +++++ docs/agents.md | 2 + frontend/CHANGELOG.md | 5 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../src/components/CytoscapeNetworkMap.jsx | 129 ++++++++++++- frontend/src/pages/Map.jsx | 177 +++++++++++++++++- 15 files changed, 500 insertions(+), 49 deletions(-) diff --git a/agent/CHANGELOG.md b/agent/CHANGELOG.md index 79714d2..96019ea 100644 --- a/agent/CHANGELOG.md +++ b/agent/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the Graphēon passive agent will be documented in this fi The format is based on Keep a Changelog, and this project follows Semantic Versioning. +## 0.17.0 - 2026-06-12 +### Fixed +- **Passive capture parser hardening**: improved CDP management-address TLV parsing and DHCPv6 delegated-prefix parsing, with additional vendor-shaped LLDP, CDP, DHCPv6, and mDNS fixture coverage. + ## 0.16.0 - 2026-06-11 ### Added - **Richer passive capture parsing**: tcpdump evidence now preserves VLAN IDs, extended LLDP/CDP details, DHCPv4/DHCPv6 options, IPv6 RA prefixes/DNS/MTU hints, DNS PTR/CNAME/SRV/SVCB/HTTPS records, NBNS names, SSDP and WS-Discovery labels, STP/LACP neighbor hints, HSRP/VRRP/CARP gateway hints, OSPF/RIP/EIGRP/BGP map hints, and aggregated optional flow counters. diff --git a/agent/README.md b/agent/README.md index 4227db3..fce42a6 100644 --- a/agent/README.md +++ b/agent/README.md @@ -105,6 +105,8 @@ The parser preserves VLAN IDs, LLDP/CDP capabilities and platform details, DHCP Service and discovery display names can contain characters that are not valid in Graphēon's hostname-like `name` field. The agent sends a schema-safe top-level label for those records and preserves the original value in `metadata.raw_name`. +The capture parser has fixture coverage for vendor-shaped LLDP/CDP management-address records, DHCPv6 delegated prefixes, and mDNS service instances with display names. These fixtures are synthetic pcaps and do not require real tcpdump during tests. + Topology evidence records support `l2_neighbor`, `switch_port_attachment`, `mac_ip_binding`, `dhcp_lease`, `dns_name`, `route`, `flow_relationship`, and `network_segment` evidence types. They are map enrichment data, not security alerts or active scans. Versioned install helpers: diff --git a/agent/VERSION b/agent/VERSION index 04a373e..c5523bd 100644 --- a/agent/VERSION +++ b/agent/VERSION @@ -1 +1 @@ -0.16.0 +0.17.0 diff --git a/agent/grapheon_agent.py b/agent/grapheon_agent.py index e901535..4a486c4 100644 --- a/agent/grapheon_agent.py +++ b/agent/grapheon_agent.py @@ -932,10 +932,25 @@ def parse_cdp_frame(frame: bytes, interface: Optional[str] = None) -> Optional[d metadata["native_vlan"] = vlan_id elif tlv_type == 0x000b and value: metadata["duplex"] = "full" if value[-1] else "half" - elif tlv_type == 0x0002 and len(value) >= 17: - protocol_type = value[8] if len(value) > 8 else None - if protocol_type == 0xCC and len(value) >= 17: - record["management_ip"] = ".".join(str(byte) for byte in value[-4:]) + elif tlv_type == 0x0002 and len(value) >= 4: + address_count = int.from_bytes(value[:4], "big") + address_offset = 4 + for _ in range(address_count): + if address_offset + 4 > len(value): + break + protocol_type = value[address_offset] + protocol_len = value[address_offset + 1] + protocol = value[address_offset + 2 : address_offset + 2 + protocol_len] + address_offset += 2 + protocol_len + if address_offset + 2 > len(value): + break + address_len = int.from_bytes(value[address_offset : address_offset + 2], "big") + address_offset += 2 + address = value[address_offset : address_offset + address_len] + address_offset += address_len + if protocol_type == 1 and protocol == b"\xcc" and address_len == 4 and len(address) == 4: + record["management_ip"] = str(ip_address(address)) + break merge_metadata(record, **metadata) if record.get("system_name") or record.get("port_id") or record.get("management_ip"): return {key: value for key, value in record.items() if value is not None} @@ -1321,8 +1336,8 @@ def parse_dhcpv6_evidence( } ) elif sub_code == 26 and len(sub_value) >= 25: - prefix_len = sub_value[24] - prefix_ip = ip_address(sub_value[25:41]) if len(sub_value) >= 41 else None + prefix_len = sub_value[8] + prefix_ip = ip_address(sub_value[9:25]) if prefix_ip: prefixes.append( { diff --git a/agent/tests/test_grapheon_agent.py b/agent/tests/test_grapheon_agent.py index 4375295..eb4aa01 100644 --- a/agent/tests/test_grapheon_agent.py +++ b/agent/tests/test_grapheon_agent.py @@ -186,6 +186,19 @@ def _dns_ptr_response(name: str, target: str) -> bytes: return b"\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" + qname + b"\x00\x0c\x00\x01" + answer +def _dns_srv_response(name: str, target: str, port: int) -> bytes: + qname = _dns_name(name) + rdata = b"\x00\x00\x00\x05" + port.to_bytes(2, "big") + _dns_name(target) + answer = ( + b"\xc0\x0c" + + b"\x00\x21\x00\x01" + + b"\x00\x00\x00\x3c" + + len(rdata).to_bytes(2, "big") + + rdata + ) + return b"\x12\x36\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" + qname + b"\x00\x21\x00\x01" + answer + + def _dns_misc_response() -> bytes: qname = _dns_name("2.0.0.10.in-addr.arpa") ptr = ( @@ -276,10 +289,13 @@ def _dhcpv6_reply() -> bytes: duid = b"\x00\x03\x00\x01" + bytes.fromhex("aabbccddee03") iaaddr = ipaddress.ip_address("2001:db8::50").packed + b"\x00\x00\x0e\x10\x00\x00\x1c\x20" ia_na = b"\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x05" + len(iaaddr).to_bytes(2, "big") + iaaddr + iaprefix = b"\x00\x00\x0e\x10\x00\x00\x1c\x20\x38" + ipaddress.ip_address("2001:db8:1200::").packed + ia_pd = b"\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x1a" + len(iaprefix).to_bytes(2, "big") + iaprefix return ( b"\x07\x12\x34\x56" + b"\x00\x01" + len(duid).to_bytes(2, "big") + duid + b"\x00\x03" + len(ia_na).to_bytes(2, "big") + ia_na + + b"\x00\x19" + len(ia_pd).to_bytes(2, "big") + ia_pd + b"\x00\x17\x00\x10" + ipaddress.ip_address("2001:db8::53").packed + b"\x00\x18" + len(_dns_name("example.local")).to_bytes(2, "big") + _dns_name("example.local") + b"\x00\x27\x00\x0b\x00lease-host" @@ -516,6 +532,8 @@ def test_parse_dns_evidence_sanitizes_service_instance_names(): def test_parse_pcap_topology_evidence_extracts_lldp_and_cdp(tmp_path): + import ipaddress + lldp_payload = b"".join( [ _lldp_tlv(1, b"\x04\x00\x11\x22\x33\x44\x55"), @@ -529,9 +547,25 @@ def test_parse_pcap_topology_evidence_extracts_lldp_and_cdp(tmp_path): _lldp_tlv(0, b""), ] ) + lldp_ipv6_payload = b"".join( + [ + _lldp_tlv(1, b"\x04\x00\x11\x22\x33\x44\x66"), + _lldp_tlv(2, b"\x05Te1/0/49"), + _lldp_tlv(5, b"switch-ipv6"), + _lldp_tlv(8, b"\x11\x02" + ipaddress.ip_address("2001:db8::2").packed + b"\x02\x00\x00"), + _lldp_tlv(0, b""), + ] + ) + cdp_address = ( + b"\x00\x00\x00\x01" + + b"\x01\x01\xcc" + + b"\x00\x04" + + bytes([10, 0, 0, 254]) + ) cdp_payload = ( bytes.fromhex("01000000") + _cdp_tlv(0x0001, b"router01") + + _cdp_tlv(0x0002, cdp_address) + _cdp_tlv(0x0003, b"Eth1/1") + _cdp_tlv(0x0004, b"\x00\x00\x00\x09") + _cdp_tlv(0x0005, b"IOS-XE") @@ -553,6 +587,11 @@ def test_parse_pcap_topology_evidence_extracts_lldp_and_cdp(tmp_path): ether_type=0x88CC, dst=bytes.fromhex("0180c200000e"), ), + _ether( + lldp_ipv6_payload, + ether_type=0x88CC, + dst=bytes.fromhex("0180c200000e"), + ), cdp_frame, ) @@ -573,6 +612,7 @@ def test_parse_pcap_topology_evidence_extracts_lldp_and_cdp(tmp_path): item["evidence_type"] == "l2_neighbor" and item["source"] == "cdp" and item["system_name"] == "router01" + and item["management_ip"] == "10.0.0.254" and item["port_id"] == "Eth1/1" and item["vlan_id"] == 20 and item["metadata"]["platform"] == "C9300" @@ -580,12 +620,20 @@ def test_parse_pcap_topology_evidence_extracts_lldp_and_cdp(tmp_path): and item["metadata"]["duplex"] == "full" for item in evidence ) + assert any( + item["evidence_type"] == "l2_neighbor" + and item["source"] == "lldp" + and item["system_name"] == "switch-ipv6" + and item["management_ip"] == "2001:db8::2" + for item in evidence + ) def test_parse_pcap_topology_evidence_enriches_dns_nbns_and_discovery(tmp_path): pcap_path = _write_pcap( tmp_path, _ether(_ipv4_udp("10.0.0.53", "10.0.0.2", 53, 53000, _dns_misc_response())), + _ether(_ipv4_udp("10.0.0.20", "224.0.0.251", 5353, 5353, _dns_srv_response("EPSON ET-16600 Series._smb._tcp.local", "epson.local", 445))), _ether(_ipv4_udp("10.0.0.10", "10.0.0.255", 137, 137, _nbns_response("WORKSTATION", "10.0.0.10"))), _ether(_ipv4_udp("10.0.0.20", "239.255.255.250", 1900, 1900, b"NOTIFY * HTTP/1.1\r\nUSN: uuid:device-1\r\nLOCATION: http://10.0.0.20/root.xml\r\nST: upnp:rootdevice\r\n\r\n")), _ether(_ipv4_udp("10.0.0.30", "239.255.255.250", 3702, 3702, b"urn:uuid:printer-1dn:Printerhttp://10.0.0.30/wsd")), @@ -596,6 +644,13 @@ def test_parse_pcap_topology_evidence_enriches_dns_nbns_and_discovery(tmp_path): assert any(item["source"] == "dns" and item["name"] == "host.local" and item["ip_address"] == "10.0.0.2" for item in evidence) assert any(item["source"] == "dns" and item["name"] == "_printer._tcp.local" and item["metadata"]["service_port"] == 9100 for item in evidence) assert any(item["source"] == "dns" and item["metadata"].get("record_kind") == "https" for item in evidence) + assert any( + item["source"] == "mdns" + and item["name"] == "EPSON_ET-16600_Series._smb._tcp.local" + and item["metadata"]["raw_name"] == "EPSON ET-16600 Series._smb._tcp.local" + and item["metadata"]["service_port"] == 445 + for item in evidence + ) assert any(item["source"] == "nbns" and item["name"] == "WORKSTATION" and item["ip_address"] == "10.0.0.10" for item in evidence) assert any(item["source"] == "ssdp" and item["metadata"]["location"] == "http://10.0.0.20/root.xml" for item in evidence) assert any(item["source"] == "wsd" and item["metadata"]["types"] == "dn:Printer" for item in evidence) @@ -617,7 +672,8 @@ def test_parse_pcap_topology_evidence_enriches_dhcpv4_dhcpv6_and_ra(tmp_path): assert dhcpv4["metadata"]["dns_servers"] == ["10.0.0.53", "10.0.0.54"] assert dhcpv4["metadata"]["lease_time_seconds"] == 3600 - assert any(item["source"] == "dhcpv6" and item["ip_address"] == "2001:db8::50" and item["hostname"] == "lease-host" for item in evidence) + assert any(item["source"] == "dhcpv6" and item.get("ip_address") == "2001:db8::50" and item["hostname"] == "lease-host" for item in evidence) + assert any(item["source"] == "dhcpv6" and item.get("network") == "2001:db8:1200::/56" for item in evidence) assert any(item["evidence_type"] == "network_segment" and item["network"] == "2001:db8:1::/64" for item in evidence) ra_route = next(item for item in evidence if item["evidence_type"] == "route" and item["gateway"] == "fe80::1") assert ra_route["metadata"]["mtu"] == 1500 diff --git a/backend/CHANGELOG.md b/backend/CHANGELOG.md index 27fe994..4eac4d7 100644 --- a/backend/CHANGELOG.md +++ b/backend/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi Versioning policy: do not use `Unreleased` changelog sections. Every behavior change, bug fix, hardening change, or notable test addition must be recorded under a concrete SemVer version. Bump patch versions for bug fixes and minor versions for new behavior or API/UI changes. +## 0.19.0 - 2026-06-12 +### Added +- **Historical topology evidence metadata**: Network Map relationship evidence now includes current/stale state, stale and removed timestamps, observation IDs, raw payload summaries, and host endpoint hints so the UI can expose history and promotion actions. + ## 0.18.0 - 2026-06-11 ### Added - **Passive topology evidence ingest**: agent check-ins now accept normalized `topology_evidence` records for L2 neighbors, switch-port attachments, MAC/IP bindings, DHCP leases, DNS names, routes, flow relationships, and network segments. diff --git a/backend/VERSION b/backend/VERSION index 6633391..1cf0537 100644 --- a/backend/VERSION +++ b/backend/VERSION @@ -1 +1 @@ -0.18.0 +0.19.0 diff --git a/backend/network/edges.py b/backend/network/edges.py index 2cf8df7..c4a614f 100644 --- a/backend/network/edges.py +++ b/backend/network/edges.py @@ -524,6 +524,43 @@ def add_agent_topology_edges( } ) + def evidence_record( + observation: Any, + *, + source_host_id: int | None = None, + target_host_id: int | None = None, + ) -> dict[str, Any]: + payload = observation.payload or {} + record = { + "source": payload.get("source") or "agent", + "observer": payload.get("observer"), + "observer_agent_id": observation.agent_id, + "confidence": observation.confidence, + "first_seen": observation.first_seen_at.isoformat() + if observation.first_seen_at + else None, + "last_seen": observation.last_seen_at.isoformat() + if observation.last_seen_at + else None, + "stale_at": observation.stale_at.isoformat() + if observation.stale_at + else None, + "removed_at": observation.removed_at.isoformat() + if observation.removed_at + else None, + "is_current": observation.is_current, + "evidence_type": observation.relationship_type, + "summary": _topology_edge_summary(payload), + "raw_ref": payload.get("raw_ref"), + "agent_observation_id": observation.id, + "payload": payload, + } + if source_host_id is not None: + record["source_host_id"] = source_host_id + if target_host_id is not None: + record["target_host_id"] = target_host_id + return record + def add_edge( source: str, target: str, @@ -537,6 +574,8 @@ def add_edge( if edge_id in edge_ids: return edge_ids.add(edge_id) + source_host_id = int(source) if str(source).isdigit() else None + target_host_id = int(target) if str(target).isdigit() else None edges.append( { "data": { @@ -558,22 +597,20 @@ def add_edge( "last_seen_at": observation.last_seen_at.isoformat() if observation.last_seen_at else None, + "stale_at": observation.stale_at.isoformat() + if observation.stale_at + else None, + "removed_at": observation.removed_at.isoformat() + if observation.removed_at + else None, + "is_current": observation.is_current, "relationship_key": observation.relationship_key, "topology_evidence": [ - { - "source": (observation.payload or {}).get("source") or "agent", - "observer": (observation.payload or {}).get("observer"), - "confidence": observation.confidence, - "first_seen": observation.first_seen_at.isoformat() - if observation.first_seen_at - else None, - "last_seen": observation.last_seen_at.isoformat() - if observation.last_seen_at - else None, - "evidence_type": observation.relationship_type, - "summary": _topology_edge_summary(observation.payload or {}), - "raw_ref": (observation.payload or {}).get("raw_ref"), - } + evidence_record( + observation, + source_host_id=source_host_id, + target_host_id=target_host_id, + ) ], "tooltip": tooltip, } @@ -610,22 +647,14 @@ def ensure_evidence_node( "source_type": payload.get("source") or "agent", "observer_agent_id": observation.agent_id, "agent_observation_id": observation.id, - "topology_evidence": [ - { - "source": payload.get("source") or "agent", - "observer": payload.get("observer"), - "confidence": observation.confidence, - "first_seen": observation.first_seen_at.isoformat() - if observation.first_seen_at - else None, - "last_seen": observation.last_seen_at.isoformat() - if observation.last_seen_at - else None, - "evidence_type": observation.relationship_type, - "summary": _topology_edge_summary(payload), - "raw_ref": payload.get("raw_ref"), - } - ], + "is_current": observation.is_current, + "stale_at": observation.stale_at.isoformat() + if observation.stale_at + else None, + "removed_at": observation.removed_at.isoformat() + if observation.removed_at + else None, + "topology_evidence": [evidence_record(observation)], "tooltip": tooltip_from_payload(payload, label), } } diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py index 9666dda..e8a1382 100644 --- a/backend/tests/test_agents.py +++ b/backend/tests/test_agents.py @@ -873,6 +873,48 @@ async def test_topology_evidence_checkin_feeds_evidence_api_and_map_layers( for edge in map_data["elements"]["edges"] if edge["data"].get("relationship_type") == "dns_name" ) + dns_edge = next( + edge + for edge in map_data["elements"]["edges"] + if edge["data"].get("relationship_type") == "dns_name" + ) + dns_evidence = dns_edge["data"]["topology_evidence"][0] + assert dns_evidence["is_current"] is True + assert dns_evidence["agent_observation_id"] == dns_edge["data"]["agent_observation_id"] + assert dns_evidence["source_host_id"] or dns_evidence["target_host_id"] + assert dns_evidence["payload"]["name"] == "printer-40.example.test" + + stale_payload = _checkin_payload( + "agent-topology-evidence-001", + observed_at="2026-03-22T22:10:00Z", + ) + stale_payload["sequence_number"] = 2 + stale_payload["addresses"] = payload["addresses"] + stale_payload["topology_evidence"] = [] + stale_response = await _post_checkin(async_client, api_key, stale_payload) + assert stale_response.status_code == 200 + assert stale_response.json()["summary"]["observations_stale"] == 8 + + historical_map_response = await async_client.get( + "/api/network/map", + params=[ + ("observed_by_agent_id", str(agent_id)), + ("relationship_types", "dns_name"), + ("include_historical_evidence", "true"), + ], + headers=admin_headers, + ) + assert historical_map_response.status_code == 200 + historical_edges = [ + edge + for edge in historical_map_response.json()["elements"]["edges"] + if edge["data"].get("relationship_type") == "dns_name" + ] + assert historical_edges + assert historical_edges[0]["data"]["is_current"] is False + assert historical_edges[0]["data"]["stale_at"] is not None + assert historical_edges[0]["data"]["topology_evidence"][0]["is_current"] is False + assert historical_edges[0]["data"]["topology_evidence"][0]["stale_at"] is not None @pytest.mark.asyncio async def test_on_demand_collection_request_is_polled_and_fulfilled( diff --git a/docs/agents.md b/docs/agents.md index 8b0a077..1df898c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -114,6 +114,8 @@ On-demand requests can include `passive_capture` options with `enabled`, `durati The passive tcpdump parser is map-focused. It extracts VLAN IDs, LLDP/CDP metadata, DHCPv4/DHCPv6 lease and option hints, IPv6 router-advertisement prefixes and DNS options, DNS/mDNS/LLMNR/NBNS names, SSDP and WS-Discovery service labels, STP/LACP L2 hints, HSRP/VRRP/CARP gateway hints, visible OSPF/RIP/EIGRP/BGP routing hints, and aggregated optional flow headers. It does not parse TLS SNI, HTTP Host, QUIC SNI, Kerberos, LDAP, or SMB names. +Network Map evidence can be inspected as current-only by default or with historical/stale evidence included. Selecting evidence-backed map elements shows the source, observer, confidence, current/stale state, timestamps, and map summary. Operators can promote selected DNS evidence to a hostname, attach a selected host to an existing device identity, mark a selected network segment as expected, or ignore a noisy evidence source/observer for the current map view without deleting stored evidence. + ## Report Model The ingest endpoint expects a normalized JSON payload. The current host-side runtime converts local command output into that schema and sends gzip-compressed reports. diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md index 0bb0d87..0291707 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi Versioning policy: do not use `Unreleased` changelog sections. Every behavior change, bug fix, hardening change, or notable test addition must be recorded under a concrete SemVer version. Bump patch versions for bug fixes and minor versions for new behavior or API/UI changes. +## 0.18.0 - 2026-06-12 +### Added +- **Topology evidence promotion actions**: selected evidence can now promote DNS names to hostnames, attach hosts to existing device identities, mark selected segments as expected network groups, and ignore noisy sources or observers for the current map view. +- **Stale evidence visibility**: Network Map filters now include an opt-in historical evidence toggle, and selected evidence displays current/stale status plus stale/removed timestamps. + ## 0.17.0 - 2026-06-11 ### Added - **Topology evidence layers**: Network Map filters now include Physical/L2, Routes/Gateways, DHCP identity, DNS names, Flow relationships, Agent observer topology, and Manual/saved network group layer controls. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ffec410..cb570ce 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "grapheon-frontend", - "version": "0.17.0", + "version": "0.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "grapheon-frontend", - "version": "0.17.0", + "version": "0.18.0", "dependencies": { "@isoflow/isopacks": "^0.0.10", "cytoscape": "^3.33.1", diff --git a/frontend/package.json b/frontend/package.json index 3c81ab0..bb8a673 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "grapheon-frontend", - "version": "0.17.0", + "version": "0.18.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/CytoscapeNetworkMap.jsx b/frontend/src/components/CytoscapeNetworkMap.jsx index 9f01494..bd760b4 100644 --- a/frontend/src/components/CytoscapeNetworkMap.jsx +++ b/frontend/src/components/CytoscapeNetworkMap.jsx @@ -30,6 +30,12 @@ export default function CytoscapeNetworkMap({ onNodeClick, onCyReady, loading = false, + deviceIdentities = [], + onUseHostname, + onAttachToDevice, + onIgnoreEvidenceSource, + onIgnoreObserver, + onMarkExpected, }) { const containerRef = useRef(null) const cyRef = useRef(null) @@ -42,6 +48,7 @@ export default function CytoscapeNetworkMap({ const [showLegend, setShowLegend] = useState(true) const [isFullscreen, setIsFullscreen] = useState(false) const [showExportMenu, setShowExportMenu] = useState(false) + const [selectedDeviceId, setSelectedDeviceId] = useState('') const exportMenuRef = useRef(null) // Watch for theme changes @@ -98,6 +105,7 @@ export default function CytoscapeNetworkMap({ // Clear selectedNode when elements change useEffect(() => { setSelectedElement(null) + setSelectedDeviceId('') }, [elements]) // ── Initialize Cytoscape ────────────────────────────────────── @@ -273,6 +281,33 @@ export default function CytoscapeNetworkMap({ const hasElements = (elements.nodes || []).length > 0 const selectedData = selectedElement?.data const selectedEvidence = selectedData?.topology_evidence || [] + const primaryEvidence = selectedEvidence[0] || null + const selectedHostId = (() => { + if (!selectedData) return null + if (selectedElement?.kind === 'node' && selectedData.type === 'host' && String(selectedData.id || '').match(/^\d+$/)) { + return Number(selectedData.id) + } + const evidenceHostId = primaryEvidence?.source_host_id || primaryEvidence?.target_host_id + if (evidenceHostId) return Number(evidenceHostId) + if (selectedElement?.kind === 'edge') { + if (String(selectedData.source || '').match(/^\d+$/)) return Number(selectedData.source) + if (String(selectedData.target || '').match(/^\d+$/)) return Number(selectedData.target) + } + return null + })() + const hostnameCandidate = (() => { + const payload = primaryEvidence?.payload || {} + if ((primaryEvidence?.evidence_type || selectedData?.relationship_type) !== 'dns_name') return '' + return payload.name || payload.hostname || payload.fqdn || '' + })() + const expectedNetworkCandidate = (() => { + const payload = primaryEvidence?.payload || {} + return payload.network || selectedData?.subnet_cidr || selectedData?.network || '' + })() + const expectedLabelCandidate = (() => { + const payload = primaryEvidence?.payload || {} + return payload.label || selectedData?.label || expectedNetworkCandidate + })() return (
@@ -501,14 +536,32 @@ export default function CytoscapeNetworkMap({

Topology Evidence

{selectedEvidence.slice(0, 4).map((evidence, idx) => ( -
-

{evidence.evidence_type || 'evidence'}

+
+
+

{evidence.evidence_type || 'evidence'}

+ + {evidence.is_current === false ? 'Stale' : 'Current'} + +
{evidence.summary &&

{evidence.summary}

}

Source: {evidence.source || 'agent'}

{evidence.observer &&

Observer: {evidence.observer}

}

Confidence: {evidence.confidence ?? selectedData.confidence ?? 0}%

{evidence.first_seen &&

First: {new Date(evidence.first_seen).toLocaleString()}

} {evidence.last_seen &&

Last: {new Date(evidence.last_seen).toLocaleString()}

} + {evidence.stale_at &&

Stale: {new Date(evidence.stale_at).toLocaleString()}

} + {evidence.removed_at &&

Removed: {new Date(evidence.removed_at).toLocaleString()}

} {evidence.raw_ref &&

Raw: {evidence.raw_ref}

}
))} @@ -516,6 +569,78 @@ export default function CytoscapeNetworkMap({
)}
+ {(hostnameCandidate || expectedNetworkCandidate || selectedHostId || primaryEvidence?.source || primaryEvidence?.observer_agent_id) && ( +
+

Actions

+
+ {selectedHostId && hostnameCandidate && onUseHostname && ( + + )} + {selectedHostId && onAttachToDevice && ( +
+ + +
+ )} + {expectedNetworkCandidate && onMarkExpected && ( + + )} +
+ {primaryEvidence?.source && onIgnoreEvidenceSource && ( + + )} + {primaryEvidence?.observer_agent_id && onIgnoreObserver && ( + + )} +
+
+
+ )} {selectedElement.kind === 'node' && selectedData.id && !String(selectedData.id).includes('_') && (
+ {(ignoredEvidenceSources.length > 0 || ignoredObserverAgentIds.length > 0) && ( +
+ Ignored + {ignoredEvidenceSources.map(source => ( + + ))} + {ignoredObserverAgentIds.map(agentId => ( + + ))} +
+ )}

Topology Evidence Layers

@@ -916,6 +1071,12 @@ export default function Map() {
)} + {mapActionMessage && ( +
+ {mapActionMessage} +
+ )} + {/* Inline warnings for secondary fetch failures */} {warnings.length > 0 && (
@@ -994,6 +1155,12 @@ export default function Map() { onNodeClick={handleNodeClick} onCyReady={handleCyReady} loading={loading} + deviceIdentities={deviceIdentities} + onUseHostname={handleUseHostname} + onAttachToDevice={handleAttachToDevice} + onIgnoreEvidenceSource={handleIgnoreEvidenceSource} + onIgnoreObserver={handleIgnoreObserver} + onMarkExpected={handleMarkExpected} /> ) : (