Cloud Security Wire
AWS Azure GCP RSS
Hardening Guide

Kubernetes NetworkPolicy: Enforcing Zero-Trust Pod Isolation in Production Clusters

By default, every pod in a Kubernetes cluster can reach every other pod. NetworkPolicy resources change that — but only if your CNI plugin enforces them. Here's how to design and deploy NetworkPolicies that actually enforce zero-trust lateral movement controls.

By Cloud Security Wire · ·
#Kubernetes#NetworkPolicy#zero-trust#CNI#pod-security#lateral-movement#EKS#AKS#GKE#Calico#Cilium

The Default Kubernetes Trust Problem

A freshly provisioned Kubernetes cluster has no NetworkPolicy resources. That means every pod can send traffic to every other pod — across namespaces, across applications, across environments. If an attacker compromises a single pod (via a vulnerable application, a supply chain implant, or a stolen container image), they have immediate network reachability to your databases, internal APIs, and other workloads.

This is not a theoretical risk. Container escape and pod-to-pod lateral movement are well-documented in real-world Kubernetes attacks. The 2020 Tesla cryptomining incident, the 2022 TraderTraitor Kubernetes compromises, and multiple ransomware attacks against container environments all involved lateral movement across workloads that had no network isolation between them.

NetworkPolicy resources let you define what traffic is permitted between pods — but there’s a critical prerequisite: your CNI plugin must enforce them. The vanilla Kubernetes kubenet plugin does not. You need Calico, Cilium, Weave Net, or a cloud-managed equivalent (AWS VPC CNI with policy enforcement, Azure CNI with Calico, GKE Dataplane V2 which uses Cilium).

NetworkPolicy Fundamentals

A NetworkPolicy selects pods using podSelector labels and defines allowed ingress and/or egress traffic. The key behaviours:

  • A pod with no matching NetworkPolicy is non-isolated — all traffic is allowed
  • A pod selected by any NetworkPolicy is isolated to the traffic explicitly permitted by that policy
  • Policies are additive: multiple policies selecting the same pod combine their allow rules

The minimal structure:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: example-policy
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api-server
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: postgres
      ports:
        - protocol: TCP
          port: 5432

This policy: allows ingress to api-server pods only from frontend pods on port 8080; allows egress from api-server pods only to postgres pods on port 5432. All other traffic is denied.

Step 1: Default-Deny as the Foundation

Start with a namespace-wide default-deny policy for both ingress and egress. This ensures that any new pod without an explicit policy is isolated by default:

# Apply to every namespace where isolation is required
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}    # empty selector matches ALL pods in namespace
  policyTypes:
    - Ingress
    - Egress

Apply this immediately to all application namespaces. Exempt system namespaces (kube-system) initially, but plan to harden them too.

Verify it works:

# Deploy a test pod and confirm it cannot reach another pod
kubectl run test-client --image=busybox --rm -it --restart=Never -- \
  wget -T 3 -O- http://api-server.production.svc.cluster.local:8080
# Should time out after 3 seconds if NetworkPolicy is enforced

Step 2: Allow Required Traffic Explicitly

For each workload, create explicit allow policies. Think in terms of a minimal allow list:

# Allow frontend to reach api-server
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-frontend-to-api
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api-server
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend
      ports:
        - protocol: TCP
          port: 8080
---
# Allow api-server to reach postgres
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-api-to-postgres
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: postgres
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: api-server
      ports:
        - protocol: TCP
          port: 5432

Step 3: DNS Egress — A Common Footgun

After applying default-deny egress, your pods will immediately lose DNS resolution. You must explicitly allow egress to kube-dns:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: production
spec:
  podSelector: {}       # all pods need DNS
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
        - podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

Step 4: Cross-Namespace Policies

Use namespaceSelector when traffic crosses namespace boundaries — for example, an ingress controller in the ingress-nginx namespace sending traffic to application pods:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-ingress-controller
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: frontend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
          podSelector:
            matchLabels:
              app.kubernetes.io/name: ingress-nginx
      ports:
        - protocol: TCP
          port: 80

Note the YAML structure: namespaceSelector and podSelector in the same list item (from) are AND conditions (both must match). In separate list items, they are OR conditions.

Step 5: Monitoring and Auditing NetworkPolicy Gaps

Find pods not covered by any NetworkPolicy

# List all pods in a namespace and check which have matching policies
kubectl get pods -n production -o json | jq -r '.items[].metadata.labels'

# Check which policies exist
kubectl get networkpolicies -n production -o wide

For at-scale auditing, use kube-network-policies-audit or netfetch CLI tools:

# netfetch — scan cluster for uncovered pods
docker run --rm -v ~/.kube:/root/.kube nettrace/netfetch scan --namespace production

Cilium Network Policy Observability

If using Cilium, enable Hubble for flow-level policy visibility:

# Install Hubble CLI
cilium hubble enable

# Watch denied flows in real time
hubble observe --namespace production --verdict DROPPED --follow

# Identify what traffic is hitting the default-deny
hubble observe --namespace production --verdict DROPPED \
  --output jsonpb | jq '.flow | {src: .source.pod_name, dst: .destination.pod_name, port: .l4.TCP.destination_port}'

Cloud Provider Differences

ProviderCNI DefaultNetworkPolicy Enforcement
EKSVPC CNI (no enforcement)Must install Calico or enable VPC CNI network policy mode
AKSAzure CNI (no enforcement)Must select “Azure CNI with Calico” or “Cilium” at cluster creation
GKEStandard (no enforcement)Must enable Dataplane V2 (Cilium-based) or GKE Network Policy

EKS — enable VPC CNI network policy mode:

aws eks update-addon \
  --cluster-name my-cluster \
  --addon-name vpc-cni \
  --configuration-values '{"enableNetworkPolicy": "true"}'

GKE — enable Dataplane V2 at cluster creation:

gcloud container clusters create my-cluster \
  --enable-dataplane-v2 \
  --region us-central1

Common Mistakes

Forgetting egress policies: Default-deny ingress only is a half-measure. A compromised pod can still reach external attacker-controlled infrastructure if egress is unrestricted.

Using namespace labels that don’t exist: namespaceSelector silently fails (matches nothing) if the label doesn’t exist on the target namespace. Verify namespace labels with kubectl get namespaces --show-labels.

Not testing isolation: Apply the policy, then test connectivity from the intended paths (should work) and from unintended paths (should fail). Don’t assume the policy works until you’ve verified it blocks.

Permissive monitoring exceptions: Prometheus scrapers, service mesh sidecars, and log agents all need network access. Add explicit policies for them rather than disabling isolation for the whole namespace.

Terraform Snippet

resource "kubernetes_network_policy" "default_deny" {
  metadata {
    name      = "default-deny-all"
    namespace = kubernetes_namespace.production.metadata[0].name
  }
  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}

Kubernetes NetworkPolicy is a foundational zero-trust control, not a silver bullet — it operates at Layer 3/4 and doesn’t inspect application-layer content. Combine it with service mesh mTLS (Istio, Linkerd), pod security admission, and container runtime security (Falco) for defence in depth.

← All Analysis Subscribe via RSS