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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- kubernetes-security Installs to ~/.agents/skills/kubernetes-security/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install kubernetes-security The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project kubernetes-security Installs to .agents/skills/kubernetes-security/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/kubernetes-security.zipunzip -o kubernetes-security.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) kubernetes-security PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- A pod is crashing or pending and you need to debug it: use kubernetes-debugging
- You need image signing, SBOMs and provenance in the build: use supply-chain-security
- The workload images themselves have CVEs to fix: use container-scanning
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.
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
| # | Area | Why first |
|---|---|---|
| 1 | Privileged pods, hostPath to /, docker or containerd socket | Each is a node takeover |
| 2 | RBAC: cluster-admin bindings, wildcard rules, secret read access | One stolen token becomes the cluster |
| 3 | Pod Security Admission per namespace | Stops new risky pods |
| 4 | NetworkPolicy default deny | Limits lateral movement after a compromise |
| 5 | Image provenance policy | Stops unknown or tampered images |
| 6 | Secrets: encryption at rest, external store, no env-var plain text | Limits blast radius of leaks |
| 7 | Audit logging and runtime detection | Lets 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
RoleplusRoleBindingby default;ClusterRoleonly 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/watchonsecrets,createonpodsorpods/exec,escalate,bind,impersonate, write access torolebindings,mutatingwebhookconfigurations,nodes/proxy. - One ServiceAccount per workload. Set
automountServiceAccountToken: falseon 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,
/procand 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
| Tool | Use |
|---|---|
kube-bench | CIS benchmark checks for nodes and control plane (self-managed clusters) |
kubescape, trivy k8s | Workload and configuration scans in cluster or CI |
| Kyverno / Gatekeeper policy reports | Continuous admission and background scans |
| Falco or Tetragon | Runtime 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.pyshows nocriticalorhighfindings outside documented exceptions.kubectl label --dry-run=serveratrestrictedreturns 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 (
:latestimage, 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.yaml
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.py
#!/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.