Skip to main content
Source

This page is generated from skills/eks-operation-review/references/access-identity.md. Edit the source, not this page.

Vendored skill

This skill is sourced from eks-operation-review, also maintained by the APEX team.

Access & Identity

Purpose

Assess IAM and RBAC configuration for security and operational excellence — pod-level permissions, least privilege, and API server access controls.

Checks to Execute

3.1 — Pod-Level AWS Permissions (IRSA / EKS Pod Identity)

What to check:

  • OIDC provider configured (prerequisite for IRSA)
  • EKS Pod Identity associations
  • Service accounts with IRSA annotations (eks.amazonaws.com/role-arn)
  • Node IAM role policies (are they overly broad?)
  • AWS credentials in Kubernetes Secrets or pod env vars

How to check:

  1. Describe cluster → identity.oidc.issuer (OIDC configured?). 403 fallback: If 403/Forbidden on DescribeCluster → mark the OIDC-configured signal UNKNOWN (do not conclude OIDC/IRSA is absent); this is a core EKS API so 404 is not expected.
  2. List Pod Identity associations. 403 fallback: If 403/Forbidden when listing Pod Identity associations → mark the Pod-Identity signal UNKNOWN (do not conclude Pod Identity is absent); this is a core EKS API so 404 is not expected, and an empty successful list means none exist. Do not let a forbidden read satisfy the RED 'No IRSA/Pod Identity' arm.
  3. List ServiceAccounts across all namespaces → filter for IRSA annotation. 403 fallback: If 403/Forbidden when listing ServiceAccounts → mark the IRSA-adoption signal UNKNOWN (do not conclude IRSA is absent); ServiceAccount is a core v1 API so 404 is not expected; an empty successful list means no IRSA-annotated SAs. If IRSA adoption cannot be enumerated AND Pod Identity associations are empty, rate 3.1 UNKNOWN rather than RED.
  4. List node groups → describe first one → get nodeRole → list attached managed policy names for that role (aws iam list-attached-role-policies). Then enumerate inline policies (aws iam list-role-policies) and read each inline policy document (aws iam get-role-policy --role-name <role> --policy-name <name>); evaluate inline policy statements for overly-broad permissions (wildcards like "Action": "*" or "*:*", or "Resource": "*" on sensitive services) the SAME way attached managed policies are judged. 403 fallback: If 403/Forbidden on any of the IAM reads (ListAttachedRolePolicies, ListRolePolicies, GetRolePolicy, or the node-group describe) → mark the node-role-breadth signal UNKNOWN (do not conclude the node role is minimal); these are core IAM/EKS APIs so 404 is not expected, and an empty successful list means no such policies exist. Do not award the GREEN 'node role minimal' precondition on a forbidden read, and do not fire the RED 'node role has broad permissions' arm unless the IAM read SUCCEEDED.
  5. List Secrets across namespaces → project key names only (never values), enriched with namespace/name so findings are attributable: kubectl get secrets -A -o go-template='{{range .items}}{{.metadata.namespace}}/{{.metadata.name}}: {{range $k,$v := .data}}{{$k}} {{end}}{{"\n"}}{{end}}' — flag secrets whose key names match the credential-shaped pattern set (exact: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN; substring: SECRET, TOKEN, PASSWORD, API_KEY). Never record raw Secrets output — credential values must never appear in output. 403 fallback: If 403/Forbidden when listing Secrets → mark the Secret-key-name signal UNKNOWN (do not conclude no credential-shaped Secret keys exist); Secret is a core v1 API so 404 is not expected, and an empty successful list means none exist. Do not award the GREEN 'no credential-shaped Secret-key names' precondition on a forbidden list. R1(4) — AMBER-with-note, not whole-check UNKNOWN: when the other 3.1 signals are confirmed GREEN-worthy (OIDC/Pod Identity configured, IRSA/Pod Identity in use, node role confirmed minimal by successful IAM reads, pod env-var scan clean) and only this Secrets-list read 403s, cap 3.1 at AMBER-with-note ("IRSA/identity posture looks good but the Secret-key-name scan could not be completed — verify no hardcoded credentials in Secrets"), not whole-check UNKNOWN. Whole-check UNKNOWN only when this 403 is the sole discriminator (nothing else confirmed). A confirmed RED (hardcoded creds found, or a broad node role) survives this 403 as a floor.
  6. List pods → detect credential-shaped env var names only (never values) across containers, initContainers, and envFrom refs: kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{range .spec.containers[*].env[*].name}{@}{" "}{end}{range .spec.containers[*].envFrom[*].configMapRef.name}{@}{" "}{end}{range .spec.containers[*].envFrom[*].secretRef.name}{@}{" "}{end}{range .spec.initContainers[*].env[*].name}{@}{" "}{end}{range .spec.initContainers[*].envFrom[*].configMapRef.name}{@}{" "}{end}{range .spec.initContainers[*].envFrom[*].secretRef.name}{@}{" "}{end}{"\n"}{end}' | grep -iE 'SECRET|TOKEN|PASSWORD|API_KEY|AWS_ACCESS|AWS_SECRET|AWS_SESSION'. Credential-shaped = exact AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN or substring SECRET, TOKEN, PASSWORD, API_KEY. Per output line, the tokens after the tab are env var names first, then envFrom secretRef/configMapRef names, in command order. Report each finding as <ns>/<pod> — credential-shaped <env var|secret ref|configMap ref> name found: <name> (value never read). 403 fallback: If 403/Forbidden when listing pods → mark the credential-shaped-env-var signal UNKNOWN (do not conclude none detected); do not award the GREEN 'no credential-shaped names' precondition on a forbidden list. R1(4) — AMBER-with-note, not whole-check UNKNOWN: when the other 3.1 signals are confirmed GREEN-worthy (OIDC/Pod Identity configured, IRSA/Pod Identity in use, node role confirmed minimal by successful IAM reads) and only this pod list 403s, cap 3.1 at AMBER-with-note ("IRSA/identity posture looks good but the pod env-var scan could not be completed — verify no hardcoded credentials in pod env vars"), not whole-check UNKNOWN. Whole-check UNKNOWN only when this 403 is the sole discriminator (nothing else confirmed). A confirmed RED (hardcoded creds found, or a broad node role) survives this 403 as a floor.

Rating:

  • 🟢 GREEN: On observable signals only — OIDC/Pod Identity configured, IRSA or Pod Identity in use, node role minimal, and no credential-shaped env-var/Secret-key NAMES detected via the name-only scan; route Secrets-value verification to Investigate Manually
  • 🟡 AMBER: IRSA partially adopted, or node role has some extra permissions, or credential-shaped env-var/Secret key NAMES detected that are not AWS-specific (e.g. API_KEY, PASSWORD, generic TOKEN) — a hygiene concern; value never read, so route value-verification to Investigate Manually. Exception — envFrom secretRef is informational only: an envFrom secretRef (a Secret-sourced ref) whose NAME matches a credential substring (e.g. a secretRef named app-secrets matching the substring SECRET) is informational ONLY and is never a band signal — it never drives AMBER or RED; report it for context but do not let it set the band. An envFrom configMapRef is different: a configMapRef sources from a ConfigMap (a PLAINTEXT store), so a configMapRef whose NAME matches a credential substring is NOT blessed / informational-only — it is still worth flagging: cap 3.1 at AMBER — a hygiene concern (credential-shaped name sourced from a plaintext ConfigMap) — with a note routed to Items to Investigate Manually.
  • 🔴 RED: No IRSA/Pod Identity, node role has broad permissions (S3FullAccess, DynamoDBFullAccess by attached-policy name, or an inline policy granting overly-broad access such as "Action": "*"/"*:*" or "Resource": "*" on sensitive services), or AWS credentials hardcoded as a literal value: — report the pod/secret location and key/env-var name only; never record or echo the credential value. Confirm before rating RED (do not auto-RED on a name match): a credential-shaped AWS name (e.g. AWS_SECRET_ACCESS_KEY) is RED only when it is set via a literal value: in the pod spec. If instead it is populated by a Secret-sourced reference — valueFrom.secretKeyRef (or an envFrom secretRef) — that is the RECOMMENDED injection pattern and is NOT a finding: route it to Investigate Manually. ConfigMap-sourced refs are NOT blessed: valueFrom.configMapKeyRef (and envFrom.configMapRef) source from a ConfigMap, a PLAINTEXT store, so a credential-shaped name sourced that way is still worth flagging — cap 3.1 at AMBER — a hygiene concern (credential-shaped name sourced from a plaintext ConfigMap) — with a note routed to Items to Investigate Manually, not "not a finding." The name-only scan cannot by itself distinguish a hardcoded literal from an injected reference, so any credential-shaped AWS name it detects must be manually confirmed (value: → RED; valueFrom.secretKeyRef/envFrom secretRef → not a finding, Investigate Manually; valueFrom.configMapKeyRef/envFrom configMapRef → still worth flagging, cap 3.1 at AMBER with a note routed to Items to Investigate Manually) rather than auto-rated RED.
  • ⬜ UNKNOWN: A required signal cannot be enumerated — e.g. DescribeCluster (OIDC), the ServiceAccount list, Pod Identity associations, the IAM node-role reads (ListAttachedRolePolicies/ListRolePolicies/GetRolePolicy), the Secrets list, or the pod list returned 403/Forbidden, so OIDC/IRSA adoption, node-role breadth, or credential-shaped env-var/Secret-key names cannot be determined (see the step 1, 2, 3, 4, 5, and 6 403 fallbacks)
  • Investigate Manually: Which pods actually require AWS access (vs over-provisioned ServiceAccounts) is not observable from cluster state — flag for manual review; it is NOT a band discriminator.
  • Evaluation order: assess RED first; if not RED, assess AMBER; otherwise GREEN. Keeps the bands exhaustive and non-overlapping. R1 routing: a forbidden read marks only its own signal UNKNOWN — it never downgrades a confirmed RED/AMBER signal (e.g. a node role confirmed broad by a successful IAM read stays RED even if the ServiceAccount or pod list 403s), never satisfies a RED arm on unconfirmed absence, and never awards a GREEN precondition; rate the whole check UNKNOWN only when no successfully-read signal yields a color.

Key talking point: Node-level IAM = every pod on that node inherits the same permissions. One compromised pod gets access to everything.


3.2 — Least Privilege RBAC

What to check:

  • ClusterRoleBindings to cluster-admin (count and subjects)
  • ClusterRoles with wildcard permissions (* verbs on * resources)
  • Ratio of namespace-scoped RoleBindings vs cluster-scoped ClusterRoleBindings
  • Service accounts with cluster-admin

How to check:

  1. List ClusterRoleBindings → filter roleRef.name == "cluster-admin" → count and list subjects. Exclude a binding as a default RBAC-bootstrap binding ONLY when BOTH of these hold: (a) its NAME matches the known-defaults allowlist below, AND (b) it carries the label kubernetes.io/bootstrapping=rbac-defaults. Kubernetes/EKS ships API-server-managed system ClusterRoleBindings on every cluster, labels them kubernetes.io/bootstrapping=rbac-defaults, and recreates them if deleted; a binding satisfying BOTH (a) and (b) is never a finding. The known-defaults NAME allowlist is: cluster-admin (the cluster-admin → Group system:masters binding — this is how kubectl admin access works; system:masters is a built-in group baked into the API server), system:node, the system:controller:* bindings, and the other system:-prefixed default bindings Kubernetes ships as part of the RBAC bootstrap. Neither condition alone is sufficient: the NAME is attacker-settable (a rogue cluster-admin binding could be named system:controller:foo to slip past a name-only filter) and the LABEL is attacker-settable too, so BOTH the allowlist match AND the label are required to exclude — the name allowlist is kept as a backstop against a forged label. roleRef gate (third condition, closes the forge-both bypass): because BOTH name and label are attacker-settable, an attacker who forges both could otherwise slip a powerful binding through the AND-gate (the allowlist literally contains the system:controller:* wildcard). So add a roleRef check: the genuine system:node / system:controller:* bootstrap bindings reference their own narrowly-scoped system: ClusterRoles, NEVER cluster-admin and NEVER a wildcard (*/*) ClusterRole. If a binding that matches the name allowlist references cluster-admin or a wildcard ClusterRole, it is NOT exempt regardless of its name or label — drop it from the exclusion and evaluate it for RED. Exclude a binding only when all three hold: (a) name in allowlist, (b) kubernetes.io/bootstrapping=rbac-defaults label present, AND (c) its roleRef is not cluster-admin and not a wildcard ClusterRole (the sole intended exception being the API-server-managed cluster-adminsystem:masters binding named exactly cluster-admin). Subject check on the excluded cluster-admin binding (closes the subject-tamper gap): the binding-name + label + roleRef gate still exempts the genuine cluster-admin binding from the "cluster-admin CRB exists" RED, but that exemption covers ONLY its sole legitimate subject, Group system:masters. The RBAC reconciler does not strip extra subjects (it runs with --remove-extra-subjects default false) even with autoupdate=true, so an operator-injected subject persists silently. Therefore ANY subject on the excluded cluster-admin binding OTHER than Group system:masters (e.g. an added Group, User, or ServiceAccount) is operator-introduced and is independently RED — assess it even though the binding itself is exempt. FLAG (never exclude) ANY wildcard (*/*) or cluster-admin binding that LACKS the kubernetes.io/bootstrapping=rbac-defaults label, regardless of its name — an unlabeled powerful binding is operator-created and must be assessed (see RED). Do NOT exclude system:authenticated or system:unauthenticated — although they are system:-prefixed, a wildcard/cluster-admin binding to either is operator-introduced and a critical finding (see RED). Assess only the remaining (operator-created) bindings.
  2. List ClusterRoles → check rules for verbs: ["*"] and resources: ["*"]
  3. Count RoleBindings across all namespaces vs ClusterRoleBindings
  4. List application namespaces (exclude kube-system, kube-public, kube-node-lease, default)
  5. 403 fallback: If 403/Forbidden when listing ClusterRoleBindings, ClusterRoles, RoleBindings, or namespaces (step 4) → mark the corresponding RBAC signal UNKNOWN (do not infer absence from a forbidden list); if the cluster-admin/wildcard bindings or the namespace-scoped RoleBindings cannot be enumerated, rate 3.2 UNKNOWN rather than GREEN or RED. In particular do not award the GREEN/AMBER 'namespace-scoped RBAC present' precondition, nor fire the RED 'no namespace-scoped RBAC isolation at all' arm, on a forbidden RoleBinding or namespace list. (These are core rbac.authorization.k8s.io / v1 APIs, so 404 is not expected; an empty successful list means none exist.)

Rating:

  • 🟢 GREEN: Namespace-scoped RBAC present, and — after excluding the default RBAC-bootstrap bindings (step 1) — every remaining (operator-created) binding of a wildcard (*/*) ClusterRole (cluster-admin is itself one) is to a named human User — at most 2 such break-glass bindings, and none to a broad subject. The named-human-User qualifier is a hard precondition, not a default: a remaining binding whose subject is anything other than a named human User can never be GREEN — it is a broad subject and is RED. A normal cluster (the default cluster-adminsystem:masters binding present but excluded as a bootstrap binding, namespace-scoped RBAC in place, and no operator-added broad bindings) is GREEN. GREEN predicate — "namespace-scoped RBAC present" is decidable as: at least one operator-created namespace-scoped RoleBinding exists. The default RBAC-bootstrap RoleBindings (those carrying the label kubernetes.io/bootstrapping=rbac-defaults, e.g. the system:-prefixed defaults in kube-system) do NOT on their own satisfy this precondition — a cluster whose only RoleBindings are bootstrap defaults has no operator-configured namespace RBAC isolation and fails the GREEN predicate (it falls to the RED "no namespace-scoped RBAC isolation at all" arm).
  • 🟡 AMBER: Namespace-scoped RBAC present, but cluster-admin (or another wildcard */* ClusterRole) overused — bound to 3 or more named human-user bindings (skill-defined heuristic, not an AWS-published threshold; counting operator-created bindings only, after excluding the default bootstrap bindings), with none of those bindings to a broad subject. This is the count-based concern only
  • 🔴 RED: An operator-created wildcard (*/*) ClusterRole binding — including cluster-admin — to a broad subject, regardless of binding count, or no namespace-scoped RBAC isolation at all — the qualitatively-worse conditions, evaluated independently of the human break-glass binding count. Assessed only over the bindings that remain after the step 1 exclusion of default RBAC-bootstrap bindings. The exclusion is scoped to specific default bootstrap BINDINGS, identified by the binding — NOT to a class of subjects. The only bindings excluded are the API-server-managed cluster-adminsystem:masters ClusterRoleBinding and the system:-prefixed controller/node bootstrap bindings that Kubernetes ships and recreates if deleted; only a properly-labeled bootstrap binding (carrying kubernetes.io/bootstrapping=rbac-defaults, per the step-1 AND-gate) never triggers RED — a label-stripped binding that merely textually matches a bootstrap name is NOT excluded and must still be assessed for RED. An operator-created binding is never a default bootstrap binding and is always assessed — even when its subject is system:-prefixed (e.g. an operator binding cluster-admin to system:serviceaccounts or system:authenticated is RED). A broad subject is defined precisely as ANY of: (a) a Group that is not part of a default bootstrap binding — this includes system:serviceaccounts, system:serviceaccounts:<ns>, system:authenticated, system:unauthenticated, system:nodes, and any custom Group such as dev-team (binding a wildcard ClusterRole to any of these is a critical finding even when the group name is system:-prefixed, because the binding is operator-created, not a bootstrap binding); (b) ANY ServiceAccount — default OR non-default (a cluster-admin binding to any SA, including default:default or kube-system:default, is a finding; there is no non-default qualifier); or (c) system:anonymous. So a single operator-added ClusterRoleBinding of cluster-admin to one Group (e.g. dev-team with 50 members), to system:serviceaccounts, or to the default ServiceAccount is RED even though it is one binding — and any non-system:masters subject on the excluded cluster-admin binding is independently RED, per step 1
  • ⬜ UNKNOWN: The RBAC lists (ClusterRoleBindings, ClusterRoles, RoleBindings, or the namespace list) returned 403 so the relevant bindings cannot be enumerated AND no successfully-read RBAC signal already yields a color — suggest user investigate
  • Investigate Manually: Whether RBAC is reviewed periodically is not observable from cluster state — flag for manual review; it is NOT a band discriminator and never drives UNKNOWN.
  • Evaluation order: assess RED first; if not RED, assess AMBER; otherwise GREEN. Keeps the bands exhaustive and non-overlapping. Because "broad subject" (RED) now covers every subject that is not a named human User (any Group not part of a bootstrap binding, any ServiceAccount, system:anonymous), and the excluded set is only the specific default bootstrap bindings, there is no "otherwise GREEN" fall-through for an unclassified subject: any remaining operator-created wildcard binding to a non-human, non-excluded subject is caught by RED before GREEN is ever reached. GREEN's named-human-User precondition is binding, so GREEN is awarded only when every remaining binding is confirmed to a named human User. R1 routing: a 403 on one RBAC list marks only that signal UNKNOWN; a signal already confirmed by a successful read holds its color as a floor — e.g. a cluster-admin binding to a broad subject confirmed by a successful ClusterRoleBinding read stays RED even if the ClusterRole or RoleBinding list 403s (the unread lists can only reveal more risk, never less). A forbidden read never satisfies a RED arm on unconfirmed absence and never awards a GREEN/AMBER precondition; rate the whole check UNKNOWN only when no successfully-read signal yields a color.
  • Scoring authority: this check owns least-privilege RBAC / cluster-admin scoring; check 2.4 defers here for the cluster-admin signal.

3.3 — EKS API Server Endpoint & Network Access

What to check:

  • Public/private endpoint configuration
  • Public access CIDR restrictions
  • Cluster security group inbound rules

How to check:

  1. Describe cluster → resourcesVpcConfig.endpointPublicAccess, endpointPrivateAccess, publicAccessCidrs. This is the endpoint-config signal, rated on its own successful read: public open to 0.0.0.0/0 → endpoint-RED; public CIDR-restricted with private access disabled → endpoint-AMBER; private enabled with public disabled or CIDR-restricted → endpoint-GREEN-worthy. 403 fallback: If 403/Forbidden on DescribeCluster → mark ONLY the endpoint-config signal UNKNOWN (do not conclude the endpoint is private/restricted); this is a core EKS API so 404 is not expected. If both the endpoint signal and the SG read are unavailable (no successfully-read signal yields a color), rate 3.3 UNKNOWN rather than GREEN.
  2. Describe cluster → resourcesVpcConfig.clusterSecurityGroupId, then read that security group's inbound rules with ec2:DescribeSecurityGroupRules --filters group-id=<clusterSecurityGroupId> → check for overly-broad ingress (0.0.0.0/0 on sensitive ports). This is the SG-ingress signal, independent of the endpoint-config signal. 403 fallback (R1, floor-preserving): If 403/Forbidden on DescribeSecurityGroupRules → mark ONLY the SG-ingress signal UNKNOWN (do not conclude no open rule exists); this is a core EC2 API so 404 is not expected, and an empty successful rule list means no such ingress exists. The RED 'cluster SG allows 0.0.0.0/0 inbound on sensitive ports' arm is only reachable when this read SUCCEEDED, and the SG signal itself never resolves to GREEN on a forbidden read. But an SG 403 (a) does NOT downgrade a confirmed endpoint-RED or endpoint-AMBER — those are rated on the step-1 read alone and stand as a floor (an SG 403 can only reveal more risk, never less); and (b) does NOT let a GREEN-worthy endpoint reach GREEN on the unconfirmed SG signal — when the endpoint is otherwise GREEN-worthy and the SG read 403s, cap at AMBER-with-note ("endpoint hardened but security-group ingress could not be verified"), not UNKNOWN and not GREEN. Note the SG-ingress uncertainty under Investigate Manually; never treat 'no open rule found' as confirmed when the read was forbidden.

Rating:

  • 🟢 GREEN: Private endpoint enabled, public either disabled or CIDR-restricted, and the step-2 SG read SUCCEEDED and found no 0.0.0.0/0 ingress on sensitive ports. GREEN is unreachable if the SG read was forbidden (the open-ingress RED trigger would be undeterminable) — that case caps at AMBER-with-note.
  • 🟡 AMBER: Public endpoint CIDR-restricted with private access disabled (endpoint-AMBER, confirmed on the step-1 read alone — holds regardless of whether the step-2 SG read succeeded, 403'd, or found no open ingress); or the endpoint is otherwise GREEN-worthy (private enabled, public disabled/CIDR-restricted) but the step-2 SG read 403'd — AMBER-with-note "endpoint hardened but security-group ingress could not be verified"
  • 🔴 RED: Public endpoint open to 0.0.0.0/0 (rated on the step-1 read alone; a step-2 SG 403 does not downgrade this), or the cluster security group allows 0.0.0.0/0 inbound on sensitive ports (this SG arm only when the step-2 SG read SUCCEEDED)
  • ⬜ UNKNOWN: DescribeCluster returned 403 so the endpoint config cannot be read AND the SG-ingress read is also unavailable — i.e. no successfully-read signal yields a color. (When the endpoint config was read successfully, its color is a floor and the check is never UNKNOWN: a confirmed endpoint-RED/AMBER stays RED/AMBER, and a GREEN-worthy endpoint with an SG 403 caps at AMBER-with-note, never UNKNOWN.) Suggest user investigate.
  • Investigate Manually: If the step-2 SG read returned 403, the SG-ingress is unconfirmed — flag it for manual verification and never treat 'no open rule found' as confirmed; this note routes the rating per the bands above (AMBER-floor or AMBER-with-note), it does not force the whole check to UNKNOWN. Whether MFA/SSO is required for API-server access is not observable from cluster state — flag for manual review; it is NOT a band discriminator.
  • Evaluation order: assess RED first; if not RED, assess AMBER; otherwise GREEN. Keeps the bands exhaustive and non-overlapping. R1 routing: rate the endpoint-config signal on its step-1 read and the SG-ingress signal on its step-2 read independently; a forbidden read marks ONLY its own signal UNKNOWN and never downgrades the other confirmed signal (floor). Because a GREEN-worthy endpoint with a forbidden SG read caps at AMBER-with-note (never GREEN, never UNKNOWN), the "otherwise GREEN" line can never award GREEN on a 403.

Key talking point: An API server open to 0.0.0.0/0 is exposed to the internet. You're relying entirely on authentication.


3.4 — Pod Security Admission (PSA)

What to check:

  • Pod Security Standards enforcement via namespace labels (pod-security.kubernetes.io/enforce)
  • Which namespaces have PSA labels and at what level (privileged, baseline, restricted)
  • Production namespaces without PSA enforcement

How to check:

  1. List namespaces → inspect labels for pod-security.kubernetes.io/enforce, pod-security.kubernetes.io/warn, pod-security.kubernetes.io/audit
  2. Count namespaces with enforcement vs without (exclude kube-system, kube-public, kube-node-lease). 403 fallback: If 403/Forbidden when listing namespaces or reading their labels → mark the PSA-label signal UNKNOWN (do not conclude PSA is absent); Namespace is a core v1 API so 404 is not expected. Do not rate RED for 'no PSA labels' on a forbidden namespace list; route to UNKNOWN.
  3. Check if any application namespaces use privileged enforce level
  4. Detect whether a third-party policy engine handles pod security instead: list for Gatekeeper CRDs (constrainttemplates.templates.gatekeeper.sh) and Kyverno CRDs (policies.kyverno.io, clusterpolicies.kyverno.io), or their controller deployments (e.g. gatekeeper-controller-manager in gatekeeper-system, kyverno in kyverno). Treat the engine as present if any such CRD or controller is found; not present on a clean 404/empty result; and undetermined on 403 (access denied) — an undetermined result routes the no-labels case to UNKNOWN, not RED.

Rating:

  • 🟢 GREEN: PSA labels on all application namespaces, baseline or restricted enforcement
  • 🟡 AMBER: PSA labels on some namespaces but not all, or only warn/audit mode (no enforcement)
  • 🔴 RED: No PSA labels on any namespace (and no third-party policy engine detected), or application namespaces set to privileged
  • ⬜ UNKNOWN: No PSA labels on any namespace but a third-party admission controller (OPA/Gatekeeper, Kyverno) is detected (pod security may be handled there), or the step-4 detection returned 403 so third-party coverage cannot be determined, or the namespace list / label read returned 403 so the PSA-label signal cannot be enumerated (do not rate RED in that case)
  • Evaluation order: assess RED first; if not RED, assess AMBER; otherwise GREEN. Keeps the bands exhaustive and non-overlapping. R1 routing: a 403 marks only its own signal UNKNOWN; a PSA-label signal read successfully (from the namespace/label list) holds its color as a floor — a confirmed privileged application namespace stays RED, and a confirmed partial-labels AMBER stays AMBER, even if the step-4 engine detection 403s (that 403 only routes the no-labels case to UNKNOWN, and can never make a confirmed misconfiguration better). A forbidden read never satisfies a RED arm on unconfirmed absence. RED-vs-UNKNOWN precedence (no-labels case): when the namespace/label list was read successfully and shows no PSA labels, the outcome depends solely on the step-4 engine detection — engine confirmed absent (clean 404/empty) → RED; engine detection 403 (undetermined) → UNKNOWN (a policy engine might be enforcing pod security); engine confirmed present → UNKNOWN by design (coverage lives in the engine). The RED arm is reachable only when both the label read succeeded-and-empty AND the engine was confirmed absent; any 403 on either the label list or the engine detection routes the no-labels case to UNKNOWN, never RED.

Key talking point: PodSecurityPolicy was removed in Kubernetes 1.25. Pod Security Admission is the built-in replacement. Without it (or a third-party equivalent), any pod spec is accepted — including privileged containers.