Hedronite · Dev Lesson · Polyglot-Dev / Python · Mon 2026-08-31

Python and the Kubernetes SecurityContext Census — the host that is not the cluster

The client lists Pods. privileged, ape, seccomp, apparmor are attributes. Your loop is not a patch.

Lesson Class: Dev (Python + Kubernetes client CoreV1Api)
Cloud Referent: GKE leftover is Unconfined on COS; this client cannot see image_type
Paired Ops: GKE COS + Shielded + AppArmor-on-the-node
Paired Cert: CKS Q01/Q33 jsonpaths the checker must not write
Grounding: K8sUR3 Ch.13 p.216 · Ch.14 SecurityContext · Q01/Q33 Verify
List
Pods, container securityContext, annotation fallback.
Tags
privileged_host / ape_open / unconfined_seccomp / unconfined_apparmor.
No patch
Q01 writes one Pod. A loop confines the fleet.
The client lists Pods. privileged, ape, seccomp, apparmor are attributes. Your loop is not a patch.

<!-- hal:authoritative:yaml -->

The client lists Pods. privileged, ape, seccomp, apparmor are attributes. Your loop is not a patch.

§I — Frame

The day's Ops lesson names the leftover a GKE node that already shows AppArmor enabled while every Pod still runs Unconfined. COS_CONTAINERD plus both Shielded bits. A Deployment with no securityContext. Autopilot would have deleted the host. gVisor would have changed the runtime. Neither is today's read.

This slot is Python touching K8s. Cert is CKS System Hardening Q01 and Q33. Dev stays on the client. 08-28 already listed Deployments and owned ReplicaSets and refused to patch the image. 08-25 already listed ClusterRoleBindings and refused to delete. 08-22 already listed PersistentVolumes and refused to patch claimRef. 08-19 already read encryptionConfig and could not see etcd. 08-16 already predicted a scheduler no and refused to bind. 08-13 already mutated a RuntimeClass with kopf. None of those tools answer the question Ops just asked: which Pods are privileged, which are Unconfined, which still use the beta AppArmor annotation, and is anyone about to patch a profile from a loop?

Call the failure the census that must not confine. A first draft will read seccomp_profile is None and call patch_namespaced_pod so RuntimeDefault lands. That is Cert's apply on one object. It is not a census. Q01 LabSetUp plants a Pod so a candidate can add appArmorProfile. Q33 LabSetUp plants a Deployment so a candidate can add seccompProfile. A cluster-wide loop that does the same thing writes a host constraint a canary is mid-flight on, writes a constraint a debug Pod needed Unconfined for, and writes a constraint you needed as evidence. Anonymous delete was 08-25. Image patch was 08-28. Today the wrong write is a SecurityContext you did not mean to mint.

Today's tool does one read. CoreV1Api.list_pod_for_all_namespaces for spec. It prints rows whose privileged is True, whose allow_privilege_escalation is True or None, whose seccomp type is missing or Unconfined, whose AppArmor is missing. It does not patch. It does not create. It does not call kubectl apply. It does not call gcloud container node-pools update. The node image is a different client.

§II — Language idiom: the generated core object

K8sUR3 Ch. 13 is blunt about how you talk to this API. The OpenAPI spec is the source. The generated client libraries are how a language consumes it (p. 216). Python's kubernetes package is that generated client. 08-28 used AppsV1Api because Deployment lives on apps/v1. 08-25 used RbacAuthorizationV1Api because ClusterRoleBinding lives on rbac.authorization.k8s.io/v1. 08-22 used CoreV1Api because PersistentVolume is core v1. Pod is core v1. The client is CoreV1Api. There is no AppsV1Api.list_pod_for_all_namespaces. Do not mix the two clients and then wonder why the method is missing.

K8sUR3 Ch. 14 names the fields (PDF pp. 233-241). User, group, fsGroup. readOnlyRootFilesystem. allowPrivilegeEscalation. privileged. seccompProfile. AppArmor via annotation or, on new enough clusters, appArmorProfile. SELinux. capabilities add and drop. The generated class is V1PodSpec.security_context for the Pod, and V1Container.security_context for each container. Container wins when both are set. A census that only reads the Pod object will miss a container that set privileged true under a Pod that set it false.

Load config the way 08-04 already taught. In-cluster uses the projected token. Out-of-cluster uses kubeconfig. Today's tool does not care which, so long as the ServiceAccount can get and list pods. A patch verb is a bug. An update verb is a bug. A create on RuntimeClass is a bug in a different API. The Role that owns this tool lists one resource and two verbs.

from kubernetes import client, config

def core() -> client.CoreV1Api:
    try:
        config.load_incluster_config()
    except config.ConfigException:
        config.load_kube_config()
    return client.CoreV1Api()

The list call returns V1PodList. Each item is a V1Pod. Prefer the attributes. They fail loud when the generated class changes. A dict silently returns None for a typo.

spec.security_context is a V1PodSecurityContext or None. containers[i].security_context is a V1SecurityContext or None. They are different generated types. Pod-level has fs_group and run_as_non_root and seccomp_profile. Container-level has privileged and allow_privilege_escalation and capabilities and seccomp_profile and, on new enough clients, app_armor_profile. There is no privileged on the Pod securityContext. A census that looks for pod.spec.security_context.privileged is reading a field that does not exist. AttributeError is the honest failure. getattr(..., "privileged", None) will hide it and print a green row.

Q01 Verify.bash reads one jsonpath first:

.spec.containers[0].securityContext.appArmorProfile.localhostProfile

Then falls back to the annotation:

.metadata.annotations.container\.apparmor\.security\.beta\.kubernetes\.io/nginx-pod

Q33 Verify.bash reads pod-level spec.template.spec.securityContext.seccompProfile.type on a Deployment, then container-level. Today's tool reads live Pods, not Deployment templates. A template that sets RuntimeDefault and a Pod that has not rolled still prints Unconfined. Print the Pod. Print the owner. Do not patch the template to make the Pod row go green.

None on seccomp_profile is not Unconfined. Unconfined is an explicit type that says "do not filter." Missing is "the runtime default may or may not apply," and Rice already told you Kubernetes does not apply the Docker default AppArmor without an annotation (Ch. 8, p. 98). Tag them separately. absent versus unconfined versus runtime_default versus localhost. Collapse them and you cannot tell Q33's missing file from a PSA-restricted namespace that already enforced RuntimeDefault.

IntOrString is not today's field. Capabilities are lists of strings. Print them. Do not re-implement the bounding set. A wrong drop is worse than no drop.

§III — Worked example: the census that will not confine

from dataclasses import dataclass
from typing import Optional

from kubernetes import client, config

DANGER_TRUE = True

@dataclass(frozen=True)
class Row:
    ns: str
    pod: str
    container: str
    privileged: Optional[bool]
    ape: Optional[bool]
    seccomp: str
    apparmor: str
    why: str


def _seccomp(sc) -> str:
    if sc is None or sc.seccomp_profile is None:
        return "absent"
    t = sc.seccomp_profile.type or "absent"
    if t == "Localhost":
        return "localhost:" + (sc.seccomp_profile.localhost_profile or "")
    return t


def _apparmor(pod: client.V1Pod, c: client.V1Container) -> str:
    sc = c.security_context
    if sc is not None and getattr(sc, "app_armor_profile", None) is not None:
        prof = sc.app_armor_profile
        if prof.type == "Localhost":
            return "localhost:" + (prof.localhost_profile or "")
        return prof.type or "absent"
    key = "container.apparmor.security.beta.kubernetes.io/" + c.name
    anns = pod.metadata.annotations or {}
    if key in anns:
        return "annotation:" + anns[key]
    psc = pod.spec.security_context
    if psc is not None and getattr(psc, "app_armor_profile", None) is not None:
        prof = psc.app_armor_profile
        return (prof.type or "absent")
    return "absent"


def census(api: client.CoreV1Api) -> list[Row]:
    rows: list[Row] = []
    token = None
    while True:
        resp = api.list_pod_for_all_namespaces(limit=200, _continue=token)
        for pod in resp.items:
            if not pod.status or pod.status.phase in {"Succeeded", "Failed"}:
                continue
            for c in pod.spec.containers:
                sc = c.security_context
                priv = None if sc is None else sc.privileged
                ape = None if sc is None else sc.allow_privilege_escalation
                sec = _seccomp(sc) if sc is not None else _seccomp(pod.spec.security_context)
                aa = _apparmor(pod, c)
                why = "ok"
                if priv is True:
                    why = "privileged_host"
                elif ape is True or ape is None:
                    why = "ape_open"
                elif sec in {"absent", "Unconfined"}:
                    why = "unconfined_seccomp"
                elif aa == "absent":
                    why = "unconfined_apparmor"
                if why != "ok":
                    rows.append(Row(pod.metadata.namespace, pod.metadata.name, c.name, priv, ape, sec, aa, why))
        token = resp.metadata._continue if resp.metadata else None
        if not token:
            break
    return rows

Pagination is not optional. 08-25 already hit continue tokens on ClusterRoleBindings. A year-old GKE cluster has more Pods than 200. A census that prints page one and exits 0 is a lie. _continue is the private attribute on V1ListMeta. The public name in JSON is continue, which is a Python keyword, so the generated field is _continue. If you write resp.metadata.continue you get a syntax error. If you write resp.metadata["continue"] you have abandoned the typed object. Use the attribute the stub actually grew.

Skip Succeeded and Failed. A Job that already exited still carries its original securityContext. Tagging it every morning trains the on-call to ignore the printer. Pending is live: the Pod has not been admitted yet, or it has, and the node has not started it. Print Pending. A restricted PSA namespace will keep it Pending for missing RuntimeDefault. That row is the leftover.

kube-system is a policy, not a default skip. kube-proxy on COS may be privileged on purpose. If you drop the namespace you will never see it, and you will never file the exception. Print it with why=privileged_host and a note in the ticket template that kube-proxy is expected. A custom controller you installed into kube-system is not expected. The census does not know the difference. The owner column does.

hostNetwork, hostPID, hostIPC, and a hostPath volume are Pod-level holes the container securityContext will not mention. Add four extra tags if those spec fields are true or if any volume has host_path not None. docker.sock as a hostPath is Q12's cousin. It is not Q01. Print host_path=/var/run/docker.sock and stop. Do not delete the volume from a loop. 08-22 already taught that a leftover checker that patches volumes deletes the evidence.

Do not list Deployments as the primary object. Q33's verify greps the Deployment because the exam object is a Deployment. The fleet object that is running is the Pod. A template can say RuntimeDefault while an old ReplicaSet still owns Unconfined Pods. 08-28 already taught you to join owner_references. Reuse that join: print owned_by=ReplicaSet/web-5d4c. Then a human patches the Deployment once. The census still does not.

allow_privilege_escalation is None tags ape_open because K8sUR3 Ch. 14 says the default is true, and true if privileged is true. An unset field is not a deny. PSA restricted will demand false. Until admission does, the census does.

Exit codes. Zero rows of danger: exit 0. Any privileged_host: exit 2. Only ape/seccomp/apparmor leftovers: exit 1. The on-call needs the privileged case to page louder than Unconfined nginx. Do not collapse to a boolean.

Never call patch_namespaced_pod. Never call patch_namespaced_deployment. Q33's verify greps the Deployment. A helper that "fixes" by patching the live Pod leaves the template Unconfined and the next replica comes back loud. Print the owner_references kind and name so a human knows which template to edit. Then stop.

GKE Autopilot still lists Pods. The node image is gone. Print rbac_census_only is the 08-25 joke. Today's analog: print pod_census_only. The operator still has to run gcloud container node-pools describe. This client cannot see COS_CONTAINERD. This client cannot see enable_secure_boot. Ops named those fields. Stay here for the Pod.

§IV — Connection to today's Ops lesson

Ops coined the host that is not the cluster. Node pool image, Shielded wrap, AppArmor enabled on describe node, Unconfined on the Deployment. This file is that Unconfined as a typed list.

The Python client cannot set image_type. It cannot flip Secure Boot. It cannot run apparmor_parser. Rice already told you the profile lives in the kernel (Ch. 8, p. 97). GKE COS already loaded one. The client sees whether the Pod asked for it. It does not see the kernel file. If the task is apparmor_parser, you are in the Cert file. Stay here for the object.

Ops printed a describe of the node pool and a describe of the node. Those are gcloud and kubectl. This lesson is the Pod half with pagination, exit codes, and a frozen dataclass. The desk check misses page two. The tool should not.

Ops also named Autopilot as the product that deletes the host. This client will still return Pods on Autopilot. Absence of a node-pool field in this list is not absence of a host. It is a different API. Do not let a green Pod census become a Shielded report.

§V — Prior-lesson reach

08-28 listed Deployments, printed CURRENT against DESIRED, and refused to patch the image. Today's shape is the same shape on a different object. CoreV1Api instead of AppsV1Api. A SecurityContext instead of a ReplicaSet. Patch instead of patch as the forbidden write, same verb, different field. The reason is the same: LabSetUp is a one-object cleanup. A loop is a fleet event.

08-25 listed ClusterRoleBindings and refused to delete. Today's analog: the client can see the leftover Unconfined Pod and cannot see the COS image. Two blinds. Two honest tools. Do not pretend a list of RuntimeDefault Pods proves Shielded Nodes.

08-22 listed PersistentVolumes, printed claimRef, and refused to patch. A leftover volume is not a leftover profile. Do not open claimRef.

08-19 read encryptionConfig and could not see etcd. Today's analog is sharper: the client can see appArmorProfile and cannot see /etc/apparmor.d. Q01 Verify.bash SSHes for aa-status. This process does not SSH.

08-16 predicted a scheduler no and did not bind. Today predicts nothing. It reports objects that already exist.

08-13's kopf operator applied RuntimeClass. That was a write. Today's client has no create method in the file. If you reach for kopf because "operator" sounds like hardening, you are writing SecurityContext from a reconcile loop. That is how a leftover gets recreated after a human set Unconfined on purpose. Inventory is a timer that prints. It is not a handler that applies.

08-10 watched events with resourceVersion. Today's list is not a watch. Profiles change rarely. A periodic list that exits is enough.

One more collision to refuse: 08-15's Workload Identity binding. That lesson asked who the Pod is. Today's attributes ask what the process may do on the kernel. A GSA-KSA pair can be correct and the container can still be privileged. Print both on different days. Do not join them in this dataclass.

The generated app_armor_profile attribute is new. Older kubernetes wheels will raise AttributeError on getattr if you access it as a required field. The code uses getattr(..., None) only for that one field, because the annotation fallback is the compatibility path Q01 Verify.bash already wrote. Do not getattr the rest. privileged must fail loud if the stub drops it.

Frozen dataclass is the row. A dict invites a later line that mutates why after append. 08-23 already spent __repr__ and field(repr=False) on credentials. Today's row has no secrets. Print it raw. Do not redact Unconfined.

§VI — Close

privileged is the host. Unconfined is the leftover. Localhost is the exam. RuntimeDefault is the fleet. The client prints those four. It does not write them.

Read list_pod_for_all_namespaces first. Then the container securityContext. Then the annotation fallback Q01 still accepts. Decide whether you are on the exam or on the fleet. The exam wants one Pod patched by hand. The fleet wants a CSV of leftovers. Neither wants a loop that confines the namespace.

Examine well. The host is not the cluster. Your loop is not a parser.

Related