From 2d3212f337b932c9e102a962ca877351863dd973 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Wed, 7 Oct 2026 18:43:11 +0200 Subject: [PATCH] docs: add no privilege escalation to the restricted hardening posture The posture named restricted listed four controls and left out allowPrivilegeEscalation, one Kubernetes' own restricted Pod Security Standard requires. Rendering it, in both implementations and every oracle tree, is #294; the test 0020 owes is #295. --- .../0020-hardening-is-one-platform-posture.md | 16 ++++++++++++---- spec/v1/10-project-intent.md | 3 ++- spec/v1/20-resolved-deployment.md | 4 ++-- spec/v1/30-deliverables.md | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/adr/model/0020-hardening-is-one-platform-posture.md b/docs/adr/model/0020-hardening-is-one-platform-posture.md index 3abca27e8..ea4633cce 100644 --- a/docs/adr/model/0020-hardening-is-one-platform-posture.md +++ b/docs/adr/model/0020-hardening-is-one-platform-posture.md @@ -29,10 +29,11 @@ listed in [chapter 10](../../../spec/v1/10-project-intent.md#hardening). ## Rests on -Kubernetes applies the four controls (non-root, an immutable root filesystem, no -capabilities, the default syscall filter) per pod and per container +Kubernetes applies the five controls (non-root, an immutable root filesystem, no +capabilities, no privilege escalation, the default syscall filter) per pod and +per container ([0002](0002-kubernetes-is-the-substrate-for-one-applier.md)), so the render can -state all four on every container with no author involved. Ephemeral disk is +state all five on every container with no author involved. Ephemeral disk is contended, so its size is platform-assigned ([0004](0004-contention-decides-authority.md)). The lock is a pinned input ([0006](0006-pinned-inputs.md)), so a user read from it is a derivation, not a @@ -42,7 +43,7 @@ registry read at render time. exception to render, a Process's writable set cannot be enumerated ahead of time, or an image the estate depends on has no numeric user and cannot be replaced. **Settled by:** the committed trees under -`spec/v1/examples/{auth,data,knowledge,minimal}/rendered/` carrying the four +`spec/v1/examples/{auth,data,knowledge,minimal}/rendered/` carrying the five controls on every container, the `postgres-exporter` sidecar included, with no project file carrying a hardening field (a test asserting both is to be written, owned by joris); and `platform-postgres` running `initdb` on a freshly @@ -71,6 +72,13 @@ A fresh `local-path` directory is root-owned, so a non-root pod with no to resolve a digest, so it reads the user too. An init container that chowns the volume would need root on every stateful Process. +**Privilege escalation is closed, not implied.** Non-root with every capability +dropped still lets a setuid binary in the image raise its process to uid 0; +`allowPrivilegeEscalation: false` sets `no_new_privs` and closes that. It is +also one of the controls Kubernetes' own `restricted` Pod Security Standard +requires, so a posture under that name without it would be refused by a +namespace enforcing the standard. + **A low port is refused.** `auth-ui` declared port 80 under `restricted` and rendered a pod that could not bind it. Deriving `NET_BIND_SERVICE` would re-add a dropped capability silently. A route names a surface, not a number diff --git a/spec/v1/10-project-intent.md b/spec/v1/10-project-intent.md index 7d190e033..74c3cd1f3 100644 --- a/spec/v1/10-project-intent.md +++ b/spec/v1/10-project-intent.md @@ -1149,7 +1149,7 @@ The posture itself is **not authored per Process**. It is one estate-wide value, it thirty times would be restating the only value there is, and a field with one legal value carries no information ([0022](../../docs/adr/model/0022-a-derived-value-has-one-declaring-site.md) deleted `minAvailable` for the same reason). What a Process authors is the -paths it must write, and nothing else. `restricted` is four controls, applied +paths it must write, and nothing else. `restricted` is five controls, applied together: | control | rendered as | @@ -1157,6 +1157,7 @@ together: | non-root | `runAsNonRoot: true`, with the numeric UID the images lock resolved ([0020](../../docs/adr/model/0020-hardening-is-one-platform-posture.md)) | | immutable root filesystem | `readOnlyRootFilesystem: true` | | no capabilities | `capabilities.drop: [ALL]` | +| no privilege escalation | `allowPrivilegeEscalation: false` | | default syscall filter | `seccompProfile.type: RuntimeDefault` | ### Writable paths are declared, not exempted diff --git a/spec/v1/20-resolved-deployment.md b/spec/v1/20-resolved-deployment.md index 34cb727b0..4a4a2019d 100644 --- a/spec/v1/20-resolved-deployment.md +++ b/spec/v1/20-resolved-deployment.md @@ -657,8 +657,8 @@ Five rules carry most of the weight: with different volumes, which is the property that makes a restore rehearsal meaningful ([0018](../../docs/adr/model/0018-durability-class-derives-a-backup.md)). - **Hardening is one platform posture, and nothing else.** `restricted` ( - `runAsNonRoot`, `readOnlyRootFilesystem`, all capabilities dropped, seccomp - `RuntimeDefault`) is declared once in the Platform document and authored by no + `runAsNonRoot`, `readOnlyRootFilesystem`, all capabilities dropped, no + privilege escalation, seccomp `RuntimeDefault`) is declared once in the Platform document and authored by no Process. There is no per-control relaxation and no exception vocabulary: a Process declares the paths it must write, and an image that cannot meet the class is `E_HARDENING_UNMET` diff --git a/spec/v1/30-deliverables.md b/spec/v1/30-deliverables.md index 7c8278ffe..9ddcdb1d9 100644 --- a/spec/v1/30-deliverables.md +++ b/spec/v1/30-deliverables.md @@ -330,7 +330,7 @@ spells only what the projection holds: | the `secretStore` endpoint | `vso` | a `VaultConnection` named `secret-store` in the project's namespace, at that address, `skipTLSVerify: false` | | a grant delivered `env` or `file` | `vso` | a `VaultAuth` per holding identity, its `ServiceAccount` that identity and its role `-`, over the `secret-store` connection; a `VaultStaticSecret` named for the `destination`, `kv-v2` on the `secret` mount at the path below `secret/data/`, `refreshAfter: 1h`, its destination created and `flagger.app/config-tracking: disabled`, each restart target a `Deployment`, a `blue-green` Process's `-primary` | | a `read` grant | `vault-policy` | `read` on its path and on the same document's `secret/metadata/` path | -| `hardening: restricted` | `kubernetes` | `runAsNonRoot`, the images lock's `uid` and `gid`, seccomp `RuntimeDefault`, a read-only root filesystem, every capability dropped | +| `hardening: restricted` | `kubernetes` | `runAsNonRoot`, the images lock's `uid` and `gid`, seccomp `RuntimeDefault`, a read-only root filesystem, every capability dropped, `allowPrivilegeEscalation: false` | | a probe's `period`, `timeout`, `failures` | `kubernetes` | `periodSeconds`, `timeoutSeconds`, `failureThreshold`; `initialDelaySeconds: 0` on readiness and liveness only | | `ingress`, `egress`, an edge's `peers` | `networking` | one rule per peer, from or to its namespace (by `kubernetes.io/metadata.name`) and, where the peer is a Process, its `instance`, on TCP; the `cluster-dns` peer on UDP and TCP both | | a held `api`'s `server` | `networking` | one egress rule per range in the Process's own policy, after its peers, to an `ipBlock` of its `cidr` on its port over TCP |