DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building a Kubernetes Security Audit Tool with Go

Most Kubernetes clusters ship with insecure defaults that nobody touches until something goes wrong. A misconfigured Pod security context, a container running as root, missing resource limits — these are the first things an attacker looks for after gaining access. Writing a Go tool that connects directly to the Kubernetes API lets you catch these issues automatically, before your next audit or incident.

What We're Auditing

A typical cluster has dozens of potential misconfigurations. For this tool, we focus on the controls most frequently violated in the wild:

  • Containers running as root (UID 0)
  • Privileged containers (privileged: true)
  • Missing resource limits (CPU and memory)
  • Pods with hostNetwork: true or hostPID: true
  • Missing readOnlyRootFilesystem
  • allowPrivilegeEscalation set to true (which is the Kubernetes default if unset)

This maps closely to the CIS Kubernetes Benchmark. If you want a complete audit checklist with all CIS controls, we publish one at ayinedjimi-consultants.fr/checklists — PDF and Excel, free.

Setting Up the Go Client

We'll use client-go, the official Kubernetes Go client. Add the dependencies:

go get k8s.io/client-go@latest k8s.io/api@latest k8s.io/apimachinery@latest
Enter fullscreen mode Exit fullscreen mode

The client authenticates via in-cluster config when running inside a Pod, or falls back to a local kubeconfig. We handle both cases with a single function:

package main

import (
    "fmt"
    "os"
    "path/filepath"

    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/rest"
    "k8s.io/client-go/tools/clientcmd"
)

func buildClient() (*kubernetes.Clientset, error) {
    // Try in-cluster config first (running inside a Pod)
    cfg, err := rest.InClusterConfig()
    if err != nil {
        // Fall back to local kubeconfig
        home, _ := os.UserHomeDir()
        kubeconfigPath := filepath.Join(home, ".kube", "config")
        cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath)
        if err != nil {
            return nil, fmt.Errorf("failed to build config: %w", err)
        }
    }
    return kubernetes.NewForConfig(cfg)
}
Enter fullscreen mode Exit fullscreen mode

This pattern works whether you run the auditor locally, in a CI job, or deploy it as a CronJob inside the cluster.

Core Audit Logic

We iterate over all pods across all namespaces and check each container's security context. The Finding struct is the central data type:

package main

import (
    "context"
    "fmt"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
)

type Finding struct {
    Namespace string `json:"namespace"`
    Pod       string `json:"pod"`
    Container string `json:"container,omitempty"`
    Check     string `json:"check"`
    Severity  string `json:"severity"`
}

func auditPods(ctx context.Context, client *kubernetes.Clientset) ([]Finding, error) {
    var findings []Finding

    pods, err := client.CoreV1().Pods("").List(ctx, metav1.ListOptions{})
    if err != nil {
        return nil, fmt.Errorf("listing pods: %w", err)
    }

    for _, pod := range pods.Items {
        if pod.Spec.HostNetwork {
            findings = append(findings, Finding{
                Namespace: pod.Namespace, Pod: pod.Name,
                Check: "hostNetwork=true", Severity: "HIGH",
            })
        }
        if pod.Spec.HostPID {
            findings = append(findings, Finding{
                Namespace: pod.Namespace, Pod: pod.Name,
                Check: "hostPID=true", Severity: "HIGH",
            })
        }
        for _, c := range pod.Spec.Containers {
            findings = append(findings, checkContainer(pod, c)...)
        }
    }
    return findings, nil
}

func checkContainer(pod corev1.Pod, c corev1.Container) []Finding {
    var findings []Finding

    loc := func(check, sev string) Finding {
        return Finding{
            Namespace: pod.Namespace, Pod: pod.Name,
            Container: c.Name, Check: check, Severity: sev,
        }
    }

    sc := c.SecurityContext
    if sc == nil {
        return append(findings, loc("missing SecurityContext", "MEDIUM"))
    }

    if sc.Privileged != nil && *sc.Privileged {
        findings = append(findings, loc("privileged=true", "CRITICAL"))
    }

    if sc.RunAsNonRoot == nil || !*sc.RunAsNonRoot {
        if sc.RunAsUser == nil || *sc.RunAsUser == 0 {
            findings = append(findings, loc("container may run as root", "HIGH"))
        }
    }

    if sc.ReadOnlyRootFilesystem == nil || !*sc.ReadOnlyRootFilesystem {
        findings = append(findings, loc("readOnlyRootFilesystem not enforced", "LOW"))
    }

    // allowPrivilegeEscalation defaults to true in Kubernetes if unset
    if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation {
        findings = append(findings, loc("allowPrivilegeEscalation=true", "MEDIUM"))
    }

    if c.Resources.Limits == nil {
        findings = append(findings, loc("no resource limits", "MEDIUM"))
    }

    return findings
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting. We treat a missing SecurityContext as a single MEDIUM finding and return early — no point emitting five sub-findings when the root cause is one missing block. AllowPrivilegeEscalation defaults to true in Kubernetes if unset, so we flag the nil case explicitly.

Reporting and Exit Codes

For a first version, JSON output piped into your SIEM or a Slack webhook is sufficient:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "os"
)

func main() {
    client, err := buildClient()
    if err != nil {
        log.Fatalf("init client: %v", err)
    }

    ctx := context.Background()
    findings, err := auditPods(ctx, client)
    if err != nil {
        log.Fatalf("audit failed: %v", err)
    }

    if len(findings) == 0 {
        fmt.Println("No findings.")
        os.Exit(0)
    }

    enc := json.NewEncoder(os.Stdout)
    enc.SetIndent("", "  ")
    for _, f := range findings {
        if err := enc.Encode(f); err != nil {
            log.Fatalf("encode: %v", err)
        }
    }

    // Exit non-zero on CRITICAL findings — useful as a CI gate
    for _, f := range findings {
        if f.Severity == "CRITICAL" {
            os.Exit(2)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The exit code pattern matters. Exit 0 means clean, exit 2 means at least one CRITICAL finding. This lets you wire the tool into a CI job or a pre-deployment check: if exit code is 2, block the pipeline.

To run the auditor inside the cluster on a schedule:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: k8s-auditor
  namespace: security
spec:
  schedule: "0 3 * * 1"   # Every Monday at 03:00
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: k8s-auditor
          restartPolicy: OnFailure
          containers:
          - name: auditor
            image: your-registry/k8s-auditor:latest
Enter fullscreen mode Exit fullscreen mode

The service account needs only get and list on pods across all namespaces — nothing more. Read-only access means the auditor itself cannot be weaponized if compromised.

Extending the Auditor

Once the pod checks are solid, adding new rules is straightforward — each is a function that takes a *kubernetes.Clientset and returns []Finding.

Network policies: Check whether each namespace has at least one NetworkPolicy. A namespace without one is completely open to lateral movement from any other pod in the cluster.

RBAC: List ClusterRoleBindings and flag any that grant cluster-admin to a service account. One API call surfaces over-privileged accounts that nobody remembers creating.

Image hygiene: Flag containers using the latest tag or pulling from unverified registries. Kubernetes doesn't enforce this by default, and latest makes deployments non-deterministic under pressure.

Secrets exposure: Detect secrets mounted as environment variables instead of volumes. Environment variables are visible in process listings and often end up in application logs.

The Takeaway

The Kubernetes API exposes everything you need to audit your cluster's security posture without installing a third-party agent. Go's client-go library handles authentication and serialization, and the compiled binary is a single static artifact you can drop into any environment or container image.

Start with the pod security checks above. Wire the exit code into your CI pipeline. Add RBAC checks in the next iteration. An auditor you run every week beats a comprehensive one that's too complex to maintain. Security tooling that actually gets used is the only kind that matters.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)