Deployment context
The same HIGH finding is one thing in an internet-facing pod running privileged on the host network, and another in a rootless batch job nothing outside the cluster can reach. Deployment context is where a policy says which is which.
Until this existed, admission decided both the same way. The image half of a review counted severities, KEV entries, end-of-support and provenance; the pod half, workloadHardening, looked at privilege and host namespaces; and the two never met. An operator who wanted "block HIGH where it matters" had to block HIGH everywhere — and be overruled — or nowhere.
It only ever escalates. There is no signal, and no combination of signals, that admits something the policy would otherwise have refused. Whatever the context says, the worst case is still readable from the policy text alone.
The policy block
apiVersion: attestkeep.com/v1alpha1
kind: ImageSecurityPolicy
metadata:
name: production-baseline
spec:
thresholds:
blockCritical: true
blockHigh: false
deploymentContext:
mode: Escalate # Ignore (default) | Escalate
signals: # empty = all six
- exposed
- privileged
mode: Ignore is the default and what an absent block means. Under Ignore the signals are still derived and still written to the admission ledger; nothing changes about any decision. That is the mode to run first: the ledger and the what-if replay then say how much of the cluster Escalate would touch, before it touches any of it.
An empty signals list under Escalate means all six. It is the reading an operator who wrote the mode and nothing else obviously intends, and it is the stricter one, which is the right default for a control that can only tighten. A signal name the operator does not derive is refused when the policy is saved — it would otherwise read as a working policy and escalate nothing, which is the one failure this feature exists to avoid: a control you believe is on.
The six signals
| Signal | What it means | Derived from |
|---|---|---|
privileged | A container asks for securityContext.privileged. | Hardening control PSW-01 |
root | An explicit UID 0, or neither the pod nor the container saying runAsNonRoot. | PSW-03 |
hostNamespace | hostNetwork, hostPID or hostIPC. | PSW-05, PSW-06 |
hostFilesystem | A hostPath volume. | PSW-07 |
capabilities | A dangerous Linux capability is added. | PSW-04 |
exposed | The pod is reachable from outside the cluster. | The pod spec and the namespace's Services and Ingresses |
Five of the six are read off the workload-hardening findings the admission path already produces rather than re-derived from the pod spec. That is not only cheaper: it is the only way the two can be kept from disagreeing. A second implementation of "does this run as root" would eventually answer differently from the control you read in your own console.
The signals are derived whatever workloadHardening.mode says, off included. That mode decides what admission does about the pod itself; the context is a fact about the pod, and a policy escalating on privileged must not silently stop escalating because somebody turned the hardening report off.
root deserves its own note. It follows PSW-03 exactly, which means a pod that says nothing about runAsNonRoot counts as root: an image that sets no USER runs as root, and the pod spec cannot show otherwise. On a cluster that has not adopted pod security standards this is most pods. It is a real signal and a broad one — select it deliberately, or leave it out and rely on the narrower five.
How exposed is decided
Being reachable is not a property of the pod spec alone: a Service selects pods by label and an Ingress routes to a Service. Four ways a pod counts as exposed, in the order they are established:
hostNetwork— the pod is on the node's own network stack, so every port it listens on is a port on the node.- A
hostPort— one port is published on the node whatever the pod's network namespace. Init and ephemeral containers count: an init container binds a host port exactly as effectively as an application container. - A
NodePortorLoadBalancerService whose selector matches the pod — the cluster is publishing it deliberately. - An Ingress naming a ClusterIP Service that matches the pod — the ingress controller is publishing it. Both the default backend and every path's backend count.
A ClusterIP Service with no Ingress in front of it is not exposure. It is the ordinary way one workload reaches another inside a cluster, and treating it as external reach would put nearly every pod in the escalated set — which would make the feature useless rather than strict. A Service with no selector matches nothing: its endpoints are managed by hand, so it selects neither this pod nor any other.
The denial names the evidence — Service web (LoadBalancer), Ingress acme-web -> Service web, hostPort 8080 — because a deployer told their pod is "exposed" needs to know which object did it before they can argue with it. The same string is on the ledger row as context_detail.
What escalation actually does
Three rules, all one-directional. They apply to a pod carrying at least one selected signal, and to nothing else.
| Policy says | A pod carrying a selected signal is judged as |
|---|---|
thresholds.blockCritical: true | also blocking HIGH |
thresholds.blockHigh: true | also blocking MEDIUM |
knownExploited: warn | block |
endOfLifeOS: warn | block |
And what it deliberately does not do:
maxMediumandmaxLoware untouched. They are counts you chose, not severities, and halving a limit somebody picked is a different kind of tightening from moving a severity.ignorestaysignore, in bothknownExploitedandendOfLifeOS. An operator who switched a check off has decided something about their cluster, and a signal does not overrule a decision — escalation turns a warning into a refusal, never a silence into a warning.- Nothing is ever loosened. No signal relaxes a threshold, admits an unsigned image, or waives a gate.
An image the policy would have refused anyway is refused for the reason the policy already gave. Only a refusal that would not have happened elsewhere in the cluster is labelled context, and that is the whole distinction the label draws. What a denial reads like:
3 HIGH vulnerabilities, treated as CRITICAL because the pod is exposed
(Service web (LoadBalancer)) and runs privileged
debian 10 is past end of life; the policy warns, but the pod shares a host
namespace so it is refused
The policy's own choice is named first in the second form, because the operator reading the denial will go and look at the policy and find a warn there.
What the ledger records
Migration 0020 adds two columns to the admissions table: context, the signal identifiers the pod carried, and context_detail, the exposure evidence — the Service, Ingress or hostPort. Both are written on every admission, under Ignore as well as Escalate, and for pod-level hardening denials as well as per-image rows.
Rows written before the migration carry an empty array. That is not the same fact as "this pod carried no signals" — it is "nobody was looking yet" — and the what-if replay says so rather than letting the difference pass.
The columns are deliberately not part of the ledger content hash. The hash covers what a verifier can recompute from the row, and every seal made before this migration was computed over the fourteen fields the canonical form names; adding a fifteenth would invalidate every existing checkpoint at once, for a column that is an input to a decision rather than the decision itself. The reason text — which states the signals whenever they changed an outcome — is hashed, and that is the part an auditor is being asked to trust. See the evidence trust model.
In the console the signals appear as badges under the pod name in the admission feed, and a refusal that happened only because of where the pod ran is marked Deployment context in front of its reason.
What it needs from the cluster
The exposure signal is the only part of this that reads anything outside the pod under review. The chart's ClusterRole gains, read-only:
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch"]
No chart values were added: there is nothing to configure. An installation that pins its own RBAC has to grant these two rules itself — see what runs in your cluster for the rest of the operator's permissions.
It degrades rather than fails. An operator that cannot list these — an upgrade where the ClusterRole was pinned, an installation with no Kubernetes client at all — derives exposure from the pod spec alone (hostNetwork and hostPort), logs a warning naming the namespace, and admits. Exposure that cannot be established is not asserted, which is the lenient direction and the correct one for a control that escalates.
Services and Ingresses are cached per namespace for 60 seconds. A busy namespace rolling a Deployment produces dozens of reviews in a few seconds, and each of them asking the API server for every Service in the namespace would make this operator the thing slowing the cluster down. What that costs: a pod admitted in the first minute after a Service is created is judged against the namespace as it was just before. It is under-escalated for up to sixty seconds, which is the safe direction for a control that only tightens, and the next pod or the next rollout is judged correctly. If a workload's exposure has just changed and you want the decision to reflect it immediately, wait a minute before rolling the pods. A namespace whose Services could not be read is not cached at all, so the next review retries rather than reading an empty namespace for a minute.
Evidence and compliance
The evidence document carries a top-level context section: the mode, the signals selected, how many admitted rows carried each signal, how many refusals happened only because of where the pod ran, and the escalating policies by name with their namespaces.
admitted_by_signal counts admitted rows only. A pod that was refused never ran, and counting it under "admitted pods carrying this signal" would report a control's successes as its exposure. The mode is folded over every policy in force rather than read off the one policy the document's control statement quotes, so one namespace-scoped policy escalating is enough for the mode to read Escalate — and escalating_policies names them with their namespaces, so a reader can tell one namespace from the whole cluster.
The attestation context-aware-thresholds reports the same thing to the frameworks:
| Assessment | When |
|---|---|
met | Escalate, and the gate acts on both exposed and privileged. |
partial | Escalate, but one of those two is not selected. A narrower control is still a control. |
gap | Ignore. You can switch it on today, so reporting it as unobservable would be a polite lie. |
Those two signals are not an arbitrary choice. "Is the internet-facing estate held to a higher standard than the batch jobs" is the question every risk-ranking clause is actually asking, and exposed is the only signal that answers it; privileged is the one that turns a container escape into a node compromise, which is what "criticality of the asset" means when the asset is a pod. The attestation is cited by ISO/IEC 27001 A.8.8, NIST SP 800-53 RA-7 and PCI DSS 6.3.1 and 11.3.1.1, and by nothing else: RA-5, SI-2 and DORA 9(4)(f) ask for scanning, flaw correction and patch policy, and a gate that ranks by placement answers none of those three.
Rolling it out
- Leave
deploymentContextabsent, or setmode: Ignore. Every admission from then on records its context. - Watch
attestkeep_admission_context_total{signal}, or read the signal counts in the admission feed, for as long as it takes a full deployment cycle to pass through. - Run the what-if replay with a draft that sets
mode: Escalateand the signals you mean.escalated_denialsis exactly what would have been refused; the samples name the pods. - Apply it.
Step 3 is the point of the feature being staged this way. A control that can only tighten is still a control that can stop a deploy at 3am, and the replay is how that is found out in the afternoon instead.