Skip to content

feat: east-west & egress identity-aware microsegmentation (service graph, dns.fqdn, pod labels)#420

Merged
pigri merged 35 commits into
mainfrom
feat/east-west-identity-microseg
Jul 3, 2026
Merged

feat: east-west & egress identity-aware microsegmentation (service graph, dns.fqdn, pod labels)#420
pigri merged 35 commits into
mainfrom
feat/east-west-identity-microseg

Conversation

@pigri

@pigri pigri commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

East-west & egress visibility + identity-aware microsegmentation for the agent. A single smart-firewall rule can now match on destination domain, source/destination workload identity (incl. pod-label selectors), and behavioural signals, and deny per-edge in-kernel. Works on Kubernetes (overlay CNI) and on a plain host/VM (same eBPF, different attach interface).

Depends on the published amygdala 0.1.9 + hippocampus 0.0.3 (gen0sec registry).

Service graph — east-west & north-south visibility

  • tcx overlay observer on the CNI/overlay netdev (decapsulated pod-to-pod), attached BPF_TCX_FIRST as a pure observer (TC_ACT_UNSPEC).
  • XDP-level JA4TS capture (overlay/Cilium-safe), sourced from the ingress SYN-ACK; established-flow sampling, not just SYN.
  • Egress tap on the primary NIC for source→external north-south flows the overlay never carries.
  • Passive service detection via nmap-service-probes (per-port), plus an optional active prober for long-lived servers.
  • ServiceGraphWorker assembles the agent's observed slice (edges + node fingerprints) and uploads it.

Workload-identity microsegmentation

  • Identity read path via hippocampus (IP → namespace / workload / labels), delivered as an MMDB baseline + incremental delta stream (DashMap client, apply_delta / load_baseline, epoch/seq resync).
  • Rule fields identity.k8s.src_namespace / src_workload and pod-label selectors identity.k8s.{src,dst}_label["key"].
  • Per-edge enforcement via a banned_edges BPF map (TC_ACT_SHOT, TTL'd) — observer-first, drops opt-in per (src → dst).

Egress-by-domain

  • dns.fqdn rules: snoop DNS responses (UDP/53) into an IP→domain store, match connections by resolved IP. Protocol-agnostic (immune to ECH, unlike SNI).

Rule actions

  • The east-west eval site honours all three amygdala actions: drop installs the edge-drop, log records an observation without enforcing, allow is an explicit whitelist exception — each emits one block-log audit record. Mirrors the north-south kernel_pump path.

Config

  • cni_* → observe_* rename (observe_interfaces / observe_egress_interfaces / observe_mode), now interface lists — the observer attaches to any netdev (overlay on k8s, eth0 on a VM), not Kubernetes-specific.

Deps

  • amygdala 0.1.9 (identity.k8s.* rename, dns.fqdn, pod-label maps), hippocampus 0.0.3 (delta-stream client + IdentityInfo.labels), resolved from the registry.

Wellness: fmt --check, clippy --workspace --all-targets (default + classifier) --deny warnings, and cargo test --workspace --lib all pass.

pigri and others added 30 commits July 2, 2026 23:11
…ral + direction fields

Adds the agent half of the east-west / lateral-movement detection chain.

- Direction + behavior: populate ids.dst_home_net/dst_external_net,
  ids.src_pod_net/dst_pod_net and the flow.* behavioral group (unique_dst_ports,
  flows_per_min, dst_port_entropy, ...) at BOTH eval sites (kernel_pump XDP and
  the AF_PACKET fallback). POD_NETS/is_pod_ip distinguishes true pod-to-pod from
  node-to-node-over-public-IP. New BlockSource::Microseg/Behavioral.

- Workload identity: IdentityMmdbWorker pulls identity.mmdb on the threat-MMDB
  rails (push-aware via config SSE + interval-poll fallback); a security::identity
  lookup module resolves src/dst IP -> workload/namespace, surfaced as id.* fields.

- Declared-edge microsegmentation: security::edge_set consumes the policy-edges
  allow-list; edge.declared / edge.policy_violation evaluated per flow.

- Per-rule `log` smart-firewall action (records a notice, installs no kernel
  block) for staged alert-first rollout.

- Overlay VXLAN/Geneve decap in the IDS inspect path so pod-to-pod overlay
  frames decode.

Requires the matching amygdala scheme fields (ids.dst_*, ids.*_pod_net, flow.*,
id.*, edge.*, Action::Log). The amygdala dependency + Cargo.lock need bumping once
that publishes; build is blocked until then.

Claude-Session: https://claude.ai/code/session_01MAY62VuZHhkeXgQ9feF3fJ
…rary

The IdentityInfo/IdentityClient model + MMDB reader and the EdgeSet parser/
evaluator move to the hippocampus crate; synapse-core keeps the process-global
client singletons + capability registration as thin wrappers that re-export the
library types. Public API (lookup/evaluate/init_*/refresh_*/get_version_cache,
IdentityInfo/EdgeVerdict) is unchanged, so the workers and enrichment call sites
are untouched.

hippocampus is a dev path dependency for now; switch to the gen0sec registry
once it is published.
hippocampus is published, so synapse-core depends on it via the registry instead
of a local path. A commented [patch.gen0sec] entry keeps the local-path dev
override available, matching the amygdala/cortex pattern.
amygdala 0.1.6 publishes the id./edge./flow./ids. wirefilter enrichment fields
this branch depends on; synapse-app now builds against the registry crate.
Process-global, bounded, idle-TTL'd accumulator (mirrors flow_signals): records
observed src->dst:port edges (flow_count) and per-IP passive fingerprints
(server JA4TS, client JA4T, client software) on the capture path; drains the
slice for the ServiceGraphWorker to resolve + upload. Identity is resolved at
drain, never on the hot path.
…'s slice

Drains the observed buffer each tick, resolves IPs to workloads via the
identity MMDB (host/<ip> fallback on-prem), and PUTs two contributions to
service-graph-api: a declared slice (classification from identity + declared
edges folded from the policy-edges artifact) and an observed slice (this
agent's edges + passive fingerprints). Pure, tested projection; on-prem
degrades gracefully (no artifacts -> observed-only, host refs).

Links hippocampus with the graph feature (synapse-core Cargo.toml).
- Record observed east-west edges (both endpoints in HOME_NET) + passive
  fingerprints (client JA4T->src, server JA4TS->dst) at BOTH capture sites
  (kernel_pump XDP + AF_PACKET fallback), beside flow_signals::record.
- platform.service_graph upload config (enabled/url/interval_secs).
- Register the ServiceGraphWorker when configured (off in PCAP replay).
…ation)

0.0.1 lacked the graph feature + classification fields this worker needs. CI is
green once hippocampus 0.0.2 publishes (gen0sec/hippocampus#8); dev builds via
the local [patch] (uncommitted).
The agent owns only the observed layer and has no Kubernetes identity (it
must run on-prem), so the service-graph worker now drains its observed
edges + passive fingerprints and uploads a single `observed` slice keyed
by raw IP. service-graph-api resolves those IPs to workload refs
server-side against the operator's declared IP index (host/<ip> when
unresolved), so the agent no longer folds identity/policy-edges itself.

Drops the declared upload, identity-MMDB resolution and policy-edges fold
from the worker; ServiceGraphConfig loses policy_edges_path.
The SYN-ACK (ja4ts) collector emits ServerToClient events with the server
(this host) as src, so the previous src=client / dst=server mapping recorded
the server fingerprint against the client and gated it on dst_home — leaving
ja4ts empty in practice while client ja4t worked. Normalize each observation
to client->server by PacketDirection: ja4t -> client, ja4ts -> server, edge
client->server:server_port. Draw edges only from real flows (synthetic
fingerprint carriers have src_port 0). Applied at both the XDP (kernel_pump)
and AF_PACKET (lib.rs) capture sites.
On overlay clusters the egress SYN-ACK map is empty — the server's SYN-ACK
is handled by the CNI datapath and never traverses the physical-NIC TC
egress hook, so iter_egress() yields nothing while iter_egress_syn()
(host-originated SYNs) works. The same outbound connection's SYN-ACK does
arrive at ingress, so look the server's JA4TS up in the ingress SYN-ACK map
(keyed by the connection's dst ip/port) and attach it to the egress-SYN
event. That event is client->server, so the service graph attributes the
fingerprint to the server node.
The TC-based SynAckCollector sees no SYN-ACKs on overlay/Cilium clusters
(the CNI's TC datapath owns the reply path), so server JA4TS was always
empty there. XDP runs at the driver, before the CNI's TC programs, so
inbound SYN-ACKs are visible.

Add an `xdp_synack_capture` program (separate __noinline, observe-only, no
blocking) that extracts the SYN-ACK fingerprint and stores it in new
server-keyed simple maps (tcp_synack_simple{,_v6}). Implement the
previously-stubbed `lookup_synack_fingerprint`/`_force` to read those maps,
which also revives the proxy's upstream-JA4TS lookup. Wire the service
graph's server-fingerprint recording to fall back to this lookup, and
attribute it to the server node by flow direction.
The per-event JA4TS lookup only fires when a flow event coincides with a
captured SYN-ACK, so it misses periodic short-lived handshakes (the SYN-ACK
lands in the map a beat after the egress-SYN event is processed). Add
`drain_synack` (batch-iterate the server-keyed synack maps) and call it on a
throttled cadence (every 30s) from the kernel pump, recording JA4TS for every
internal server the node has handshaked with since startup — independent of
flow-event timing.
On an overlay (Cilium VXLAN) cluster the physical NIC only sees encapsulated
node-level traffic, so the observed graph is node-to-node. Add
`capture.cni_interface` — a passive AF_PACKET SYN-only tap on the CNI tunnel
netdev (e.g. cilium_vxlan), where decapsulated inner pod packets are visible.
It never attaches XDP/TC (can't clobber the CNI datapath) and runs alongside
the primary XDP capture; copies feed the same enrich pipeline so pod-to-pod
edges land in the service graph and resolve to pod workloads via the
operator's declared pod IPs. SYN-only keeps volume low; internal pod IPs are
HOME_NET so they're never banned.

Also fix the service-graph worker agent_id default to fall back to HOSTNAME
(not a shared "synapse" literal) so each agent owns a distinct OBSERVED_EDGE
slice instead of resync-clobbering the others.
Adds the production path for east-west pod-to-pod visibility on overlay
(Cilium VXLAN) clusters: a tc/sched_cls program (tcx_overlay.bpf.c) attached
via tcx ingress on the CNI tunnel netdev (e.g. cilium_vxlan) at BPF_TCX_FIRST
— ahead of the CNI's own tcx program, so it sees the decapsulated inner pod
packets first. Observer-only (always TC_ACT_UNSPEC), so it coexists with
Cilium's tcx programs and never touches the datapath.

It emits SYN/SYN-ACK connection-setup packets (raw TCP options included) over
a ringbuf; the userspace consumer records pod-to-pod edges + JA4T/JA4TS into
the service-graph buffer, resolved to pod workloads server-side via the
operator's declared pod IPs (pod CIDR is HOME_NET, so never banned).

`capture.cni_mode` selects the backend: `tcx` (default, this) or `af_packet`
(the portable passive tap). Only active when `capture.cni_interface` is set.
Wires hippocampus's scan matcher into the agent so graph nodes get a
service/product label, not just fingerprints. The tcx overlay program now also
captures each flow's FIRST payload packet (the banner) — deduped via an LRU map,
up to 256 bytes via bpf_skb_load_bytes — alongside the SYN/SYN-ACK connection
events, over the same ringbuf (kind discriminator). The userspace consumer runs
ProbeDb::identify(NULL/GetRequest/GenericLines, banner); a match labels the
sender (the server) with service + "product version" via record_node_service.
synapse-core links hippocampus with the scan feature (pcre2, vendored); the DB
(nmap-service-probes, NPSL/GPL) is loaded from /var/lib/synapse at startup and
is external data, never bundled in the crate.
A workload can listen on several ports (postgres:5432 + a metrics http port).
Key detected services by server port instead of collapsing to one, so they no
longer overwrite each other. The buffer keeps a port->service map; drain emits
the full per-port list plus the lowest-port primary for the node label. The
passive matcher and active prober pass the server port through; the observed
payload carries a services[] array per node.
The tcx tap recorded a flow only at SYN/SYN-ACK, so long-lived connections
whose handshake we never saw (DB streaming replication, pooled channels) were
invisible as edges. Add a kind=2 'established flow' event: any non-SYN packet
emits the 5-tuple, deduped per-flow to once per 60s window via a timestamp LRU
map (seen_flow). Userspace records the canonical client->server edge (lower
port = server), no fingerprint. Proven live: database/core-1/2 -> core-0
streaming replication and the kafka inter-broker mesh now surface.
The overlay tap (cilium_vxlan) only sees east-west; pod egress to the internet
(e.g. download-api -> S3) goes out the node's primary NIC, masqueraded, so it
was invisible. Add a second tcx EGRESS program on the primary NIC at
BPF_TCX_FIRST (before Cilium's BPF masquerade, so the pod IP is intact),
filtered to pod-CIDR sources. Same event stream; the edge gate is relaxed to
require only the client to be a pod, so external destinations are recorded.
Config: capture.cni_egress_interface (name | 'auto' = default-route dev).
External peers resolve to host/<ip> server-side and external/<reverse-dns> in
the exporter (teal nodes). Best-effort: egress attach failure doesn't disable
the east-west tap.
copy_raw_tcp_options{,_short} copied TCP options with a break-terminated,
variable-index byte loop. On stricter (newer-kernel) verifiers the index
becomes a runtime value and the verifier over-approximates the write to the
buffer end, rejecting the program load ("R3 min value is outside of the
allowed memory range" / "invalid write to stack") — which prevents the
XDP program from loading at all.

Replace the loop with a constant-size copy ladder: pick the largest fixed
block that fits within the packet bounds and __builtin_memcpy it, using a
constant length in each 'src + N <= data_end' check so the verifier can
prove the access. opt_total is always a multiple of 4, so out_len stays
exact. Mirrors the constant-size copy idiom already used for banner capture.
…in resilience

Agent-side east-west security work (consumes the hippocampus identity/edge
readers and the amygdala identity/edge scheme via workspace deps):

- Identity/edge delta-stream apply: identity::apply_delta / edge_set::apply_delta
  wrappers + config_push SSE arms (identity-delta / policy-edges-delta) with an
  epoch/seq gap -> baseline resync.
- Overlay-CNI identity microsegmentation: banned_edges BPF LRU_HASH + TC_ACT_SHOT
  drop in the tcx overlay; ban_edge/unban_edge; kernel_pump diverts a Drop verdict
  on a resolved pod-to-pod edge to an edge-drop (not an IP ban, since the pod IP
  is never-ban); the overlay consumer evaluates the observed edge so enforcement
  works on overlay CNIs where kernel_pump never sees the decapsulated flow.
- Verifier-safe TCP options copy in the tcx overlay (constant-index write) for the
  stricter newer-kernel verifier.
- Resilient BPF pinning: probe bpffs writability and load unpinned when pin
  creation is denied, instead of failing the whole skeleton load.
- TLS SNI bridge: sni_store carries the ClientHello SNI into the smart-firewall
  eval (amygdala tls.sni) for domain-based rules.
Snoop DNS responses on the overlay to map a connection's destination IP back
to the queried hostname, so smart-firewall rules can filter egress by domain:
`dns.fqdn matches "(^|\.)evil\.com$" -> block`, optionally scoped by workload
identity (`... and identity.k8s.src_namespace eq "..."`).

- tcx overlay: capture UDP/53 DNS *responses* (new kind=3 event) alongside the
  TCP conn/banner/flow events; a direct bounded constant-index copy of the
  payload (not a fixed-step load ladder, which truncates odd-sized answers).
- fqdn_store: a self-contained DNS response parser (header, question name with
  compression pointers, A/AAAA answers + TTL) feeding a TTL-bounded
  IP -> hostname map keyed by the resolved address.
- overlay consumer: parse each snooped response, record every A/AAAA answer.
- eval: kernel_pump + the overlay microseg eval populate amygdala's dns.fqdn
  extra from fqdn_store.lookup(dst_ip); the overlay eval now also fires for
  pod->external edges (identities optional) so a Drop bans the flow.
- tests/dns_fqdn_repro.rs: exercises the compile_and_install + evaluate path.

Reliable regardless of the app TLS library (unlike SNI), matches before any
handshake. Requires the tcx egress tap; the DNS snoop is cross-node (same-node
pod<->CoreDNS bypasses the overlay). Depends on amygdala dns.fqdn.
Populate amygdala's src/dst label map fields from IdentityInfo.labels in the
overlay microseg eval, so NetworkPolicy-style label selectors work:
`identity.k8s.src_label["role"] eq "database"`, incl. fused with dns.fqdn /
namespace. The identity delta-wire parse carries the labels map through to the
in-memory overlay. Depends on hippocampus IdentityInfo.labels + amygdala 0.1.9.
… interfaces

The east-west/DNS observer attaches to any netdev (overlay on k8s, primary NIC
on a plain host/VM) — the cni_ naming was misleadingly Kubernetes-specific.
Replace (pre-release, no back-compat aliases):
  cni_interface        -> observe_interfaces        (now a list)
  cni_egress_interface -> observe_egress_interfaces  (now a list)
  cni_mode             -> observe_mode
start_overlay_tcx takes interface slices and loops the tcx attach (ingress +
egress) into one ringbuf, so a multi-homed host or multiple overlays are all
observed. Requires the deployed config to use the new field names.
The overlay/east-west eval site (dns.fqdn + identity.k8s.* rules) previously
acted only on Drop, so `log` rules were a silent no-op and `allow` rules were
an unaudited whitelist. Mirror the north-south kernel_pump path: match all three
amygdala actions — drop installs the kernel edge-drop, log records an
observation without enforcing, allow is an explicit whitelist exception — and
emit one block-log audit record (Block / Notice / Allow) per matched edge.

The action already flows end to end from remote config: WirefilterRule.action
-> reload tuple -> compile_and_install -> amygdala Action::{Allow,Log,Drop}.
pigri added 3 commits July 2, 2026 23:11
inspect_packet takes the full 9-input per-packet inspection context and its
trace path uses serde_json::json! (which expands to internal unwrap()s). Both
trip clippy only under the bpf feature, so -p synapse-idp missed them but the
--workspace wellness gate does not. Add the modules existing
#[allow(clippy::too_many_arguments, clippy::disallowed_methods)] convention.
Both crates are now published to the gen0sec registry with the east-west surface:
- amygdala 0.1.9: identity.k8s.* rename, dns.fqdn, identity.k8s.{src,dst}_label maps
- hippocampus 0.0.3: delta-stream identity/edge client + IdentityInfo.labels

Bump the version requirements to the published versions and resolve them from the
registry; the dev-only [patch.gen0sec] path overrides stay commented out.
The rebase conflict resolution kept main's classifier gate (is_enabled +
is_protected_ip HOME_NET skip) but the merged body calls the shared
classify_and_record_traffic helper, so the cortex inference/TrafficContext
imports were unused under the classifier feature.
pigri added 2 commits July 2, 2026 23:48
… docs

CI on non-default targets caught three gating gaps in the east-west code
(local wellness runs linux + bpf, so they were invisible):

- firewall_noop.rs (the non-BPF firewall module selected when bpf is off)
  lacked overlay_capture, so kernel_pumps AF_PACKET path and the microseg
  eval site failed to resolve firewall::overlay_capture::ban_edge on the
  legacy/musl build. Add no-op ban_edge/unban_edge stubs mirroring the real
  modules not(bpf) path.
- last_synack_drain is read only under linux+bpf (the XDP synack drain), so it
  tripped dead_code on Windows. Gate the lint with cfg_attr.
- overlay_capture doc used [`ban_edge`]/[`unban_edge`] intra-doc links that
  did not resolve under cargo doc -D warnings; use plain code spans.
ServiceGraphWorker lives in another crate, so the intra-doc link was unresolved
under cargo doc --workspace --no-deps -D warnings. Use a plain code span.
Verified the full workspace doc build is clean this time (not just one crate).
@pigri pigri merged commit 62adeec into main Jul 3, 2026
39 of 41 checks passed
@pigri pigri deleted the feat/east-west-identity-microseg branch July 3, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant