Skip to content

feat(observe): resolve connection peer IPs to cluster service/pod names (informer-backed, zero-egress)#131

Merged
thejefflarson merged 1 commit into
mainfrom
feat/resolve-connection-peers
Jun 30, 2026
Merged

feat(observe): resolve connection peer IPs to cluster service/pod names (informer-backed, zero-egress)#131
thejefflarson merged 1 commit into
mainfrom
feat/resolve-connection-peers

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Problem

Behavior::NetworkConnection { peer, internet } carries a raw IP:port peer, and Behavior::summary() renders connects to {peer}. In the dashboard and the adjudicator prompt that reads as connects to 10.42.1.159:8086 — opaque. The operator (and the model) can't see what a pod connects to.

Approach — an informer-backed IP index, NOT reverse DNS

We build an in-memory IpIndex { ip -> (namespace, kind, name) } from the Pod/Service objects the engine's reflector stores already watch (status.podIP on a Pod, spec.clusterIP/clusterIPs on a Service). Resolution is then a pure hashmap probe — zero network calls on the hot path.

We deliberately do not do DNS/PTR lookups: cluster pod IPs (10.42.x.x) aren't in external DNS, and any outbound lookup would violate the zero-egress invariant (the security graph and evidence never leave the cluster — CLAUDE.md / docs/adr). The index is a snapshot read; no blocking, no IO.

The index and the resolution live in the engine (which has cluster access), never the shared behavior crate (which has none) — the wire type stays pure data.

Where resolution happens

In RuntimeAdapter::contribute (engine/src/engine/observe/adapter/enrich.rs) — the point where behaviors enter evidence and get attached to a Workload's runtime signals. Enriching the peer there means the resolved form flows through Behavior::summary() to both the adjudicator prompt and the dashboard's evidence display with no change to either rendering site. No dashboard/findings-view edits.

connects to analytics/influxdb:8086 (10.42.1.159)

Behavior rules

  • Same-cluster pod IP -> ns/pod-name:port (raw-ip).
  • Service ClusterIP -> ns/service-name:port (raw-ip).
  • internet: true peers -> kept raw (external egress, not resolved).
  • Unknown/unresolvable IP -> left exactly as the raw IP:port (never fabricate a name).
  • Raw IP kept in parens for forensics; original port preserved. Handles IPv4 and bracketed IPv6.
  • Headless Service ClusterIP ("None") is not indexed; a Pod wins any (pathological) IP collision with a Service.

The peer remains untrusted-adjacent (resolved names derive from cluster object names), and the prompt's existing fence/sanitize/budget treatment is untouched — it sanitizes the resolved string exactly as before.

Tests

  • observe::ip_index::tests (11): pod IP -> ns/name; service ClusterIP -> ns/name; unknown IP stays raw; internet peer stays raw; non-IP:port untouched; IPv6; headless None not indexed; pod-wins-collision; index built from a fake set of Pod/Service objects (pure lookup).
  • enrich::tests::runtime_adapter_resolves_cluster_connection_peers_to_names: end-to-end through the full adapter pipeline — pod + service peers resolved, unknown IP raw, internet peer raw.

Gates (from engine/)

  • cargo fmt — clean
  • cargo build — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo test --workspace — 357 lib tests + integration suites pass, 0 failed

Zero-egress invariant intact: no new outbound calls (pure in-memory lookup). No source file exceeds the 1,000-line cap.

🤖 Generated with Claude Code

…es (informer-backed, zero-egress)

A NetworkConnection behavior carries a raw IP:port peer; in the dashboard
and the adjudicator prompt that reads as e.g. `connects to 10.42.1.159:8086`
— opaque. Resolve same-cluster peer IPs to the workload/service they belong
to: `connects to analytics/influxdb:8086 (10.42.1.159)`.

Resolution is a pure in-memory lookup against an IP->object index built from
the Pod/Service objects the engine's reflector stores already watch
(status.podIP, spec.clusterIP) — NOT reverse DNS: cluster pod IPs aren't in
external DNS and a PTR lookup would leave the cluster, violating zero-egress.

The index (`observe::ip_index::IpIndex`) and resolution live in the engine,
never the shared `behavior` crate (which has no cluster access — the wire
type stays pure data). Enrichment happens in `RuntimeAdapter::contribute`,
the point where behaviors enter evidence, so the resolved peer flows through
`Behavior::summary()` to BOTH the prompt and the dashboard unchanged — no
edits to either rendering site.

Rules: pod IP -> ns/pod-name; service ClusterIP -> ns/name; internet peers
kept raw (egress, not resolved); unknown/unresolvable IPs left exactly as the
raw IP:port (never fabricate a name); raw IP kept in parens for forensics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson thejefflarson merged commit 9eb3e77 into main Jun 30, 2026
3 checks passed
@thejefflarson thejefflarson deleted the feat/resolve-connection-peers branch June 30, 2026 03:36
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