Skip to main content
AuditNetworkPolicy is a kguardian CRD that lets you preview the impact of a Kubernetes NetworkPolicy before you enforce it. The spec is byte-identical to upstream networking.k8s.io/v1.NetworkPolicy. The only thing that changes is what kguardian does with it: the kguardian-evaluator watches observed pod traffic and reports flows that the policy would deny — but never drops a single packet.

Why

The hardest part of shipping NetworkPolicies in production isn’t writing them — it’s confidence. A typo in a podSelector or a missing to: block can blackhole half a service in a heartbeat. The conventional answer is “test in staging”, but staging traffic shapes never match production. AuditNetworkPolicy lets you apply your candidate policy to live production traffic, watch for false positives in the audit stream, then promote with a one-line kind: change once you’re confident.

Prior art

This pattern is directly modelled on Calico’s StagedKubernetesNetworkPolicy. The differences:
  • Calico evaluates staged policies alongside its own dataplane. kguardian is observability-only and runs over the live eBPF flow stream from the controller — you can use it with any CNI (Cilium, Calico, OVN, kindnet, etc.).
  • Calico’s promotion path renames the resource kind. kguardian uses the same approach: copy the spec, change kind: AuditNetworkPolicy to kind: NetworkPolicy, apply with kubectl. Your CNI then enforces it.
  • Cilium has policyAuditMode but it’s a cluster-wide agent flag and disables enforcement entirely; per-policy audit was closed not planned. The CRD-per-policy model that Calico (and now kguardian) use is what people actually want.

How it works

┌──────────────────┐  flow events    ┌──────────┐  POST /evaluate   ┌────────────┐
│ kguardian-       │ ──────────────▶ │ broker   │ ────────────────▶ │ evaluator  │
│ controller (eBPF)│                 │          │                   │            │
└──────────────────┘                 └────┬─────┘                   └─────┬──────┘
                                          │                               │ watches
                                  audit_verdicts                          │ AuditNetworkPolicy CRDs
                                  (Postgres)                              │ + Pods + Namespaces
                                          │                               │
                                          ▼                               ▼
                                 frontend "Would-Deny"             status.evaluation
                                 view + Prometheus              + would-deny logs / events
  1. The controller observes every TCP/UDP connection on each node (no change from before).
  2. The broker forwards each flow to the evaluator’s /evaluate endpoint.
  3. The evaluator looks up which AuditNetworkPolicy resources select either side of the flow, runs the standard NetworkPolicy semantics over the rule set, and returns a verdict per (policy, direction).
  4. WouldDeny verdicts are persisted in audit_verdicts and surface as logs, Kubernetes Events on the policy, and rolling counts in .status.evaluation.

Example

Apply this to a namespace and watch the evaluator’s logs:
apiVersion: kguardian.dev/v1alpha1
kind: AuditNetworkPolicy
metadata:
  name: payments-isolation
  namespace: prod
spec:
  podSelector:
    matchLabels:
      app: payments
  policyTypes: [Ingress, Egress]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              tier: frontend
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
    - to:
        - podSelector:
            matchLabels:
              app: postgres
      ports:
        - protocol: TCP
          port: 5432
kubectl get auditnetworkpolicy payments-isolation -n prod shows a WOULD-DENY column populated by the rolling-window count. kubectl describe shows the most frequent offenders.

Promoting to enforced

When you’re satisfied the would-deny set is empty (or the false positives have all been triaged), promote:
kubectl get auditnetworkpolicy payments-isolation -n prod -o yaml \
  | sed 's|kind: AuditNetworkPolicy|kind: NetworkPolicy|' \
  | sed 's|kguardian.dev/v1alpha1|networking.k8s.io/v1|' \
  | kubectl apply -f -

kubectl delete auditnetworkpolicy payments-isolation -n prod
Your CNI now enforces the policy. The evaluator stops watching it.

Tracking evaluator progress — status.observedGeneration

After you edit an AuditNetworkPolicy (e.g. broaden a to: selector) the evaluator has to pick the new spec up, re-evaluate the rolling window of observed traffic, and re-publish the WOULD-DENY counts. The CRD exposes .status.observedGeneration so you know when that loop has caught up:
kubectl get auditnetworkpolicy payments-isolation -n prod \
  -o jsonpath='{.metadata.generation} → {.status.observedGeneration}{"\n"}'
# e.g.  4 → 4   ← evaluator is on the latest spec
# e.g.  4 → 3   ← still working through your last edit
The evaluator stamps observedGeneration once it has finished a full reconcile of the current .metadata.generation. Don’t trust the WOULD-DENY count for tuning decisions until the two numbers match.

Querying verdicts directly

The frontend’s Would-Deny view consumes the broker’s GET /audit/verdicts endpoint. The same endpoint is what to hit from your own tooling (scripted reports, periodic export, etc.):
# Latest 25 WouldDeny verdicts for one policy in the prod namespace
kubectl port-forward -n kguardian svc/kguardian-broker 9090:9090
curl 'http://localhost:9090/audit/verdicts?policy=web-deny&namespace=prod&verdict=WouldDeny&limit=25'
Filters are server-side and index-backed; ?namespace= (empty value) is the legitimate selector for cluster-scoped policy verdicts. The result is ordered (observed_at DESC, id DESC) so external paginators can cursor on the BIGSERIAL id. See the endpoint reference for the full contract.

Cluster-scoped policies — AuditClusterNetworkPolicy

For cross-namespace audits (e.g. “what would happen if I default-denied ingress on every workload across the cluster?”) use the cluster-scoped sibling. The spec is identical to AuditNetworkPolicy but adds a top-level namespaceSelector (nil/empty matches all namespaces) and is itself cluster-scoped. Within each matching namespace the rule evaluation is identical.
apiVersion: kguardian.dev/v1alpha1
kind: AuditClusterNetworkPolicy
metadata:
  name: platform-default-deny
spec:
  namespaceSelector:
    matchLabels:
      team: platform
  podSelector: {}
  policyTypes: [Ingress]
  # no ingress rules → default-deny ingress for every pod in
  # any namespace labelled team=platform
Promotion works the same way but you’ll typically expand a single cluster-scoped policy into N per-namespace NetworkPolicy resources (one per matched namespace) for actual enforcement, since upstream NetworkPolicy is namespaced. Calico’s GlobalNetworkPolicy and upstream’s AdminNetworkPolicy are the cluster-wide enforcement counterparts if your CNI supports them.

CLI helper — kguardian audit promote

# print the equivalent NetworkPolicy YAML
kguardian audit promote payments-isolation -n prod

# pipe straight to kubectl
kguardian audit promote payments-isolation -n prod | kubectl apply -f -

# dump every audit policy in the namespace
kguardian audit promote --all -n prod

# write one file per policy across the whole cluster
kguardian audit promote --all --all-namespaces --output-dir ./promoted/
The promoted YAML strips the audit-side status and the kubectl.kubernetes.io/last-applied-configuration annotation (it’d be wrong for the new kind). Existing labels and other annotations are preserved.

Cluster-scoped — kguardian audit promote-cluster

AuditClusterNetworkPolicy promotes to one networking.k8s.io/v1.NetworkPolicy per matching namespace (since native NetworkPolicy is namespaced; the cluster-scope namespaceSelector is dropped from each emitted spec).
# discover namespaces matching spec.namespaceSelector and emit one
# NetworkPolicy per match
kguardian audit promote-cluster platform-default-deny-ingress

# explicit namespace list — skip the namespaceSelector discovery,
# useful when promoting to a subset
kguardian audit promote-cluster platform-default-deny-ingress \
  --target-namespace prod --target-namespace staging

# write one file per namespace
kguardian audit promote-cluster platform-default-deny-ingress \
  --output-dir ./promoted/

What kguardian does not do

  • It does not enforce anything. Even with an AuditNetworkPolicy in place, all traffic flows. If you want enforcement, promote the policy as above and rely on your CNI.
  • It does not aggregate verdicts across multiple evaluator replicas — the evaluator’s status updater is single-replica by design (a Helm guardrail blocks replicaCount > 1). Multi-replica HA is a future story.

Limits + caveats

The matcher implements podSelector + namespaceSelector + numeric port + endPort range + named-port + ipBlock (CIDR + except). Edge cases that warrant care:
  • Empty from: / to: matches all peers — the same trap as upstream NetworkPolicy.
  • Empty ingress: [] / egress: [] is a default-deny in the relevant direction. If a policy’s policyTypes includes a direction with no rules, every flow in that direction is “would-deny”.
  • Unknown peer pods (deleted between flow capture and evaluation) are not matched against any selector — this can cause an apparent under-count. The audit_verdicts.reason column records when this happens.
  • Named ports are resolved against the destination pod’s spec.containers[].ports[] declarations. A named port matches only when both the name and the observed containerPort line up.
  • ipBlock matches against the peer’s L3 address as observed by the eBPF controller. Flows with unknown IPs (e.g. malformed eBPF events) are non-matches, not silent allows.
  • Ingress UDP is systematically under-counted. The controller’s eBPF probes have no kind for inbound UDP, so traffic like CoreDNS receiving queries, syslog, or NTP responses arriving at a pod is not seen by the evaluator. Auditing a UDP-fronted namespace will show zero ingress flows where there may be millions. Egress UDP and all TCP directions are tracked normally.
If you’re carrying production-critical policy decisions on this, treat the verdicts as a strong signal but verify against your CNI’s enforcement output once promoted.