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: trueorhostPID: true - Missing
readOnlyRootFilesystem -
allowPrivilegeEscalationset 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
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)
}
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
}
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)
}
}
}
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
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)