This page is generated from skills/eks-operation-review/references/access-identity.md. Edit the source, not this page.
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:
- 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. - 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.
- 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.
- 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. - 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. - List pods → detect credential-shaped env var names only (never values) across containers, initContainers, and
envFromrefs: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 = exactAWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKENor substringSECRET,TOKEN,PASSWORD,API_KEY. Per output line, the tokens after the tab are env var names first, thenenvFromsecretRef/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, genericTOKEN) — a hygiene concern; value never read, so route value-verification to Investigate Manually. Exception —envFromsecretRef is informational only: anenvFromsecretRef(a Secret-sourced ref) whose NAME matches a credential substring (e.g. a secretRef namedapp-secretsmatching the substringSECRET) 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. AnenvFromconfigMapRefis 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 literalvalue:— 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 literalvalue:in the pod spec. If instead it is populated by a Secret-sourced reference —valueFrom.secretKeyRef(or anenvFromsecretRef) — that is the RECOMMENDED injection pattern and is NOT a finding: route it to Investigate Manually. ConfigMap-sourced refs are NOT blessed:valueFrom.configMapKeyRef(andenvFrom.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/envFromsecretRef → not a finding, Investigate Manually;valueFrom.configMapKeyRef/envFromconfigMapRef → 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:
- 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 labelkubernetes.io/bootstrapping=rbac-defaults. Kubernetes/EKS ships API-server-managed system ClusterRoleBindings on every cluster, labels themkubernetes.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(thecluster-admin→ Groupsystem:mastersbinding — this is how kubectl admin access works;system:mastersis a built-in group baked into the API server),system:node, thesystem:controller:*bindings, and the othersystem:-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 namedsystem:controller:footo 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 thesystem:controller:*wildcard). So add a roleRef check: the genuinesystem:node/system:controller:*bootstrap bindings reference their own narrowly-scopedsystem:ClusterRoles, NEVERcluster-adminand NEVER a wildcard (*/*) ClusterRole. If a binding that matches the name allowlist referencescluster-adminor 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-defaultslabel present, AND (c) itsroleRefis not cluster-admin and not a wildcard ClusterRole (the sole intended exception being the API-server-managedcluster-admin→system:mastersbinding named exactlycluster-admin). Subject check on the excludedcluster-adminbinding (closes the subject-tamper gap): the binding-name + label + roleRef gate still exempts the genuinecluster-adminbinding from the "cluster-admin CRB exists" RED, but that exemption covers ONLY its sole legitimate subject, Groupsystem:masters. The RBAC reconciler does not strip extra subjects (it runs with--remove-extra-subjectsdefault false) even with autoupdate=true, so an operator-injected subject persists silently. Therefore ANY subject on the excludedcluster-adminbinding OTHER than Groupsystem: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 thekubernetes.io/bootstrapping=rbac-defaultslabel, regardless of its name — an unlabeled powerful binding is operator-created and must be assessed (see RED). Do NOT excludesystem:authenticatedorsystem:unauthenticated— although they aresystem:-prefixed, a wildcard/cluster-admin binding to either is operator-introduced and a critical finding (see RED). Assess only the remaining (operator-created) bindings. - List ClusterRoles → check rules for
verbs: ["*"]andresources: ["*"] - Count RoleBindings across all namespaces vs ClusterRoleBindings
- List application namespaces (exclude kube-system, kube-public, kube-node-lease, default)
- 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 defaultcluster-admin→system:mastersbinding 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 labelkubernetes.io/bootstrapping=rbac-defaults, e.g. thesystem:-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-managedcluster-admin→system:mastersClusterRoleBinding and thesystem:-prefixed controller/node bootstrap bindings that Kubernetes ships and recreates if deleted; only a properly-labeled bootstrap binding (carryingkubernetes.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 issystem:-prefixed (e.g. an operator binding cluster-admin tosystem:serviceaccountsorsystem:authenticatedis RED). A broad subject is defined precisely as ANY of: (a) a Group that is not part of a default bootstrap binding — this includessystem:serviceaccounts,system:serviceaccounts:<ns>,system:authenticated,system:unauthenticated,system:nodes, and any custom Group such asdev-team(binding a wildcard ClusterRole to any of these is a critical finding even when the group name issystem:-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, includingdefault:defaultorkube-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-teamwith 50 members), tosystem:serviceaccounts, or to thedefaultServiceAccount is RED even though it is one binding — and any non-system:masterssubject on the excludedcluster-adminbinding 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:
- Describe cluster →
resourcesVpcConfig.endpointPublicAccess,endpointPrivateAccess,publicAccessCidrs. This is the endpoint-config signal, rated on its own successful read: public open to0.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. - Describe cluster →
resourcesVpcConfig.clusterSecurityGroupId, then read that security group's inbound rules withec2: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 onDescribeSecurityGroupRules→ 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/0ingress 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 allows0.0.0.0/0inbound 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:
- List namespaces → inspect labels for
pod-security.kubernetes.io/enforce,pod-security.kubernetes.io/warn,pod-security.kubernetes.io/audit - 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.
- Check if any application namespaces use
privilegedenforce level - 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-manageringatekeeper-system,kyvernoinkyverno). 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,
baselineorrestrictedenforcement - 🟡 AMBER: PSA labels on some namespaces but not all, or only
warn/auditmode (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
privilegedapplication 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.