Kubernetes Security skill for Codex

Restricted pods, least RBAC, default-deny networks. Codex audits workloads with a bundled script that flags privileged pods, host mounts, root users and mutable image tags, then fixes issues in priority order: node-takeover risks, RBAC, Pod Security Admission, NetworkPolicy, image policy and secrets. Every policy goes out in warn or audit mode first, default-deny networking keeps DNS egress, and RBAC removals on shared clusters wait for your confirmation.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- kubernetes-security

Installs to ~/.agents/skills/kubernetes-security/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $kubernetes-security, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Hardens Kubernetes clusters and workloads - least-privilege RBAC audited with kubectl auth can-i, Pod Security Admission labels (baseline, restricted), securityContext defaults, default-deny NetworkPolicies with DNS egress, image policy via ValidatingAdmissionPolicy, Kyverno or signature verification, and secret handling - plus a bundled audit script for pod specs. Use when reviewing manifests or Helm charts for security, preparing for a CIS or SOC 2 review, locking down a namespace, a pod needs privileged or hostPath access, or after a container escape or leaked service account token.

Use it when

  • Reviewing manifests or Helm charts before a security review
  • Locking down a namespace with PSA labels and NetworkPolicies
  • Auditing RBAC after a leaked token or for least privilege
  • Enforcing approved registries and digest-pinned images at admission

Use something else when

How to check the result

audit_pods.py reports no critical or high findings, a server-side dry-run of enforce=restricted shows no warnings, and test pods confirm blocked traffic fails while DNS and allowed paths work.

The mistake it prevents

Applying a default-deny egress NetworkPolicy without an explicit DNS allow rule, which breaks every service lookup in the namespace at once.

~/.agents/skills/kubernetes-security/SKILL.md

--- name: kubernetes-security description: Hardens Kubernetes clusters and workloads - least-privilege RBAC audited with kubectl auth can-i, Pod Security Admission labels (baseline,… license: MIT ---

Done well means: every workload runs under the restricted Pod Security profile or has a written exception, no identity has more RBAC than it uses, each namespace starts from default-deny networking, only signed or digest-pinned images from approved registries run, and every policy was rolled out in warn/audit mode before enforce.

Inspect before changing

kubectl version; kubectl get nodes -o wide
kubectl get ns --show-labels | grep pod-security          # PSA labels per namespace
kubectl get clusterrolebindings -o wide | grep -E 'cluster-admin|system:masters'
kubectl get networkpolicy -A
kubectl get validatingadmissionpolicies,validatingwebhookconfigurations
kubectl get pods -A -o json > pods.json && python3 scripts/audit_pods.py pods.json --skip-ns kube-system

scripts/audit_pods.py (standard library) reads kubectl ... -o json for Pods or workload controllers and flags privileged containers, host namespaces, dangerous hostPath mounts, root users, missing drop: [ALL], missing seccomp, mutable image tags, plain-text secret env vars and missing limits. It exits 1 when a finding at or above --fail-on (default high) exists. Run it on rendered manifests in CI too: helm template . | kubectl apply --dry-run=client -o json -f - | python3 scripts/audit_pods.py -.

Also read AGENTS.md and the GitOps repo: changes go through the same Helm/Kustomize path as everything else, not kubectl edit on the cluster.

Priority order

#AreaWhy first
1Privileged pods, hostPath to /, docker or containerd socketEach is a node takeover
2RBAC: cluster-admin bindings, wildcard rules, secret read accessOne stolen token becomes the cluster
3Pod Security Admission per namespaceStops new risky pods
4NetworkPolicy default denyLimits lateral movement after a compromise
5Image provenance policyStops unknown or tampered images
6Secrets: encryption at rest, external store, no env-var plain textLimits blast radius of leaks
7Audit logging and runtime detectionLets you see the next incident

RBAC

kubectl auth can-i --list --as=system:serviceaccount:shop:api -n shop
kubectl auth can-i get secrets -A --as=system:serviceaccount:shop:api
kubectl get rolebindings,clusterrolebindings -A -o json \
  | jq -r '.items[] | select(.subjects[]?.name=="api") | "\(.kind) \(.metadata.namespace)/\(.metadata.name) -> \(.roleRef.name)"'
  • Namespaced Role plus RoleBinding by default; ClusterRole only for cluster-scoped resources or reuse across namespaces (still bound with a RoleBinding).
  • No * in verbs, resources or apiGroups for app identities.
  • Treat these as admin-equivalent: get/list/watch on secrets, create on pods or pods/exec, escalate, bind, impersonate, write access to rolebindings, mutatingwebhookconfigurations, nodes/proxy.
  • One ServiceAccount per workload. Set automountServiceAccountToken: false on pods that never call the Kubernetes API.
  • Humans get access through the cloud IAM/OIDC group mapping, not static kubeconfigs or long-lived token Secrets.

Removing a binding can break running controllers. Show the diff and the subjects affected, and confirm with the user before applying RBAC removals on a shared cluster.

Pod Security Admission

apiVersion: v1
kind: Namespace
metadata:
  name: shop
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted

Rollout: warn/audit at restricted first, read the warnings (kubectl apply prints them; audit annotations land in the API audit log), fix workloads, then raise enforce. Dry-run a level against existing pods without changing anything:

kubectl label --dry-run=server --overwrite ns shop pod-security.kubernetes.io/enforce=restricted

A securityContext that passes restricted:

spec:
  automountServiceAccountToken: false
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    fsGroup: 10001
    seccompProfile: { type: RuntimeDefault }
  containers:
    - name: api
      image: ghcr.io/acme/api@sha256:<digest>
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities: { drop: ["ALL"] }
      volumeMounts:
        - { name: tmp, mountPath: /tmp }
  volumes:
    - name: tmp
      emptyDir: {}

readOnlyRootFilesystem is not required by restricted but blocks most dropped-payload attacks; give the app emptyDir mounts for the paths it writes. Images whose USER is a name rather than a numeric UID fail runAsNonRoot checks; set runAsUser.

System namespaces (CNI, CSI drivers, node agents) often need privileged. Label those namespaces explicitly and keep app workloads out of them.

NetworkPolicy

Policies only work if the CNI enforces them (Calico, Cilium, most managed CNIs with policy enabled). Flannel alone ignores them silently. Check before relying on them.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny, namespace: shop }
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns, namespace: shop }
spec:
  podSelector: {}
  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 }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: api-from-frontend, namespace: shop }
spec:
  podSelector: { matchLabels: { app: api } }
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector: { matchLabels: { app: frontend } }
      ports:
        - { protocol: TCP, port: 8080 }

Apply default-deny last, after the allow rules, and in one namespace at a time. Forgetting DNS egress is the classic outage. Allow the ingress controller namespace and monitoring scrapers explicitly. Block egress to the cloud metadata endpoint (169.254.169.254/32) from app namespaces unless workload identity needs it.

Image policy

Native first: ValidatingAdmissionPolicy (GA since Kubernetes 1.30) with CEL.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata: { name: images-pinned-and-approved }
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: >-
        object.spec.containers.all(c, c.image.startsWith('ghcr.io/acme/') && c.image.contains('@sha256:'))
      message: "Images must come from ghcr.io/acme and be pinned by digest"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata: { name: images-pinned-and-approved }
spec:
  policyName: images-pinned-and-approved
  validationActions: [Warn, Audit]      # switch to [Deny] after a clean week
  matchResources:
    namespaceSelector:
      matchLabels: { policy.acme.io/images: enforced }

Extend the expression to initContainers and ephemeralContainers once the basic rule holds. Signature verification (cosign keyless identities, attestations) needs Kyverno verifyImages or the Sigstore policy-controller; see $supply-chain-security for signing.

Secrets

  • Enable encryption at rest for Secrets (managed clusters: KMS envelope encryption option).
  • Prefer an external store via External Secrets Operator or the CSI secrets driver; see $secrets-management.
  • Mount secrets as files over env vars where the app allows; env vars leak into crash dumps, /proc and child processes.
  • A leaked ServiceAccount token: delete the token Secret (legacy) or rotate by recreating the ServiceAccount, and review audit logs for its use. Confirm with the user first.

Audit tooling

ToolUse
kube-benchCIS benchmark checks for nodes and control plane (self-managed clusters)
kubescape, trivy k8sWorkload and configuration scans in cluster or CI
Kyverno / Gatekeeper policy reportsContinuous admission and background scans
Falco or TetragonRuntime detection (shell in container, sensitive file reads)

Installing these pulls images or binaries over the network; ask before installing into a shared cluster.

Verification

  • audit_pods.py shows no critical or high findings outside documented exceptions.
  • kubectl label --dry-run=server at restricted returns no warnings for target namespaces.
  • From a debug pod in the namespace, blocked traffic actually fails (kubectl run np-test --rm -it --image=busybox:1.36 -n shop -- wget -qO- -T 3 http://other.svc:8080) and allowed traffic plus DNS still work.
  • A deliberately bad pod (:latest image, privileged) is warned or denied by policy.
  • kubectl auth can-i --list --as=<sa> matches what the app needs and nothing more.

Report template

**Scope:** cluster prod-eu (EKS 1.33), namespaces shop, payments
| Finding | Severity | Status |
|---|---|---|
| ds/log-agent mounts /var/run/docker.sock | critical | moved to logging ns, exception documented |
| sa/ci bound to cluster-admin | critical | replaced with Role deploy-shop (confirmed by user) |
| shop: no NetworkPolicies | high | default-deny + 4 allow rules applied |
| 11 containers without drop ALL | medium | fixed in Helm values |
**PSA:** shop enforce=restricted; payments warn=restricted (2 workloads pending)
**Policy:** images-pinned-and-approved in Warn mode until 2025-07-01
**Checks:** audit_pods.py 0 critical/high; NP tests pass; can-i lists reviewed

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Kubernetes Security"
  short_description: "Restricted pods, least RBAC, default-deny networks"
  default_prompt: "$kubernetes-security Audit our Helm chart's pod specs against the restricted Pod Security profile"
scripts/audit_pods.py140 lines, 6.7 KB
#!/usr/bin/env python3
"""Flag risky pod and container settings in `kubectl ... -o json` output.

Usage:
    kubectl get pods -A -o json > pods.json
    python3 scripts/audit_pods.py pods.json
    kubectl get deploy,sts,ds,cronjob,job -A -o json | python3 scripts/audit_pods.py -
    python3 scripts/audit_pods.py pods.json --min-severity high --skip-ns kube-system

Accepts a List of Pods or of workload objects (Deployment, StatefulSet, DaemonSet,
ReplicaSet, Job, CronJob). Checks follow the Pod Security Standards "restricted"
profile plus a few operational rules. Exit 1 if any finding at or above
--fail-on (default high) remains. Standard library only.
"""
from __future__ import annotations

import argparse
import json
import sys
from collections import Counter

SEV = {"low": 1, "medium": 2, "high": 3, "critical": 4}
DANGEROUS_CAPS = {"SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "SYS_MODULE", "NET_RAW", "ALL", "BPF", "DAC_READ_SEARCH"}


def pod_spec(obj: dict) -> dict | None:
    kind = obj.get("kind")
    spec = obj.get("spec", {})
    if kind == "Pod":
        return spec
    if kind == "CronJob":
        return spec.get("jobTemplate", {}).get("spec", {}).get("template", {}).get("spec")
    return spec.get("template", {}).get("spec")


def audit(obj: dict) -> list[tuple[str, str, str]]:
    """Return (severity, container or '-', message) findings for one object."""
    spec = pod_spec(obj)
    if not spec:
        return []
    out: list[tuple[str, str, str]] = []
    psc = spec.get("securityContext", {}) or {}

    for key, sev in (("hostNetwork", "high"), ("hostPID", "high"), ("hostIPC", "high")):
        if spec.get(key):
            out.append((sev, "-", f"{key}: true"))
    for vol in spec.get("volumes", []) or []:
        if "hostPath" in vol:
            path = vol["hostPath"].get("path", "?")
            sev = "critical" if path in ("/", "/var/run/docker.sock", "/run/containerd/containerd.sock", "/etc", "/proc") else "high"
            out.append((sev, "-", f"hostPath volume {vol.get('name')} -> {path}"))
    if spec.get("automountServiceAccountToken") is not False:
        out.append(("low", "-", "service account token auto-mounted (set automountServiceAccountToken: false if the app never calls the API)"))
    if (psc.get("seccompProfile") or {}).get("type") not in ("RuntimeDefault", "Localhost"):
        pod_seccomp_missing = True
    else:
        pod_seccomp_missing = False

    containers = [("", c) for c in spec.get("containers", []) or []]
    containers += [("init:", c) for c in spec.get("initContainers", []) or []]
    for prefix, c in containers:
        name = prefix + c.get("name", "?")
        sc = c.get("securityContext", {}) or {}
        image = c.get("image", "")
        if sc.get("privileged"):
            out.append(("critical", name, "privileged: true"))
        if sc.get("allowPrivilegeEscalation") is not False:
            out.append(("medium", name, "allowPrivilegeEscalation not set to false"))
        run_as_non_root = sc.get("runAsNonRoot", psc.get("runAsNonRoot"))
        run_as_user = sc.get("runAsUser", psc.get("runAsUser"))
        if run_as_user == 0:
            out.append(("high", name, "runAsUser: 0 (root)"))
        elif not run_as_non_root and run_as_user is None:
            out.append(("medium", name, "runAsNonRoot not true and no runAsUser: may run as root"))
        caps = sc.get("capabilities", {}) or {}
        added = set(caps.get("add", []) or [])
        if added & DANGEROUS_CAPS:
            out.append(("high", name, f"adds capabilities {sorted(added & DANGEROUS_CAPS)}"))
        if "ALL" not in (caps.get("drop", []) or []):
            out.append(("medium", name, "capabilities.drop does not include ALL"))
        if pod_seccomp_missing and (sc.get("seccompProfile") or {}).get("type") not in ("RuntimeDefault", "Localhost"):
            out.append(("medium", name, "no RuntimeDefault seccompProfile"))
        if not sc.get("readOnlyRootFilesystem"):
            out.append(("low", name, "root filesystem writable"))
        if "@sha256:" not in image and (image.endswith(":latest") or ":" not in image.rsplit("/", 1)[-1]):
            out.append(("medium", name, f"mutable image reference {image!r} (latest or no tag)"))
        if not prefix:
            res = c.get("resources", {}) or {}
            if not (res.get("limits") or {}).get("memory"):
                out.append(("low", name, "no memory limit"))
            if not (res.get("requests") or {}):
                out.append(("low", name, "no resource requests"))
        for env in c.get("env", []) or []:
            n = env.get("name", "").upper()
            if "value" in env and any(k in n for k in ("PASSWORD", "SECRET", "TOKEN", "API_KEY")):
                out.append(("high", name, f"plain-text env var {env['name']} looks like a secret (use secretKeyRef)"))
    return out


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("file", help="kubectl JSON output, or - for stdin")
    ap.add_argument("--min-severity", choices=SEV, default="low", help="hide findings below this level")
    ap.add_argument("--fail-on", choices=SEV, default="high", help="exit 1 if a finding at or above this level exists")
    ap.add_argument("--skip-ns", action="append", default=[], help="namespace to ignore (repeatable)")
    args = ap.parse_args()

    try:
        data = json.load(sys.stdin if args.file == "-" else open(args.file, encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        print(f"error: cannot read JSON: {exc}", file=sys.stderr)
        return 2
    items = data.get("items", [data]) if isinstance(data, dict) else []

    counts: Counter[str] = Counter()
    worst = 0
    seen_owned = 0
    for obj in items:
        meta = obj.get("metadata", {})
        ns = meta.get("namespace", "-")
        if ns in args.skip_ns:
            continue
        if obj.get("kind") == "Pod" and meta.get("ownerReferences"):
            seen_owned += 1
        findings = [f for f in audit(obj) if SEV[f[0]] >= SEV[args.min_severity]]
        if not findings:
            continue
        print(f"{obj.get('kind')} {ns}/{meta.get('name')}")
        for sev, cname, msg in sorted(findings, key=lambda f: -SEV[f[0]]):
            print(f"  [{sev.upper():8}] {cname:20} {msg}")
            counts[sev] += 1
            worst = max(worst, SEV[sev])
    print("\nSummary: " + (", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts[s]) or "no findings"))
    if seen_owned:
        print(f"Note: {seen_owned} pods are owned by controllers; fix the Deployment/StatefulSet/etc., not the pod.")
    return 1 if worst >= SEV[args.fail_on] else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc