CKS task cheat sheet
The command for each thing a CKS task asks you to do - cut a Role down, stop a token being mounted, refuse a Pod at admission, deny a namespace's traffic, encrypt what is in etcd, scan and sign what you ship, and find out afterwards who did it. Every output below is a real run on this path's clusters, not an illustration.
- Kubernetesapiserver v1.36.4, kubelet v1.36.3
- Runtimecontainerd 2.2.6
- CNICilium 1.18.1 - tunnel/VXLAN, with Hubble relay and UI
- Host OSUbuntu 26.04 LTS, kernel 7.0.0-29
- Built withkubeadm v1.36.3 - podSubnet 10.244.0.0/16, serviceSubnet 10.96.0.0/12
- Commands35
- Reviewed26 August 2026
Find out who can do what
-
kubectl auth can-i --list -n NS --as=system:serviceaccount:NS:SAEverything one identity may do, without holding it. The only honest answer to "what does this ServiceAccount actually have".
bash Example session kubectl auth can-i --list -n cks-rbac --as=system:serviceaccount:cks-rbac:report 2>/dev/null | head -4; echo "--- the first row is the grant. A wildcard on resources includes secrets, and a wildcard on verbs includes delete"Resources Non-Resource URLs Resource Names Verbs* [] [] [*]selfsubjectreviews.authentication.k8s.io [] [] [create]selfsubjectaccessreviews.authorization.k8s.io [] [] [create]--- the first row is the grant. A wildcard on resources includes secrets, and a wildcard on verbs includes delete -
kubectl get clusterrolebindings -o jsonpath='{range .items[?(@.roleRef.name=="cluster-admin")]}{.metadata.name}{" -> "}{.subjects[*].kind}{" "}{.subjects[*].name}{"\n"}{end}'Every cluster-admin subject. On a fresh kubeadm cluster this is one line, so anything else is an addition somebody made.
bash Example session kubectl get clusterrolebindings -o jsonpath='{range .items[?(@.roleRef.name=="cluster-admin")]}{.metadata.name}{" -> "}{.subjects[*].kind}{"/"}{.subjects[*].name}{"\n"}{end}'; echo "--- every binding to cluster-admin on this cluster. On a fresh kubeadm cluster this is two lines, and both are kubeadm's own"cluster-admin -> Group/system:masterskubeadm:cluster-admins -> Group/kubeadm:cluster-admins--- every binding to cluster-admin on this cluster. On a fresh kubeadm cluster this is two lines, and both are kubeadm's own -
kubectl get clusterroles -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.rules[*].verbs}{"\n"}{end}' | grep -E 'escalate|bind|impersonate'The three verbs that turn a small grant into a large one. None of them looks dangerous in a YAML review.
bash Example session kubectl get clusterroles -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.rules[*].verbs}{"\n"}{end}' | grep -E 'escalate|bind|impersonate' | grep -v '^system:' | awk '{print $1}'; echo "--- escalate writes a Role more powerful than your own, bind attaches an existing powerful Role, impersonate simply becomes someone else. None of the three looks alarming in a YAML review"adminedit--- escalate writes a Role more powerful than your own, bind attaches an existing powerful Role, impersonate simply becomes someone else. None of the three looks alarming in a YAML review -
kubectl get clusterroles -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.rules[*].verbs}{"\n"}{end}' | grep -F '["*"]'Wildcard verbs, cluster-wide. Read this list before you add anything to it.
bash Example session kubectl get clusterroles -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.rules[*].verbs}{"\n"}{end}' | grep -F '["*"]' | grep -v '^system:'; echo "--- the only non-system ClusterRole with a wildcard verb. A wildcard matches resources that did not exist when the Role was written, including CRDs installed next year"cluster-admin ["*"] ["*"]--- the only non-system ClusterRole with a wildcard verb. A wildcard matches resources that did not exist when the Role was written, including CRDs installed next year
Cut a Role down to what is needed
-
kubectl create role NAME --verb=get,list --resource=configmaps -n NS && kubectl create rolebinding NAME --role=NAME --serviceaccount=NS:SA -n NSThe imperative pair. Faster than writing YAML and far harder to get subtly wrong under time pressure.
bash Example session kubectl -n cks-rbac create role app-minimal --verb=get,list --resource=configmaps >/dev/null; kubectl -n cks-rbac create role app-minimal-pods --verb=list --resource=pods >/dev/null; kubectl -n cks-rbac delete rolebinding app-everything-b >/dev/null; kubectl -n cks-rbac create rolebinding app-minimal-b --role=app-minimal --serviceaccount=cks-rbac:report >/dev/null; kubectl -n cks-rbac create rolebinding app-minimal-pods-b --role=app-minimal-pods --serviceaccount=cks-rbac:report >/dev/null; kubectl auth can-i --list -n cks-rbac --as=system:serviceaccount:cks-rbac:report 2>/dev/null | grep -E '^(configmaps|pods)'; echo "--- the binding to the wildcard Role was DELETED. Creating a narrower Role beside it would have changed nothing, because grants are additive"configmaps [] [] [get list]pods [] [] [list]--- the binding to the wildcard Role was DELETED. Creating a narrower Role beside it would have changed nothing, because grants are additive -
kubectl create role NAME --verb=get --resource=secrets --resource-name=ONE-SECRET -n NS`--resource-name` narrows a grant to a single object. It works on get, but not on list - which is the distinction the exam likes.
bash Example session kubectl -n cks-s create sa app >/dev/null; kubectl -n cks-s create role one-secret --verb=get --resource=secrets --resource-name=dbcreds >/dev/null; kubectl -n cks-s create rolebinding one-secret-b --role=one-secret --serviceaccount=cks-s:app >/dev/null; echo -n 'get the named Secret : '; kubectl auth can-i get secret/dbcreds -n cks-s --as=system:serviceaccount:cks-s:app; echo -n 'list every Secret : '; kubectl auth can-i list secrets -n cks-s --as=system:serviceaccount:cks-s:app; echo "--- resourceNames scopes get and cannot apply to list, so the list half of such a Role grants nothing. Narrow to get, or split the namespace"get the named Secret : yeslist every Secret : no--- resourceNames scopes get and cannot apply to list, so the list half of such a Role grants nothing. Narrow to get, or split the namespace -
kubectl -n NS get secret NAME --as=system:serviceaccount:NS:SAProve the refusal with a real request, not only with can-i. Both directions, or you have not tested it.
bash Example session echo -n 'and the real read still fails: '; kubectl -n cks-rbac get secret billing --as=system:serviceaccount:cks-rbac:report 2>&1 | tail -1; echo "--- the refusal names the identity and the resource, which is what you paste into the change record"and the real read still fails: Error from server (Forbidden): secrets "billing" is forbidden: User "system:serviceaccount:cks-rbac:report" cannot get resource "secrets" in API group "" in the namespace "cks-rbac"--- the refusal names the identity and the resource, which is what you paste into the change record
ServiceAccounts and their tokens
-
kubectl -n NS patch serviceaccount default -p '{"automountServiceAccountToken":false}'Stop the token being mounted at all. Set it on the ServiceAccount for every Pod that uses it, or on the Pod spec for just one.
bash Example session kubectl -n cks-sa patch serviceaccount default -p '{"automountServiceAccountToken":false}' 2>&1 | tail -1; kubectl -n cks-sa run noauth --image=docker.io/library/busybox:1.37 --restart=Never --command -- sh -c 'sleep 900' >/dev/null; kubectl -n cks-sa wait --for=condition=Ready pod/noauth --timeout=150s >/dev/null; kubectl -n cks-sa exec noauth -- sh -c 'ls /var/run/secrets/kubernetes.io/serviceaccount/ 2>&1 | tail -1'; echo "--- patched on the ServiceAccount, so every future Pod in this namespace is covered without touching a single manifest"serviceaccount/default patchedls: /var/run/secrets/kubernetes.io/serviceaccount/: No such file or directory--- patched on the ServiceAccount, so every future Pod in this namespace is covered without touching a single manifest -
kubectl -n NS exec POD -- cat /var/run/secrets/kubernetes.io/serviceaccount/token | cut -d. -f2 | base64 -dWhat is actually in the projected token - audience, expiry, and the Pod it was bound to. It is a JWT, so the middle segment is readable.
bash Example session kubectl -n cks-sa exec app -- sh -c 'cat /var/run/secrets/kubernetes.io/serviceaccount/token' | cut -d. -f2 | base64 -d 2>/dev/null | tr ',' '\n' | grep -E '"sub"|"aud"|"exp"' | head -3; echo; echo "--- audience-bound and time-limited, and the sub is the identity every request from this Pod will be attributed to"{"aud":["https://kubernetes.default.svc.cluster.local"]"exp":1819225054"sub":"system:serviceaccount:cks-sa:default"} --- audience-bound and time-limited, and the sub is the identity every request from this Pod will be attributed to -
kubectl get secrets -A --field-selector type=kubernetes.io/service-account-tokenLong-lived token Secrets, which modern clusters no longer create. Any that exist were made deliberately and never expire.
bash Example session echo -n 'legacy service-account-token Secrets on this cluster: '; kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token --no-headers 2>/dev/null | wc -l; echo -n 'a fresh ServiceAccount lists secrets: '; kubectl -n cks-sa get sa reader -o jsonpath='{.secrets}'; echo '<none>'; echo "--- auto-created token Secrets ended in 1.24. Any that exist were created by hand, never expire, and are worth a question"legacy service-account-token Secrets on this cluster: 0a fresh ServiceAccount lists secrets: <none>--- auto-created token Secrets ended in 1.24. Any that exist were created by hand, never expire, and are worth a question -
kubectl -n NS create token SA --audience=https://kubernetes.default.svc --duration=10mA short-lived token on demand, with an audience. This is what replaced the permanent Secret.
bash Example session kubectl -n cks-sa create token reader --audience=https://kubernetes.default.svc --duration=10m | cut -d. -f2 | base64 -d 2>/dev/null | tr ',' '\n' | grep -E '"aud"|"exp"' | head -2; echo; echo "--- a token requested on purpose: one audience, ten minutes. That is what replaced the Secret, and it is what the kubelet projects into every Pod"{"aud":["https://kubernetes.default.svc"]"exp":1787689657 --- a token requested on purpose: one audience, ten minutes. That is what replaced the Secret, and it is what the kubelet projects into every Pod
The API server and the node
-
sudo grep -E 'anonymous-auth|authorization-mode|--bind-address|--secure-port' /etc/kubernetes/manifests/kube-apiserver.yamlThe four flags that decide who may reach the API and how they are authorised. Read them before changing anything.
bash Example session sudo grep -E 'anonymous-auth|authorization-mode|--bind-address|--secure-port' /etc/kubernetes/manifests/kube-apiserver.yaml; echo "--- anonymous-auth is not set, so it defaults to true. authorization-mode Node,RBAC is what turned the anonymous request into a 403" - --authorization-mode=Node,RBAC - --secure-port=6443--- anonymous-auth is not set, so it defaults to true. authorization-mode Node,RBAC is what turned the anonymous request into a 403 -
kubectl get clusterrole system:public-info-viewer -o jsonpath='{range .rules[*]}{.nonResourceURLs}{" "}{.verbs}{"\n"}{end}'Exactly what an unauthenticated caller is allowed. It is a short list of non-resource URLs, and it is a binding you can remove.
bash Example session kubectl get clusterrole system:public-info-viewer -o jsonpath='{range .rules[*]}{.nonResourceURLs}{" "}{.verbs}{"\n"}{end}'; echo "--- and this is all it grants: five non-resource URLs, read only. Worth knowing before anyone proposes disabling anonymous auth, because health probes use these"["/healthz","/livez","/readyz","/version","/version/"] ["get"]--- and this is all it grants: five non-resource URLs, read only. Worth knowing before anyone proposes disabling anonymous auth, because health probes use these -
sudo stat -c '%a %U:%G %n' /var/lib/kubelet/config.yaml /usr/lib/systemd/system/kubelet.service.d/10-kubeadm.confFile permissions on the kubelet's configuration - a whole family of CIS findings, and the easiest marks in the benchmark.
bash Example session sudo stat -c '%a %U:%G %n' /var/lib/kubelet/config.yaml /usr/lib/systemd/system/kubelet.service.d/10-kubeadm.conf; echo "--- 644, so every local user can read them. The kubelet config names the client CA and the authorization mode, which is a map of how this node decides who to trust"644 root:root /var/lib/kubelet/config.yaml644 root:root /usr/lib/systemd/system/kubelet.service.d/10-kubeadm.conf--- 644, so every local user can read them. The kubelet config names the client CA and the authorization mode, which is a map of how this node decides who to trust -
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|permitemptypasswords)'The effective sshd settings, not what the file says. `sshd -T` resolves every include and every default.
bash Example session sudo sshd -T 2>/dev/null | grep -E '^(permitrootlogin|passwordauthentication|pubkeyauthentication|permitemptypasswords|x11forwarding)' | sed 's/^/ /'; echo "--- the effective sshd configuration, not what the file says. sshd -T resolves every include and every default, which is the only version worth auditing" permitrootlogin prohibit-password pubkeyauthentication yes passwordauthentication yes x11forwarding yes permitemptypasswords no--- the effective sshd configuration, not what the file says. sshd -T resolves every include and every default, which is the only version worth auditing
Harden a Pod
-
kubectl label ns NS pod-security.kubernetes.io/enforce=restricted --overwriteThe only one of the three modes that refuses anything. warn and audit admit the Pod and tell you about it.
bash Example session kubectl label ns cks-psa pod-security.kubernetes.io/enforce=restricted --overwrite 2>&1 | tail -2; kubectl -n cks-psa run blocked --image=docker.io/library/busybox:1.37 --restart=Never --command -- sh -c 'sleep 300' 2>&1 | grep -oE 'violates PodSecurity.*' | head -1; echo "--- refused, and the message names every field. That list IS the specification for the next guide"Warning: warned: allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfilenamespace/cks-psa labeledviolates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "blocked" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "blocked" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "blocked" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "blocked" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")--- refused, and the message names every field. That list IS the specification for the next guide -
kubectl get ns NS -o jsonpath='{.metadata.labels}' | tr ',' '\n' | grep pod-securityWhich of enforce, warn and audit are actually set, and at what level. Check this before you believe a namespace is protected.
bash Example session kubectl get ns cks-psa -o jsonpath='{.metadata.labels}{"\n"}' | tr ',' '\n' | grep pod-security; echo "--- three modes and they are independent: warn talks to the client, audit writes to the audit log, enforce refuses. Roll a level out in that order""pod-security.kubernetes.io/audit":"restricted""pod-security.kubernetes.io/warn":"restricted"}--- three modes and they are independent: warn talks to the client, audit writes to the audit log, enforce refuses. Roll a level out in that order -
securityContext: runAsNonRoot / runAsUser / seccompProfile.type: RuntimeDefault / allowPrivilegeEscalation: false / capabilities.drop: ["ALL"] / readOnlyRootFilesystem: trueThe block that passes `restricted`. Note which fields are Pod-level and which are container-level - readOnlyRootFilesystem and capabilities are the latter.
bash Example session printf 'apiVersion: v1\nkind: Pod\nmetadata:\n name: app\n namespace: cks-sc\nspec:\n securityContext:\n runAsNonRoot: true\n runAsUser: 10001\n seccompProfile:\n type: RuntimeDefault\n containers:\n - name: c\n image: docker.io/library/busybox:1.37\n command: ["sh","-c","sleep 600"]\n securityContext:\n allowPrivilegeEscalation: false\n capabilities:\n drop: ["ALL"]\n' | kubectl apply -f - 2>&1 | tail -1; kubectl -n cks-sc wait --for=condition=Ready pod/app --timeout=150s; echo "--- admitted. Pod-level fields for the identity and the seccomp profile, container-level fields for privileges and capabilities"pod/app createdpod/app condition met--- admitted. Pod-level fields for the identity and the seccomp profile, container-level fields for privileges and capabilities -
kubectl -n NS exec POD -- id -u && kubectl -n NS exec POD -- grep CapEff /proc/1/statusProve it from inside the container rather than from the manifest. The uid and the capability mask are the two that matter.
bash Example session echo -n 'uid : '; kubectl -n cks-sc exec app -- id -u; echo -n 'effective capabilities : '; kubectl -n cks-sc exec app -- grep CapEff /proc/1/status; echo -n 'seccomp mode : '; kubectl -n cks-sc exec app -- grep Seccomp: /proc/1/status; echo -n 'no_new_privs : '; kubectl -n cks-sc exec app -- grep NoNewPrivs /proc/1/status; echo "--- 10001 not 0, an all-zero capability set, seccomp filtered, and no_new_privs set. Four fields, four measurable effects"uid : 10001effective capabilities : CapEff: 0000000000000000seccomp mode : Seccomp: 2no_new_privs : NoNewPrivs: 1--- 10001 not 0, an all-zero capability set, seccomp filtered, and no_new_privs set. Four fields, four measurable effects -
kubectl -n NS exec POD -- grep Seccomp: /proc/1/statusWhether a seccomp filter is actually loaded. `Seccomp: 2` is a filter, `0` is none - and the manifest can say one thing while the process says another.
bash Example session echo -n "no profile set : "; kubectl -n cks-sec exec plain -- grep Seccomp: /proc/1/status; echo -n "RuntimeDefault : "; kubectl -n cks-sec exec runtimedefault -- grep Seccomp: /proc/1/status; echo "--- 0 is unconfined and 2 is filtered. That line in /proc is how you check a RUNNING container instead of trusting the manifest that created it"no profile set : Seccomp: 0RuntimeDefault : Seccomp: 2--- 0 is unconfined and 2 is filtered. That line in /proc is how you check a RUNNING container instead of trusting the manifest that created it
Isolate it on the network
-
NetworkPolicy: podSelector: {} + policyTypes: [Ingress] - per namespaceThe one every task starts from. An empty podSelector means every Pod in the namespace; listing a policyType with no rules denies it.
bash Example session printf 'apiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\nmetadata:\n name: default-deny\n namespace: cks-client\nspec:\n podSelector: {}\n policyTypes: ["Ingress","Egress"]\n' | kubectl apply -f - 2>&1 | tail -1; sleep 8; echo -n "DNS: "; kubectl -n cks-client exec client -- sh -c 'nslookup web.cks-app.svc.cluster.local 2>&1 | grep -E "^Address|timed out" | tail -1'; echo "--- DNS is the first casualty of an egress deny, and it is the mistake everyone makes once"networkpolicy.networking.k8s.io/default-deny createdDNS: ;; connection timed out; no servers could be reached--- DNS is the first casualty of an egress deny, and it is the mistake everyone makes once -
NetworkPolicy egress to kube-system + ports 53 UDP and TCPThe first thing default-deny breaks. Add DNS back explicitly or nothing in the namespace can resolve a name.
bash Example session printf 'apiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\nmetadata:\n name: allow-dns\n namespace: cks-client\nspec:\n podSelector: {}\n policyTypes: ["Egress"]\n egress:\n - to:\n - namespaceSelector:\n matchLabels:\n kubernetes.io/metadata.name: kube-system\n podSelector:\n matchLabels:\n k8s-app: kube-dns\n ports:\n - {protocol: UDP, port: 53}\n - {protocol: TCP, port: 53}\n' | kubectl apply -f - 2>&1 | tail -1; sleep 8; echo -n "DNS: "; kubectl -n cks-client exec client -- sh -c 'nslookup web.cks-app.svc.cluster.local 2>&1 | grep -E "^Address|timed out" | tail -1'; echo -n "HTTP: "; kubectl -n cks-client exec client -- sh -c 'if wget -O /tmp/o -T 6 http://web.cks-app.svc.cluster.local:8080/ 2>/tmp/e; then cat /tmp/o; else grep -m1 "wget:" /tmp/e; fi'; echo "--- the name resolves again and the request still fails. Two separate permissions, and DNS is the one nobody remembers"networkpolicy.networking.k8s.io/allow-dns createdDNS: Address: 10.111.167.47HTTP: wget: download timed out--- the name resolves again and the request still fails. Two separate permissions, and DNS is the one nobody remembers -
NetworkPolicy ingress from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: OTHER-NSAllowing one namespace in. Every namespace carries `kubernetes.io/metadata.name` automatically, so you do not need to label it first.
bash Example session printf 'apiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\nmetadata:\n name: web-ingress-from-client-ns\n namespace: cks-app\nspec:\n podSelector:\n matchLabels:\n app: web\n policyTypes: ["Ingress"]\n ingress:\n - from:\n - namespaceSelector:\n matchLabels:\n kubernetes.io/metadata.name: cks-client\n ports:\n - {protocol: TCP, port: 8080}\n' | kubectl apply -f - 2>&1 | tail -1; sleep 8; echo -n "from cks-client: "; kubectl -n cks-client exec client -- sh -c 'if wget -O /tmp/o -T 6 http://web.cks-app.svc.cluster.local:8080/ 2>/tmp/e; then cat /tmp/o; else grep -m1 "wget:" /tmp/e; fi'networkpolicy.networking.k8s.io/web-ingress-from-client-ns createdfrom cks-client: APP-BACKEND-OK -
kubectl get networkpolicy -APolicies are ADDITIVE - a connection is allowed if any policy allows it. Reading them one at a time is how people get this wrong.
bash Example session kubectl -n cks-client get networkpolicy --no-headers; echo "--- three objects, and the rules are ADDITIVE: a connection is allowed if ANY policy allows it, and denied if none does"allow-dns <none> 23sallow-web <none> 8sdefault-deny <none> 42s--- three objects, and the rules are ADDITIVE: a connection is allowed if ANY policy allows it, and denied if none does
Secrets, etcd and what you ship
-
kubectl -n kube-system exec etcd-NODE -- etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=... --key=... get /registry/secrets/NS/NAMERead a Secret straight out of etcd. This is the check that proves encryption at rest is on - or that it is not.
bash Example session kubectl create ns cks-enc >/dev/null 2>&1; kubectl -n cks-enc create secret generic legacy --from-literal=card=4111-CARD-NUMBER >/dev/null; kubectl -n kube-system exec etcd-cka8001 -- etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/cks-enc/legacy 2>/dev/null | strings | grep -E 'CARD|^k8s' | head -3; echo "--- the value is sitting in etcd in the clear. base64 in the API is an encoding; this is the storage underneath it"4111-CARD-NUMBER--- the value is sitting in etcd in the clear. base64 in the API is an encoding; this is the storage underneath it -
kubectl -n NS get secrets -o json | kubectl replace -f -Encryption applies on WRITE, so existing Secrets stay in plaintext until you rewrite them. Forgetting this is the classic half-finished task.
bash Example session kubectl -n cks-enc get secrets -o json | kubectl replace -f - 2>&1 | tail -2; echo "--- reading and writing every Secret back unchanged is what re-encrypts it. On a real cluster this is -A, and it is the step that gets forgotten"secret/legacy replacedsecret/modern replaced--- reading and writing every Secret back unchanged is what re-encrypts it. On a real cluster this is -A, and it is the step that gets forgotten -
trivy image --scanners vuln --severity CRITICAL --ignore-unfixed --exit-code 1 IMAGEA scan that can gate a build. `--ignore-unfixed` is what makes the gate usable, because most findings in a base image have no published fix.
bash Example session trivy image -q --scanners vuln --severity CRITICAL --ignore-unfixed --exit-code 1 docker.io/library/debian:12 >/dev/null 2>&1; echo "exit code with --ignore-unfixed : $?"; echo "--- and this is the flag that makes the gate usable. Failing a build for a vulnerability with no available fix stops the build and changes nothing about the risk"exit code with --ignore-unfixed : 0--- and this is the flag that makes the gate usable. Failing a build for a vulnerability with no available fix stops the build and changes nothing about the risk -
trivy config --severity HIGH,CRITICAL DIRStatic analysis of a manifest, before a cluster is involved. Each finding carries a KSV identifier you can look up.
bash Example session trivy config -q --severity HIGH,CRITICAL /tmp/sa 2>/dev/null | grep -E 'Tests:|Failures:|^KSV|^AVD' | head -8; echo "--- checks with identifiers, run against a FILE. No cluster was involved, so this is a check that belongs in a pull request rather than at admission"Tests: 22 (SUCCESSES: 17, FAILURES: 5)Failures: 5 (HIGH: 5, CRITICAL: 0)KSV-0009 (HIGH): Deployment 'bad' should not set 'spec.template.spec.hostNetwork' to trueKSV-0010 (HIGH): Deployment 'bad' should not set 'spec.template.spec.hostPID' to trueKSV-0014 (HIGH): Container 'c' of Deployment 'bad' should set 'securityContext.readOnlyRootFilesystem' to trueKSV-0017 (HIGH): Container 'c' of Deployment 'bad' should set 'securityContext.privileged' to falseKSV-0118 (HIGH): deployment bad in default namespace is using the default security context, which allows root privileges--- checks with identifiers, run against a FILE. No cluster was involved, so this is a check that belongs in a pull request rather than at admission -
cosign verify-blob --key cosign.pub --signature FILE.sig FILEVerify a signature against a public key. Change one character in the file and this fails, which is the entire point.
bash Example session cd /tmp/sign && ./cosign-linux-amd64 verify-blob --key cosign.pub --signature release.sig --insecure-ignore-tlog=true release.yaml 2>&1 | tail -1; echo "--- Verified OK means the holder of that private key signed THESE EXACT BYTES"Verified OK--- Verified OK means the holder of that private key signed THESE EXACT BYTES
Find out afterwards, and check before you move on
-
grep '"name":"OBJECT"' /var/log/kubernetes/audit/audit.logWho touched one object, and with which verb. Filter by NAME - filtering by resource returns mostly the control plane watching its own.
bash Example session sudo python3 /tmp/auditq.py '"name":"payroll"' 5; echo "--- create, get and delete, each naming kubernetes-admin. That is the question events could never answer: not what happened, but who did it" kubernetes-admin create secrets/cks-au/payroll RequestResponse 08:59:26 kubernetes-admin get secrets/cks-au/payroll RequestResponse 08:59:26 kubernetes-admin delete secrets/cks-au/payroll RequestResponse 08:59:30 kubernetes-admin get secrets/cks-au/payroll RequestResponse 08:59:30 (4 matching events)--- create, get and delete, each naming kubernetes-admin. That is the question events could never answer: not what happened, but who did it -
hubble observe --to-namespace NS --last 20Who has been talking to the thing that matters. Run it from the cilium-agent on the node, and note that flows are a ring buffer.
bash Example session N=$(kubectl -n attacker get pod foothold -o jsonpath='{.spec.nodeName}'); AG=$(kubectl -n kube-system get pod -l k8s-app=cilium --field-selector spec.nodeName=$N -o jsonpath='{.items[0].metadata.name}'); kubectl -n attacker exec foothold -- curl -s --max-time 8 -o /dev/null http://api.victim.svc.cluster.local:8080/; sleep 3; kubectl -n kube-system exec $AG -c cilium-agent -- hubble observe --to-namespace victim --last 4 2>/dev/null | cut -c1-116; echo "--- filtered by DESTINATION namespace, which is the query an investigator runs: who has been talking to the thing that matters. The source is named, and it is a Pod in another namespace"Aug 26 08:55:40.606: attacker/foothold:48274 (ID:6449) -> victim/api:8080 (ID:40318) to-endpoint FORWARDED (TCP FlagAug 26 08:55:40.606: attacker/foothold:48274 (ID:6449) -> victim/api:8080 (ID:40318) to-endpoint FORWARDED (TCP FlagAug 26 08:55:40.606: attacker/foothold:48274 (ID:6449) -> victim/api:8080 (ID:40318) to-endpoint FORWARDED (TCP FlagAug 26 08:55:40.606: attacker/foothold:48274 (ID:6449) -> victim/api:8080 (ID:40318) to-endpoint FORWARDED (TCP Flag--- filtered by DESTINATION namespace, which is the query an investigator runs: who has been talking to the thing that matters. The source is named, and it is a Pod in another namespace -
kubectl apply -f FILE --dry-run=serverClient dry-run never contacts the apiserver, so it cannot know about admission. Use server when the question is whether the cluster will accept it.
bash Example session printf 'apiVersion: v1\nkind: Pod\nmetadata:\n name: p3\n namespace: tr1\nspec:\n containers:\n - name: c\n image: docker.io/library/busybox:1.37\n command: ["sleep","60"]\n' > /tmp/p3.yaml; echo -n " --dry-run=client : "; kubectl apply -f /tmp/p3.yaml --dry-run=client 2>&1 | tail -1; echo -n " --dry-run=server : "; kubectl apply -f /tmp/p3.yaml --dry-run=server 2>&1 | grep -oE 'forbidden.*' | cut -c1-64; rm -f /tmp/p3.yaml; echo "--- client dry-run never contacts the apiserver, so it cannot know about admission, about Pod Security, about a webhook or about anything else the cluster would say. It checks that your YAML parses. Use --dry-run=server when the question is whether the cluster will accept it" --dry-run=client : pod/p3 created (dry run) --dry-run=server : forbidden: violates PodSecurity "restricted:latest": allowPrivil--- client dry-run never contacts the apiserver, so it cannot know about admission, about Pod Security, about a webhook or about anything else the cluster would say. It checks that your YAML parses. Use --dry-run=server when the question is whether the cluster will accept it -
kubectl -n NS auth can-i VERB RESOURCE --as=system:serviceaccount:NS:SAWithout `--as` you are asking about yourself, and you are admin. Every RBAC task is about somebody else.
bash Example session kubectl -n tr1 create sa app >/dev/null 2>&1; echo -n " can-i list secrets : "; kubectl -n tr1 auth can-i list secrets; echo -n " can-i list secrets --as the ServiceAccount: "; kubectl -n tr1 auth can-i list secrets --as=system:serviceaccount:tr1:app; echo "--- the first answer is about YOU, and you are cluster-admin. Without --as, auth can-i confirms your own permissions and tells you nothing about the identity in the task. Every RBAC question on this exam is about somebody else" can-i list secrets : yes can-i list secrets --as the ServiceAccount: no--- the first answer is about YOU, and you are cluster-admin. Without --as, auth can-i confirms your own permissions and tells you nothing about the identity in the task. Every RBAC question on this exam is about somebody else -
kubectl get netpol -AA manifest with no namespace in its metadata goes to your current context. Check with -A before you call any namespaced task done.
bash Example session printf 'apiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\nmetadata:\n name: default-deny\nspec:\n podSelector: {}\n policyTypes: [Ingress]\n' > /tmp/np.yaml; kubectl apply -f /tmp/np.yaml 2>&1 | tail -1; echo " where it actually landed:"; kubectl get netpol -A --no-headers | awk '{print " ", $1, $2}'; echo -n " policies protecting tr1: "; kubectl -n tr1 get netpol --no-headers 2>&1 | tail -1; rm -f /tmp/np.yaml; echo "--- a manifest with no namespace in its metadata goes to whatever your current context points at, which is almost never the namespace in the question. The policy exists, kubectl said created, and the namespace you were asked to protect has nothing. Put the namespace IN the manifest, and check with -A afterwards"networkpolicy.networking.k8s.io/default-deny created where it actually landed: default default-deny policies protecting tr1: No resources found in tr1 namespace.--- a manifest with no namespace in its metadata goes to whatever your current context points at, which is almost never the namespace in the question. The policy exists, kubectl said created, and the namespace you were asked to protect has nothing. Put the namespace IN the manifest, and check with -A afterwards -
six read-only checks: audit-log-path in the apiserver manifest, a falco/tetragon DaemonSet, hubble Pods, validatingadmissionpolicy, pod-security enforce labels, and netpol -AWhether the cluster can answer "who did it" at all. Six read-only checks that give you a cluster's detection posture in about a minute.
bash Example session yn() { if [ "$1" -gt 0 ]; then echo PRESENT; else echo "absent "; fi; }; printf " %-36s %s\n" "control" "state"; printf " %-36s %s\n" "apiserver audit log" "$(yn $(sudo grep -c 'audit-log-path' /etc/kubernetes/manifests/kube-apiserver.yaml))"; printf " %-36s %s\n" "syscall detector (falco/tetragon)" "$(yn $(kubectl get ds -A --no-headers 2>/dev/null | grep -ciE 'falco|tetragon'))"; printf " %-36s %s\n" "network flow visibility (hubble)" "$(yn $(kubectl -n kube-system get pods --no-headers 2>/dev/null | grep -c hubble))"; printf " %-36s %s\n" "validating admission policies" "$(yn $(kubectl get validatingadmissionpolicy --no-headers 2>/dev/null | wc -l))"; printf " %-36s %s\n" "namespaces enforcing pod security" "$(yn $(kubectl get ns -o jsonpath='{range .items[*]}{.metadata.labels.pod-security\.kubernetes\.io/enforce}{"\n"}{end}' 2>/dev/null | grep -c .))"; printf " %-36s %s\n" "networkpolicies anywhere" "$(yn $(kubectl get netpol -A --no-headers 2>/dev/null | wc -l))"; echo "--- this is the honest state of the cluster this path was built on, after every guide reverted its change. Read it as the shape of a real cluster you inherit, not as a pass mark" control state apiserver audit log absent syscall detector (falco/tetragon) absent network flow visibility (hubble) PRESENT validating admission policies absent namespaces enforcing pod security absent networkpolicies anywhere absent--- this is the honest state of the cluster this path was built on, after every guide reverted its change. Read it as the shape of a real cluster you inherit, not as a pass mark
No command matches that search.