Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion agent/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.16.0
0.17.0
27 changes: 21 additions & 6 deletions agent/grapheon_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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(
{
Expand Down
58 changes: 57 additions & 1 deletion agent/tests/test_grapheon_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"),
Expand All @@ -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")
Expand All @@ -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,
)

Expand All @@ -573,19 +612,28 @@ 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"
and item["metadata"]["software_version"] == "IOS-XE"
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"<Envelope><ProbeMatch><a:Address>urn:uuid:printer-1</a:Address><d:Types>dn:Printer</d:Types><d:XAddrs>http://10.0.0.30/wsd</d:XAddrs></ProbeMatch></Envelope>")),
Expand All @@ -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)
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions backend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion backend/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.18.0
0.19.0
89 changes: 59 additions & 30 deletions backend/network/edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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": {
Expand All @@ -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,
}
Expand Down Expand Up @@ -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),
}
}
Expand Down
42 changes: 42 additions & 0 deletions backend/tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading