MastertheMesh
Solo · Istio · ambient · ztunnel · AuthorizationPolicy · access logs · Gloo Operator · kind
Live · Runs on kind

Open ports vs used ports on ambient

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

A compliance audit asks a blunt question about least privilege: is every port a service is authorized to reach actually one it uses, and is anything else left open? This lab builds the audit that answers it, on Solo Istio ambient. Two services talk over ztunnel: the server exposes eleven ports, the client only ever calls six, yet the AuthorizationPolicy authorizes ten. Those four allowed-but-never-used ports are the over-authorization an auditor is looking for. An eleventh port is exposed by the Service but left out of the policy, so a probe to it is denied by ztunnel and logged, the deny path you also have to prove works. The audit switches ztunnel access logs to JSON, runs a collector on every node that merge-patches its own key in one central ConfigMap, and a CronJob writes report.json per service: what is configured, what is authorized, what is used, what is not, and what got denied. Then you act on it and shrink the policy until authorized equals used. One kind cluster, Solo Istio via the Gloo Operator.

Solo Istio 1.29.3-solo Gloo Operator ztunnel L4 authz JSON access logs DaemonSet → ConfigMap → CronJob kind

The story: services accumulate ports over time. A listener added for a new feature. A debug port that was handy in staging. A port some integration used before it was retired. Each one widens the AuthorizationPolicy in front of the service, and the policy almost never gets tightened back. The platform team runs ambient, mTLS is everywhere, an AuthorizationPolicy says svc-a may talk to svc-b, and still nobody can answer three basic questions: which ports are open between these services, which are actually carrying traffic, and which should be closed?

Ambient already produces every byte of data the audit needs. ztunnel writes an access log line for every connection it carries, with the destination port, the workload, the service and the caller's mTLS identity in it, and it enforces port-scoped L4 policy itself, no waypoint required. What is missing is only the plumbing: something that turns a stream of per-connection log lines into a durable, queryable answer. That plumbing is this lab, and it is deliberately boring: a DaemonSet, a ConfigMap and a CronJob.

How to use this guide. The sections before Run the lab are the model: what actually enforces a port in ambient, where the usage data comes from and why the report is shaped the way it is. The numbered green STEP blocks are the exact commands, top to bottom.
STEP — run this amber note — read only (FYI)

What actually enforces a port in ambient

Enrolling a namespace in ambient gives every pod an mTLS identity and puts every connection inside an HBONE tunnel. It does not restrict anything: ambient is default-allow, and any pod can reach any port on any other pod until a policy says otherwise. Port-level control is standard Istio AuthorizationPolicy, and the port granularity lives in spec.rules[].to[].operation.ports:

yamlyaml/20-policy/authz-svc-a-to-svc-b.yaml
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: allow-svc-a-to-svc-b
  namespace: port-audit
spec:
  selector:
    matchLabels:
      app: svc-b
  action: ALLOW
  rules:
    - from:
        - source:
            principals:
              - port-audit/ns/port-audit/sa/svc-a
      to:
        - operation:
            ports: ["8080", "8081", "8082", "8083", "8084",
                    "9090", "9091", "9092", "9093", "9094"]

Look closely at the principal: the trust domain is port-audit, not cluster.local. Whatever installs your mesh decides that trust domain, so always check what yours actually is. This lab installs the mesh with the Gloo Operator, where one ServiceMeshController CR renders istiod, istio-cni and ztunnel (the single object STEP 1 applies) and the operator sets the trust domain to the SMC's .spec.cluster, here port-audit, so every identity is spiffe://port-audit/ns/…. You do not need the operator for any of this. The audit is plain Kubernetes plus ztunnel behaviour and runs the same on any Solo Istio install, istioctl or Helm, where the trust domain might be cluster.local or a value you chose. Either way, write the principal with the wrong trust domain and it matches nothing, and because an ALLOW policy now selects the workload, svc-b goes default-deny for everything, including the traffic you meant to allow. The fastest diagnosis is the one this lab is built on: read src.identity in the ztunnel access log of a denied connection and compare it, character by character, with the principal in the policy.

Everything in that policy — source identity, destination ports — is L4, and ztunnel enforces it directly on the destination node. No waypoint is involved anywhere in this lab. The moment an ALLOW policy selects a workload, everything the policy does not match is denied, which is why port 7070 (exposed by the Service but absent from the list above) becomes a hard deny the instant this policy lands.

One consequence worth spelling out for anyone pointing a scanner at the nodes: between nodes the only port actually open on the wire is 15008, the HBONE tunnel. The application ports travel inside it. Network-level tooling (netflow, port scans) sees one encrypted port and goes blind; port-level visibility has to come from ztunnel itself. That is not a limitation of the audit, it is the reason the audit reads ztunnel's logs.

Where the usage data comes from

ztunnel writes an access log entry for every connection it opens and completes, on both the source and the destination node. With LOG_FORMAT=json set on the DaemonSet the entries are structured JSON, and the fields carry everything the audit needs:

FieldWhat it gives the audit
dst.addr / dst.hbone_addrthe destination pod IP and port — the number the whole lab is about
dst.service, dst.workload, dst.namespacewhich Service and which pod the port belongs to — the per-service and per-pod report rows
src.identitythe caller's SPIFFE identity, present when the connection is mesh mTLS — also how the collector skips kubelet probes
directioninbound vs outbound — the collector keeps inbound only, so a connection is counted once, on the node that owns the destination pod
errora denied connection completes with connection closed due to policy rejection — attempted-but-denied ports, for free

The Prometheus side (istio_tcp_connections_opened_total and friends) identifies which workload pairs talk and whether the traffic is mTLS, and it is the right tool for dashboards and alerting on flows. The port number itself lives in the access log fields above, which is why the collector reads logs rather than scraping metrics. For a live snapshot at the terminal there is also istioctl ztunnel-config connections (open connections per node) and istioctl ztunnel-config policies (the compiled port matchers ztunnel is enforcing).

The architecture

Two workers, one svc-b replica pinned to each by a topology spread constraint, so both node ztunnels have inbound connections to report and the per-node design has something real to show.

Per-node collectors, one central report node port-audit-worker svc-a calls 6 of the 11 ports svc-b (replica 1) 11 listeners ztunnel LOG_FORMAT=json · L4 authz port-audit-collector DaemonSet pod · streams the local logs node port-audit-worker2 svc-b (replica 2) 11 listeners ztunnel LOG_FORMAT=json · L4 authz port-audit-collector DaemonSet pod · streams the local logs HBONE :15008 JSON access logs JSON access logs ConfigMap port-audit-report port-audit-worker: {services, pods, denied} port-audit-worker2: {services, pods, denied} report.json: the aggregated diff merge patch own key merge patch own key Service ports + AuthorizationPolicy the configured surface aggregator (CronJob) merges keys, writes report.json

The port story the report has to catch, all on svc-b:

PortIn the ServiceIn the policysvc-a calls itReport verdict
8080 http-apiyesyesyesused
8081 inventoryyesyesyesused
8082 ordersyesyesyesused
9090 metricsyesyesyesused
9091 healthyesyesyesused
9092 eventsyesyesyesused
8083 adminyesyesnoauthz_allowed_never_used
8084 debugyesyesnoauthz_allowed_never_used
9093 profilingyesyesnoauthz_allowed_never_used
9094 legacy-syncyesyesnoauthz_allowed_never_used
7070 legacyyesnoprobed oncedenied_attempts

One ConfigMap, one key per node

The report lives in a single central ConfigMap, but no component ever writes the whole object. Each collector owns exactly one data key, named after its node, and updates it with a JSON merge patch:

httpthe write — a merge patch on one key (from collector.py)
PATCH /api/v1/namespaces/port-audit-system/configmaps/port-audit-report
Content-Type: application/merge-patch+json

{"data": {"port-audit-worker": "H4sIAF...=="}}   # gzip+base64 of {services, pods, denied}

That value is not raw JSON, it is gzip+base64 of the node's {services, pods, denied}. A whole ConfigMap is a single etcd object with a hard 1 MiB budget shared across every key (the API server rejects the object once the sum of its values passes 1,048,576 bytes), and port and pod sets compress heavily, 60× and more on a real fleet, so the collector packs each key and the aggregator unpacks it. base64 keeps the gzipped bytes a valid string so the key still lives in data and merge-patches like any other. The aggregator's report.json stays plain JSON on purpose, so a human, jq, or the bonus reporter agent can read the answer without unpacking anything.

Why this shape holds up with many concurrent writers:

The other half of the design is which connections a node records. Each collector reads only its local ztunnel (matched on spec.nodeName) and keeps only direction: inbound entries, so a node's key means "ports that received traffic on pods living on this node" and a cross-node connection is never double-counted from its client side. Entries without src.identity are dropped too, which keeps kubelet health probes out of the data: a port only kubelet talks to is not a port your services use.

Run the lab

STEP 1

Cluster, operator, ambient mesh, JSON logs

One script: kind (1 control-plane + 2 workers), Gateway API CRDs, the Solo Istio images pre-loaded, the Gloo Operator, a ServiceMeshController with dataplaneMode: Ambient, and LOG_FORMAT=json patched onto the ztunnel DaemonSet. The SMC has no env passthrough field, so the patch targets the rendered DaemonSet directly, the same pattern the lab uses to wire the license env onto istiod.

shterminal
export SECRETS_FILE=~/path/to/secrets.sh   # exports SOLO_ISTIO_LICENSE_KEY
./scripts/setup-cluster.sh

ends with Cluster ready. Solo Istio 1.29.3 in AMBIENT mode, JSON access logs on.

STEP 2

Deploy svc-a and svc-b, enrolled in ambient

The namespace carries istio.io/dataplane-mode: ambient, so every pod gets identity and L4 enforcement from its node's ztunnel with no sidecar and no restart. svc-b runs two replicas with a topology spread constraint that puts one on each worker.

shterminal
kubectl --context kind-port-audit apply -f yaml/10-app/
kubectl --context kind-port-audit -n port-audit get pods -o wide

svc-b pods on port-audit-worker AND port-audit-worker2, svc-a logging ok: 8080 8081 8082 9090 9091 9092 every 2s.

STEP 3

Apply the (deliberately over-provisioned) policy

Allows svc-a's identity to ten ports: 8080-8084 and 9090-9094. Not 7070. From this moment svc-b is default-deny for anything the rule does not match.

shterminal
kubectl --context kind-port-audit apply -f yaml/20-policy/

svc-a's loop keeps running untouched — its six ports are in the allow list.

STEP 4

Watch a real access log line

Pick either ztunnel and filter for inbound connections to the app namespace. This is the raw material the whole audit is built from.

shterminal
ZT=$(kubectl --context kind-port-audit -n istio-system get pods -l app=ztunnel -o name | head -1)
kubectl --context kind-port-audit -n istio-system logs "$ZT" --since=30s \
  | jq -c 'select(.scope == "access" and .direction == "inbound" and .["dst.namespace"] == "port-audit")' \
  | head -3
jsona real line (trimmed)
{
  "level": "info",
  "time": "2026-07-16T16:43:49.050106Z",
  "scope": "access",
  "message": "connection complete",
  "src.addr": "10.244.2.12:42920",
  "src.workload": "svc-a-64ff6cdff6-lhhv2",
  "src.namespace": "port-audit",
  "src.identity": "spiffe://port-audit/ns/port-audit/sa/svc-a",
  "dst.addr": "10.244.1.6:15008",
  "dst.hbone_addr": "10.244.1.6:8080",
  "dst.service": "svc-b.port-audit.svc.cluster.local",
  "dst.workload": "svc-b-6b5d6f9886-nk2h6",
  "dst.namespace": "port-audit",
  "dst.identity": "spiffe://port-audit/ns/port-audit/sa/default",
  "direction": "inbound",
  "bytes_sent": 205,
  "bytes_recv": 74,
  "duration": "0ms"
}

JSON entries with dst.service: svc-b.port-audit.svc.cluster.local and the port on dst.hbone_addr.

STEP 5

Probe the port the policy does not allow

7070 is in the Service, svc-b listens on it, and ztunnel refuses it: the TCP connection dies before a single application byte crosses, and the destination ztunnel logs the rejection with the caller's identity.

shterminal
kubectl --context kind-port-audit -n port-audit exec deploy/svc-a -- \
  curl -s --max-time 3 http://svc-b:7070/ ; echo "exit=$?"
jsonthe deny, in the destination ztunnel's log
{
  "level": "error",
  "time": "2026-07-16T16:43:43.794875Z",
  "scope": "access",
  "message": "connection complete",
  "src.addr": "10.244.2.12:42920",
  "src.workload": "svc-a-64ff6cdff6-lhhv2",
  "src.namespace": "port-audit",
  "src.identity": "spiffe://port-audit/ns/port-audit/sa/svc-a",
  "dst.addr": "10.244.1.6:15008",
  "dst.hbone_addr": "10.244.1.6:7070",
  "dst.service": "svc-b.port-audit.svc.cluster.local",
  "dst.workload": "svc-b-6b5d6f9886-nk2h6",
  "dst.namespace": "port-audit",
  "dst.identity": "spiffe://port-audit/ns/port-audit/sa/default",
  "direction": "inbound",
  "bytes_sent": 0,
  "bytes_recv": 0,
  "duration": "0ms",
  "error": "connection closed due to policy rejection: allow policies exist, but none allowed"
}

exit=52 or exit=56 (connection torn down), and an error level access log ending connection closed due to policy rejection: allow policies exist, but none allowed.

STEP 6

Deploy the audit stack

The report ConfigMap, tight RBAC (collectors may read ztunnel logs and patch exactly one ConfigMap; the aggregator may read Services and AuthorizationPolicies), the collector DaemonSet and the aggregator CronJob. The audit namespace is deliberately NOT enrolled in ambient, so its own API traffic never appears in the data it collects.

shterminal
./scripts/build-collector.sh    # docker build collector.py + kind load
kubectl --context kind-port-audit apply -f yaml/30-audit/
kubectl --context kind-port-audit -n port-audit-system get pods

Both are short enough to read in full, both are Python, and both ship in the same image, stdlib only (the Kubernetes API is plain HTTPS plus a ServiceAccount token, so there is no pip layer). The collector holds ONE follow=true log connection per node, so every access log line is handled exactly once, and it patches its key only when a port set actually changes, debounced, with a 60-second heartbeat. Its classify() function is the entire usage taxonomy: completed without error = used, policy rejection = denied, any other error = ignored, so a refused port stays unused. The aggregator is a one-shot roll-up the CronJob runs with the same image and a different command.

pycollector/collector.py — what each DaemonSet pod runs
#!/usr/bin/env python3
"""collector.py - one per node (DaemonSet).

Streams the LOCAL ztunnel's JSON access logs over a single follow=true log
connection and merge-patches this node's key in the shared port-audit-report
ConfigMap. Compared to the polling shell version this replaced, every log
line is handled exactly once (no overlapping --since windows), the API server
holds one long-lived connection instead of a request every 30 seconds, and
the ConfigMap is only written when a port set actually changes (plus a
heartbeat so the key's `updated` stamp stays live).

Stdlib only, on purpose: the Kubernetes API is plain HTTPS with a
ServiceAccount bearer token, so the image needs no pip installs.

Classification is the report's whole taxonomy, unchanged from the shell
version:
  completed without error          -> used
  error contains "policy rejection"-> denied
  any other error (e.g. refused)   -> ignored (stays in the unused column)
Entries without src.identity are dropped (kubelet probes are not usage).
"""

import base64
import gzip
import json
import os
import socket
import ssl
import time
import urllib.error
import urllib.parse
import urllib.request

API = "https://kubernetes.default.svc"
SA = "/var/run/secrets/kubernetes.io/serviceaccount"

NODE = os.environ["NODE_NAME"]
NS_APP = os.environ.get("NS_APP", "port-audit")
NS_AUDIT = os.environ.get("NS_AUDIT", "port-audit-system")
CM = os.environ.get("REPORT_CM", "port-audit-report")
ZT_NS = os.environ.get("ZTUNNEL_NAMESPACE", "istio-system")
# Debounce between change-driven patches, and the idle heartbeat interval.
DEBOUNCE = float(os.environ.get("PATCH_DEBOUNCE_SECONDS", "2"))
HEARTBEAT = int(os.environ.get("HEARTBEAT_SECONDS", "60"))

SSL_CTX = ssl.create_default_context(cafile=f"{SA}/ca.crt")


def log(msg):
    print(time.strftime("%H:%M:%S", time.gmtime()), msg, flush=True)


def request(method, path, body=None, content_type="application/json", timeout=15):
    # Re-read the token per request: projected SA tokens rotate.
    with open(f"{SA}/token") as f:
        token = f.read().strip()
    req = urllib.request.Request(API + path, data=body, method=method)
    req.add_header("Authorization", f"Bearer {token}")
    if body is not None:
        req.add_header("Content-Type", content_type)
    return urllib.request.urlopen(req, context=SSL_CTX, timeout=timeout)


def find_ztunnel():
    query = urllib.parse.urlencode({
        "labelSelector": "app=ztunnel",
        "fieldSelector": f"spec.nodeName={NODE},status.phase=Running",
    })
    with request("GET", f"/api/v1/namespaces/{ZT_NS}/pods?{query}") as resp:
        items = json.load(resp).get("items", [])
    return items[0]["metadata"]["name"] if items else None


def pack(state):
    """Serialise a node key as gzip+base64. One ConfigMap has a hard 1 MiB
    budget shared across ALL its keys (the API server rejects the whole object
    past 1,048,576 bytes), and port/pod sets compress heavily, so a compressed
    key keeps a large fleet under the cap. base64 makes the gzip bytes a valid
    UTF-8 string so it can live in `data` (not `binaryData`) and be merge-patched
    per key like before."""
    raw = json.dumps(state, sort_keys=True).encode()
    return base64.b64encode(gzip.compress(raw)).decode()


def unpack(value):
    """Inverse of pack(); tolerates a pre-compression plain-JSON key."""
    try:
        return json.loads(gzip.decompress(base64.b64decode(value)))
    except (ValueError, OSError):
        return json.loads(value)


def seed_state():
    # A restart keeps the history this node already reported.
    try:
        with request("GET", f"/api/v1/namespaces/{NS_AUDIT}/configmaps/{CM}") as resp:
            data = json.load(resp).get("data") or {}
        if NODE in data:
            return unpack(data[NODE])
    except (urllib.error.URLError, ValueError) as exc:
        log(f"seed skipped: {exc}")
    return {"node": NODE, "services": {}, "pods": {}, "denied": {}}


def patch_state(state):
    # A JSON merge patch that touches ONLY this node's key: applied
    # server-side and atomically, so concurrent writers on other nodes can
    # never be clobbered and no resourceVersion retry loop is needed. The value
    # is gzip+base64 (see pack()) to stay inside the ConfigMap's 1 MiB budget.
    state["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    body = json.dumps({"data": {NODE: pack(state)}}).encode()
    with request("PATCH", f"/api/v1/namespaces/{NS_AUDIT}/configmaps/{CM}",
                 body=body, content_type="application/merge-patch+json") as resp:
        resp.read()


def classify(entry):
    """Return ('used'|'denied', service, workload, port) or None."""
    if entry.get("scope") != "access" or entry.get("direction") != "inbound":
        return None
    if entry.get("dst.namespace") != NS_APP or not entry.get("src.identity"):
        return None
    addr = entry.get("dst.hbone_addr") or entry.get("dst.addr") or ""
    try:
        port = int(addr.rsplit(":", 1)[1])
    except (IndexError, ValueError):
        return None
    error = entry.get("error") or ""
    if "policy rejection" in error:
        kind = "denied"
    elif error:
        return None  # refused/reset etc: NOT used, NOT denied
    else:
        kind = "used"
    return kind, entry.get("dst.service") or "unattributed", \
        entry.get("dst.workload") or "unattributed", port


def record(state, kind, service, workload, port):
    """Union the observation into the state; return True if anything changed."""
    changed = False
    if kind == "denied":
        buckets = [(state["denied"], service)]
    else:
        buckets = [(state["services"], service), (state["pods"], workload)]
    for mapping, key in buckets:
        ports = mapping.setdefault(key, [])
        if port not in ports:
            ports.append(port)
            ports.sort()
            changed = True
    return changed


def stream(ztunnel, state):
    """Follow the ztunnel log stream; patch on change (debounced) and on
    heartbeat. The read timeout doubles as the heartbeat timer: a quiet
    stream raises timeout, we patch if needed, and the caller reconnects
    with a sinceSeconds overlap (sets make the overlap harmless)."""
    query = urllib.parse.urlencode({"follow": "true", "sinceSeconds": str(HEARTBEAT)})
    resp = request("GET", f"/api/v1/namespaces/{ZT_NS}/pods/{ztunnel}/log?{query}",
                   timeout=HEARTBEAT)
    log(f"streaming logs from {ztunnel}")
    last_patch = 0.0
    dirty = False
    with resp:
        for raw in resp:
            try:
                entry = json.loads(raw)
            except ValueError:
                continue
            if isinstance(entry, dict):
                hit = classify(entry)
                if hit and record(state, *hit):
                    dirty = True
            now = time.time()
            if (dirty and now - last_patch >= DEBOUNCE) or now - last_patch >= HEARTBEAT:
                patch_state(state)
                last_patch = now
                dirty = False
                log(f"patched {CM} key={NODE}")


def main():
    state = seed_state()
    patch_state(state)  # make the key exist (and stamp `updated`) immediately
    log(f"collector up on {NODE}")
    while True:
        try:
            ztunnel = find_ztunnel()
            if not ztunnel:
                log(f"no running ztunnel on {NODE} yet")
                time.sleep(5)
                continue
            stream(ztunnel, state)
            log("log stream ended (ztunnel rotated?), reconnecting")
        except (urllib.error.URLError, socket.timeout, TimeoutError, OSError) as exc:
            # Idle heartbeat lands here too: a quiet stream times out.
            try:
                patch_state(state)
            except (urllib.error.URLError, OSError) as patch_exc:
                log(f"heartbeat patch failed: {patch_exc}")
            log(f"stream interrupted ({exc.__class__.__name__}), reconnecting")
        time.sleep(2)


if __name__ == "__main__":
    main()
dockercollector/Dockerfile — the whole image
# The per-node collector. Stdlib-only Python (the Kubernetes API is plain
# HTTPS + a ServiceAccount bearer token), so there is no pip layer to build
# or patch. Runs as a non-root user.
FROM python:3.12-alpine
COPY collector.py /collector.py
USER 65532:65532
ENTRYPOINT ["python3", "-u", "/collector.py"]
pycollector/aggregate.py — what the CronJob runs
#!/usr/bin/env python3
"""aggregate.py - runs as a CronJob, once a minute.

Merges every node's observations from the report ConfigMap, reads the
CONFIGURED surface (Service ports + AuthorizationPolicy allowed ports), and
writes the diff to the report.json key of the same ConfigMap: per service,
what is exposed, what is allowed, what is actually used, what is not, and
what got denied.

Same image and same stdlib-only style as collector.py; the two scripts are
the whole audit.
"""

import base64
import gzip
import json
import os
import ssl
import time
import urllib.request

API = "https://kubernetes.default.svc"
SA = "/var/run/secrets/kubernetes.io/serviceaccount"

NS_APP = os.environ.get("NS_APP", "port-audit")
NS_AUDIT = os.environ.get("NS_AUDIT", "port-audit-system")
CM = os.environ.get("REPORT_CM", "port-audit-report")

SSL_CTX = ssl.create_default_context(cafile=f"{SA}/ca.crt")


def request(method, path, body=None, content_type="application/json"):
    with open(f"{SA}/token") as f:
        token = f.read().strip()
    req = urllib.request.Request(API + path, data=body, method=method)
    req.add_header("Authorization", f"Bearer {token}")
    if body is not None:
        req.add_header("Content-Type", content_type)
    with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
        return json.load(resp)


def unpack(value):
    """Node keys are written gzip+base64 by the collector (to stay inside the
    ConfigMap's 1 MiB budget); decode them here. Falls back to plain JSON."""
    try:
        return json.loads(gzip.decompress(base64.b64decode(value)))
    except (ValueError, OSError):
        return json.loads(value)


def union(maps):
    """Merge a list of {key: [ports]} maps into one, unioning the port lists."""
    merged = {}
    for mapping in maps:
        for key, ports in mapping.items():
            merged[key] = sorted(set(merged.get(key, [])) | set(ports))
    return merged


def allowed_ports(policies, app):
    """Ports named by ALLOW AuthorizationPolicies whose selector matches this
    app label (the labelling convention this lab uses throughout)."""
    ports = set()
    for policy in policies:
        spec = policy.get("spec", {})
        if spec.get("action", "ALLOW") != "ALLOW":
            continue
        selector = spec.get("selector", {}).get("matchLabels", {})
        if selector.get("app") != app:
            continue
        for rule in spec.get("rules", []):
            for to in rule.get("to", []):
                for port in to.get("operation", {}).get("ports", []):
                    ports.add(int(port))
    return sorted(ports)


def main():
    cm = request("GET", f"/api/v1/namespaces/{NS_AUDIT}/configmaps/{CM}")
    nodes = [unpack(value) for key, value in (cm.get("data") or {}).items()
             if key != "report.json"]
    services = request("GET", f"/api/v1/namespaces/{NS_APP}/services")["items"]
    policies = request(
        "GET", f"/apis/security.istio.io/v1/namespaces/{NS_APP}/authorizationpolicies"
    )["items"]

    observed = union(n.get("services", {}) for n in nodes)
    observed_pods = union(n.get("pods", {}) for n in nodes)
    denied = union(n.get("denied", {}) for n in nodes)

    rows = []
    for svc in services:
        name = svc["metadata"]["name"]
        fqdn = f"{name}.{svc['metadata']['namespace']}.svc.cluster.local"
        configured = sorted(p["port"] for p in svc["spec"].get("ports", []))
        allowed = allowed_ports(policies, svc["spec"].get("selector", {}).get("app"))
        used = observed.get(fqdn, [])
        rows.append({
            "service": name,
            "configured_service_ports": configured,
            "authz_allowed_ports": allowed,
            "used_ports": used,
            "unused_ports": [p for p in configured if p not in used],
            "authz_allowed_never_used": [p for p in allowed if p not in used],
            "denied_attempts": denied.get(fqdn, []),
            "over_provisioned": any(p not in used for p in configured),
        })

    report = {
        "generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "nodes_reporting": [n["node"] for n in nodes if "node" in n],
        "services": rows,
        "pods": observed_pods,
    }
    body = json.dumps({"data": {"report.json": json.dumps(report, indent=2)}}).encode()
    request("PATCH", f"/api/v1/namespaces/{NS_AUDIT}/configmaps/{CM}",
            body=body, content_type="application/merge-patch+json")
    print("report.json updated:")
    print(json.dumps(report, indent=2))


if __name__ == "__main__":
    main()

one collector pod per node; a newly used port lands in the node key within ~2s of the connection completing, and the CronJob rebuilds report.json every minute.

STEP 7

Read a node's raw key

Each key is that node's own view: the ports that received mesh traffic on pods scheduled there. The value is written gzip+base64 (a whole ConfigMap has one 1 MiB budget across all its keys, and port sets compress heavily), so decode it to read it:

shterminal
kubectl --context kind-port-audit -n port-audit-system get cm port-audit-report -o json \
  | jq -r '.data["port-audit-worker"]' | base64 -d | gunzip | jq .
jsona node key
{
  "node": "port-audit-worker2",
  "services": {
    "svc-b.port-audit.svc.cluster.local": [8080, 8081, 8082, 9090, 9091, 9092]
  },
  "pods": {
    "svc-b-68b48c6658-59qkv": [8080, 8081, 8082, 9090, 9091, 9092]
  },
  "denied": {
    "svc-b.port-audit.svc.cluster.local": [7070]
  },
  "updated": "2026-07-16T17:05:33Z"
}

each worker's key lists the six used ports against its local replica; the denied probe appears under denied on the node that hosts the replica that took it.

STEP 8

Read the report

The aggregator has merged the node keys and diffed them against the configured surface. This is the artefact you would hand to the service owners.

shterminal
kubectl --context kind-port-audit -n port-audit-system get cm port-audit-report \
  -o jsonpath='{.data.report\.json}' | jq .
jsonreport.json — the actual output
{
  "generated": "2026-07-16T17:05:00Z",
  "nodes_reporting": ["port-audit-worker", "port-audit-worker2"],
  "services": [
    {
      "service": "svc-b",
      "configured_service_ports": [7070, 8080, 8081, 8082, 8083, 8084,
                                   9090, 9091, 9092, 9093, 9094],
      "authz_allowed_ports":      [8080, 8081, 8082, 8083, 8084,
                                   9090, 9091, 9092, 9093, 9094],
      "used_ports":               [8080, 8081, 8082, 9090, 9091, 9092],
      "unused_ports":             [7070, 8083, 8084, 9093, 9094],
      "authz_allowed_never_used": [8083, 8084, 9093, 9094],
      "denied_attempts":          [7070],
      "over_provisioned": true
    }
  ],
  "pods": {
    "svc-b-68b48c6658-wwfhd": [8080, 8081, 8082, 9090, 9091, 9092],
    "svc-b-68b48c6658-59qkv": [8080, 8081, 8082, 9090, 9091, 9092]
  }
}

six ports under used_ports, four under authz_allowed_never_used (8083, 8084, 9093, 9094), denied_attempts: [7070], both workers under nodes_reporting.

STEP 9

Act on it: shrink the policy to what is real

The report said 8083, 8084, 9093 and 9094 are allowed and never used, so they come out. The allowed set now equals the observed set, and any future caller of those ports shows up in denied_attempts — either a legitimate new consumer you then admit deliberately, or lateral movement that now goes nowhere.

shterminal
kubectl --context kind-port-audit apply -f yaml/40-remediate/
# prove it: 8083 was allowed a minute ago, now it is not
kubectl --context kind-port-audit -n port-audit exec deploy/svc-a -- \
  curl -s --max-time 3 http://svc-b:8083/ ; echo "exit=$?"

the 8083 probe fails, svc-a's real traffic never blips, and the next report cycle shows the tightened ports in denied_attempts if anything keeps trying. The Service's unused port entries are the follow-up clean-up, owned by the service team.

The whole sequence, standup to assertions, is also automated: SECRETS_FILE=… ./scripts/e2e.sh runs everything above with a five-minute traffic soak (override with SOAK=seconds) and fails loudly unless the report says exactly what STEP 8 shows — six ports used, four allowed-but-silent, one denied.

Bonus: an agent publishes the report to git

Just for fun, and to show off what Solo's agentic stack can do, this lab optionally deploys a declarative AI agent on kagent. The agent calls a RemoteMCPServer wired to GitHub's MCP server, reads report.json, and turns that structured data into clean, human-readable markdown committed straight to a GitHub repo, prettier than a ConfigMap and only when the findings have actually changed. No report generator, no CI job, no git plumbing in the collector, just a declarative agent with two tool sets. It runs on OSS kagent and is entirely optional.

A declarative agent publishes the report to git kind cluster · port-audit ConfigMap port-audit-report report.json kagent Agent port-audit-reporter Declarative · claude-sonnet-4-5 renders JSON to markdown, commits only on a diff GitHub MCP hosted · api.githubcopilot.com RemoteMCPServer GitHub repo port-audit- report.md Secret github-mcp-pat PAT, Contents: read+write k8s_get_ resources get / put commit Authorization: Bearer (headersFrom) get_file_contents · create_or_update_file

The GitHub MCP server is the auth answer

How does an agent get a token to push to GitHub? Through the GitHub MCP server. We use GitHub's hosted MCP server at api.githubcopilot.com/mcp/, the local github-mcp-server binary only speaks stdio and kagent reaches MCP over HTTP. A Personal Access Token with Contents: read+write on the repo is injected as the Authorization: Bearer header via RemoteMCPServer.headersFrom, read from the github-mcp-pat Secret. The token is created from an env var at setup time and never lives in a manifest. toolNames narrows the 44 tools the server exposes down to the two this agent may touch.

yamlyaml/50-agent/20-github-remotemcpserver.yaml
apiVersion: kagent.dev/v1alpha2
kind: RemoteMCPServer
metadata:
  name: github-mcp
  namespace: kagent
spec:
  description: GitHub, hosted MCP server. File read/write on the report repo.
  url: https://api.githubcopilot.com/mcp/
  protocol: STREAMABLE_HTTP
  timeout: 30s
  headersFrom:
    - name: Authorization
      valueFrom:
        type: Secret
        name: github-mcp-pat        # value is "Bearer <pat>", made at setup time
        key: authorization

The agent is just a system message and two tools

No code, no image. type: Declarative means the behaviour is the system message plus the tools: k8s_get_resources from the built-in kagent-tool-server to read the ConfigMap, and get_file_contents + create_or_update_file from the GitHub MCP to read the current file and write the new one. The target repo, branch and path come from the chat request, so nothing about a specific repo is baked into the manifest.

yamlyaml/50-agent/30-agent.yaml (abridged)
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
  name: port-audit-reporter
  namespace: kagent
spec:
  type: Declarative
  declarative:
    modelConfig: port-audit-reporter-model
    systemMessage: |
      Read the port-audit-report ConfigMap in port-audit-system, parse its
      report.json, and render it to a fixed markdown layout (a table per
      service). Then get_file_contents for the target path; commit with
      create_or_update_file ONLY if the tables or recommendation differ,
      ignoring the Generated: timestamp. Otherwise report "no change".
      # full prompt in the repo
    tools:
      - type: McpServer
        mcpServer:
          apiGroup: kagent.dev
          kind: RemoteMCPServer
          name: kagent-tool-server
          toolNames: [k8s_get_resources]
      - type: McpServer
        mcpServer:
          apiGroup: kagent.dev
          kind: RemoteMCPServer
          name: github-mcp
          toolNames: [get_file_contents, create_or_update_file]

On this build (kagent 0.9.4 with google-adk 2.4.0) the reporter agent runs on claude-sonnet-4-5, the proven pairing and plenty for read, render and diff. Set the model in the ModelConfig and every declarative agent that references it picks it up.

SETUP

Install kagent and deploy the agent

One script installs OSS kagent (controller, the built-in tool server, the dashboard), creates the github-mcp-pat Secret from GITHUB_PAT, and applies the three manifests above.

shterminal
export ANTHROPIC_API_KEY=...   # the model the agent runs on
export GITHUB_PAT=...          # a PAT with Contents:read+write on the repo
make kagent-setup

ends with github-mcp discovered 44 tools and the reporter agent Accepted.

RUN

Prompt it in the dashboard

Open the kagent dashboard, pick port-audit-reporter, and tell it where to publish. Headless equivalent: make kagent-report REPO=owner/repo.

shterminal
make kagent-ui    # http://localhost:8080

Then pick port-audit-reporter and send it one line, naming the repo:

promptkagent chat
Publish the port audit to tjorourke/solo-port-test at port-audit-report.md on main
The port-audit-reporter agent in the kagent dashboard: the prompt typed in the chat box, and the Agent Details panel on the right listing its three tools, k8s_get_resources, get_file_contents and create_or_update_file.
The agent in the kagent dashboard — the prompt in the chat box, its three tools (read the ConfigMap, read + write the file) on the right. Click to expand.

first run commits the report and returns the commit URL; run it again with no traffic change and it says "no change" and commits nothing.

What lands in the repo, as GitHub renders it, emoji verdicts, a surface summary and a warning callout for the ports that should come out of the policy:

The committed port-audit-report.md rendered on GitHub: a metadata list (generated, nodes reporting, surface summary of 6 used, 4 allowed but never used, 1 denied), a per-port table for svc-b with emoji verdicts, and a Warning callout listing the ports to remove from the AuthorizationPolicy.
port-audit-report.md, committed by the agent and rendered on GitHub. Click to expand.
mdport-audit-report.md (source the agent commits)
# 🛡️ Ambient port audit

- **Generated:** 2026-07-16T19:57:20Z
- **Nodes reporting:** port-audit-worker, port-audit-worker2
- **Surface:** ✅ 6 used · ⚠️ 4 allowed but never used · 🚫 1 denied

## svc-b

| Port | In Service | In AuthzPolicy | Used | Verdict |
|-----:|:----------:|:--------------:|:----:|---------|
| 7070 | yes | no  | no  | 🚫 denied |
| 8080 | yes | yes | yes | ✅ used |
| 8083 | yes | yes | no  | ⚠️ allowed, never used |
| 9094 | yes | yes | no  | ⚠️ allowed, never used |

> [!WARNING]
> Remove ports 8083, 8084, 9093, 9094 from the AuthorizationPolicy — allowed
> but never used. Port 7070 has blocked probe attempts.

---

_Generated by the port-audit-reporter kagent agent._

The aggregator rewrites report.json every minute with a fresh Generated: timestamp, so a byte compare would commit every minute forever. The agent is told to diff the tables and the recommendation and ignore that line, so a commit means the findings changed, not that a clock ticked. That is the whole point of a git diff here: the repo history becomes a timeline of when svc-b's real port surface moved.

Taking it to a real cluster

See also

Versions

Built and verified on:

Enterprise
Gateway APIv1.5.1
GitHub MCPhosted (api.githubcopilot.com)
Gloo Operator0.5.2
Kubernetes (kind)1.35
Reporter agent modelclaude-sonnet-4-5
Solo Istio (distribution)1.29.3-solo
google-adk (kagent runtime)2.4.0
kagent (OSS, bonus)0.9.4