Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/chart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,41 @@ jobs:
--set mode=enforce >/dev/null 2>&1; then
echo "mode: enforce with empty enforceScope was not refused"; exit 1
fi
- name: "Assert node-containment RBAC (ADR-0040 §6) is posture-derived on enforceRung: node"
run: |
# The metadata-only nodes READ (fleet observation for the rails) is ALWAYS ON,
# even under mode: audit (the default) — a read is harmless, and ContainNode
# stays structurally unarmed either way (ADR-0040 §5).
audit=$(helm template protector charts/protector --namespace protector)
echo "$audit" | grep -A3 'resources: ."nodes".' | grep -q 'get' \
|| { echo "audit default is missing the always-on nodes read grant"; exit 1; }
# But NO install below the top rung — including audit and enforce+quarantine —
# may ever grant the nodes WRITE (patch).
if echo "$audit" | grep -A3 'resources: ."nodes".' | grep -q 'patch'; then
echo "audit default leaked the nodes patch (write) grant"; exit 1
fi
quarantine=$(helm template protector charts/protector --namespace protector \
--set mode=enforce --set 'enforceScope.namespaces={payments}' \
--set enforceRung=quarantine)
if echo "$quarantine" | grep -A3 'resources: ."nodes".' | grep -q 'patch'; then
echo "enforce+quarantine leaked the nodes patch (write) grant"; exit 1
fi
# enforce + enforceRung: node is the ONLY posture that grants nodes patch —
# cluster-scoped (no per-namespace Role/RoleBinding exists for it, unlike
# NetworkPolicy) — alongside its predecessor rungs' NetworkPolicy write grant.
node=$(helm template protector charts/protector --namespace protector \
--set mode=enforce --set 'enforceScope.namespaces={payments}' \
--set enforceRung=node)
echo "$node" | grep -A3 'resources: ."nodes".' | grep -q 'patch' \
|| { echo "enforce+node did not derive the nodes patch (write) grant"; exit 1; }
echo "$node" | grep -A3 'resources: ."networkpolicies".' | grep -q 'create' \
|| { echo "enforce+node lost the predecessor NetworkPolicy write grant"; exit 1; }
# enforceRung: node without mode: enforce must be refused at render time too
# (defense-in-depth alongside the engine's own startup refusal).
if helm template protector charts/protector --namespace protector \
--set enforceRung=node >/dev/null 2>&1; then
echo "enforceRung: node under mode: audit was not refused"; exit 1
fi
- name: "Assert namespace-scoped enforce tightens NetworkPolicy write to per-namespace Roles"
run: |
# NetworkPolicy is namespaced and the engine only writes cuts inside
Expand Down
36 changes: 31 additions & 5 deletions charts/protector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,26 @@ rung:
A rung implies its narrower predecessor — `quarantine` still arms the edge-cut too —
so this is one ordered position to reason about, not independent per-cut toggles.

**A third, explicit opt-in — `enforceRung: node` — makes `ContainNode` ELIGIBLE TO
PROPOSE, protector's first NODE-write RBAC (ADR-0040).** Strictly above `quarantine`
(`edge-cut < quarantine < node`): a proven pod-boundary break (a host-credential read,
a root escalation paired with a container-escape primitive, a kernel tamper, or
co-resident model-confirmed compromise) escalates from a pod-scoped cut to a *proposal*
to cordon the `Host` node plus a default-deny sweep of its other labelled pods.
**`ContainNode` is PROPOSE-ONLY and NEVER auto-applied, at any rung** — ADR-0040 §5
makes a node cut propose-first *by construction* (a real node always has alive
collateral): arming this rung only makes a rails-clean cut surface as an actionable
proposal (the deterministic rails — never the control plane, at most one node
concurrently, a two-schedulable-worker floor, ownership-gated revert — are pre-checked
and named on it) instead of a bare "not auto-enabled" line; an actual cordon requires an
explicit human action outside protector. Requires `mode: enforce` with a non-empty
`enforceScope`, exactly like every other rung; the chart and the engine both refuse to
start otherwise:

```sh
--set enforceRung=node
```

**The fail-closed webhook and the actuation RBAC are derived from the same
`enforceScope`** — they can no longer drift from what the gates enforce. By default the
audit webhook **fails open** (`failurePolicy: Ignore`, so a protector outage never
Expand Down Expand Up @@ -231,11 +251,17 @@ The engine's reversible network cut is armed together with the webhook surfaces
`mode: enforce` (above) — there is no separate engine arming switch — but *how much* of
it is armed is its own ORDERED ladder, `enforceRung` (ADR-0035): `edge-cut` (the
default) arms only the surgical edge-cut; `quarantine` also arms the broader
quarantines. In `mode: audit` the engine is always dry-run, regardless of `enforceRung`;
under `mode: enforce` it applies the rung's armed cut(s) on a corroborated attack path
whose endpoints are within `enforceScope`, and the NetworkPolicy write grant is derived
quarantines; `node` (ADR-0040 §6) additionally makes `ContainNode`, protector's first
node-write action, ELIGIBLE TO PROPOSE — never to auto-apply, at any rung (ADR-0040 §5's
propose-first-by-construction rule), gated the whole way down by its own deterministic
rails. In `mode: audit` the engine is always dry-run, regardless of `enforceRung`; under
`mode: enforce` it applies the rung's armed cut(s) on a corroborated attack path whose
endpoints are within `enforceScope`, and the NetworkPolicy write grant is derived
from `mode` (they arm together, independent of the rung — the RBAC grant covers the same
object kind at either rung). Choose the CNI mechanism the cut renders with:
object kind at either rung). The `nodes` `patch` grant, unlike NetworkPolicy's, is
derived from `enforceRung: node` specifically (see "Enforce" above) — it is the one
grant on this ladder that does NOT arm together with the others. Choose the CNI
mechanism the cut renders with:

```sh
--set engine.actuator=networkpolicy # default — any NetworkPolicy-enforcing CNI (ADR-0010)
Expand Down Expand Up @@ -275,7 +301,7 @@ Requires the `protector-agent` image and probes load-tested on your kernel (see
| `mode` | `audit` | **The posture switch** (ADR-0021). `enforce` arms the webhooks + the engine's cut up to `enforceRung`, all in `enforceScope`. |
| `enforceScope.namespaces` | `[]` | Namespace names to enforce (used only under `mode: enforce`). No wildcard. |
| `enforceScope.labels` | `{}` | Pod labels (`key: value`) to enforce anywhere; labels behave like namespaces. |
| `enforceRung` | `edge-cut` | **The engine's cut-severity ladder** (ADR-0035, used only under `mode: enforce`). `edge-cut` arms only the surgical `DenyNetworkPath` cut; `quarantine` also arms the broader entry/workload quarantines. Ordered — `quarantine` implies `edge-cut`. |
| `enforceRung` | `edge-cut` | **The engine's cut-severity ladder** (ADR-0035, used only under `mode: enforce`). `edge-cut` arms only the surgical `DenyNetworkPath` cut; `quarantine` also arms the broader entry/workload quarantines; `node` (ADR-0040 §6) makes `ContainNode` (cordon + co-resident deny) ELIGIBLE TO PROPOSE — never to auto-apply, at any rung — and grants the cluster-scoped `nodes` `patch` RBAC. Ordered — each rung implies its predecessor(s). `node` requires `mode: enforce` with a non-empty `enforceScope`. |
| `image.tag` | `""` → chart `appVersion` | Pin a cosign-signed semver tag. |
| `imagePullSecrets` | `[]` | protector publishes to a public ghcr repo. |
| `engine.enabled` | `true` | The mitigation engine (the product). |
Expand Down
29 changes: 29 additions & 0 deletions charts/protector/templates/clusterrole.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ rules:
- apiGroups: ["policy.linkerd.io"]
resources: ["servers", "authorizationpolicies", "meshtlsauthentications"]
verbs: ["get", "list", "watch"]
# Node fleet observation (ADR-0040 §3/§6) — metadata-only (name, the
# node-role.kubernetes.io/control-plane label, spec.unschedulable, protector's own
# cordon-ownership annotation; never .data, there is none on a Node). ALWAYS ON,
# like every other read grant above: this is a READ, harmless under mode: audit (the
# ContainNode class stays structurally unarmed either way, ADR-0040 §5), and the
# node-containment rails need real fleet data the moment `enforceRung: node` is armed
# — gating the read behind the rung too would leave the very first pass after arming
# blind. The WRITE this read feeds (below) stays posture-derived.
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list", "watch"]
{{- if eq .Values.mode "enforce" }}
# mode: enforce — the network actuator creates/deletes its own (managed-by: protector)
# additive, reversible objects to quarantine/sever a corroborated attack path. The
Expand Down Expand Up @@ -80,5 +91,23 @@ rules:
resources: ["adminnetworkpolicies"]
verbs: ["create", "delete", "patch"]
{{- end }}
{{- if eq .Values.enforceRung "node" }}
# mode: enforce + enforceRung: node (ADR-0040 §6, ADR-0021 posture-derived RBAC) —
# the top rung's ONLY new write surface: Node.spec.unschedulable (cordon/uncordon).
# ContainNode is PROPOSE-ONLY and never auto-applied at any rung (ADR-0040 §5's
# propose-first-by-construction rule) — this grant exists for the standard ledger
# self-revert/break-glass UNCORDON path (a standing cut, however it became standing,
# must still be liftable) and for a future human-approval-to-apply flow, gated the
# whole way down by the SAME deterministic rails regardless of this grant existing
# (respond::actuator::node_containment::cordon_decision). `nodes` is IRREDUCIBLY
# CLUSTER-SCOPED — unlike NetworkPolicy there is no namespaced Node kind to narrow
# this to — so the grant is cluster-wide even for a namespace-only enforceScope;
# `enforceScope`/the arming ladder still confine WHICH mitigations may be proposed,
# this only grants the mechanism. `patch` only: the engine never creates or deletes
# a Node.
- apiGroups: [""]
resources: ["nodes"]
verbs: ["patch"]
{{- end }}
{{- end }}
{{- end }}
18 changes: 13 additions & 5 deletions charts/protector/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@
{{- end }}
{{- /* ADR-0035 arming-ladder validation (defense-in-depth; the engine also falls back to
the narrowest rung on an unrecognized value): enforceRung must be a rung on the ladder. */}}
{{- if not (or (eq .Values.enforceRung "edge-cut") (eq .Values.enforceRung "quarantine")) }}
{{- fail (printf "enforceRung must be 'edge-cut' or 'quarantine', got %q" .Values.enforceRung) }}
{{- if not (or (eq .Values.enforceRung "edge-cut") (eq .Values.enforceRung "quarantine") (eq .Values.enforceRung "node")) }}
{{- fail (printf "enforceRung must be 'edge-cut', 'quarantine', or 'node', got %q" .Values.enforceRung) }}
{{- end }}
{{- /* ADR-0040 §6 (defense-in-depth; the engine also refuses at startup): the top rung is
protector's first node-write action class — never render it without mode: enforce (which
the check above already requires to carry a non-empty enforceScope). */}}
{{- if and (eq .Values.enforceRung "node") (ne .Values.mode "enforce") }}
{{- fail "enforceRung: node requires mode: enforce with a non-empty enforceScope — node containment (ADR-0040) is protector's first node-write action and must never be configured without both enforcement gates armed" }}
{{- end }}
{{- /*
Feed-fetcher sidecar wiring. When feedSync is ON (the default), a native
Expand Down Expand Up @@ -243,11 +249,13 @@ spec:
value: {{ join "," .Values.enforceScope.namespaces | quote }}
- name: PROTECTOR_ENFORCE_SCOPE_LABELS
value: {{ include "protector.enforceScopeLabels" . | quote }}
# The ordered network-cut arming ladder (ADR-0035): how far `mode: enforce`
# The ordered cut-severity arming ladder (ADR-0035): how far `mode: enforce`
# is armed. `edge-cut` (the default) arms ONLY the surgical DenyNetworkPath
# cut; `quarantine` is an explicit second opt-in that adds the broader
# QuarantineEntry/QuarantineWorkload denies (implying the edge-cut rung too
# — one ordered position, not independent toggles). Ignored under mode: audit.
# QuarantineEntry/QuarantineWorkload denies (implying the edge-cut rung too);
# `node` is an explicit third opt-in that additionally arms ContainNode, the
# first node-write action class (ADR-0040 §6) — one ordered position, not
# independent toggles. Ignored under mode: audit.
- name: PROTECTOR_ENFORCE_RUNG
value: {{ .Values.enforceRung | quote }}
{{- if .Values.rekor.enabled }}
Expand Down
14 changes: 13 additions & 1 deletion charts/protector/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ enforceScope:
namespaces: []
labels: {}

# The ORDERED network-cut arming ladder (ADR-0035) — how far `mode: enforce` is armed.
# The ORDERED cut-severity arming ladder (ADR-0035) — how far `mode: enforce` is armed.
# One position, not independent per-cut toggles: a higher rung always implies its
# narrower predecessor. Used only when mode: enforce; ignored under mode: audit.
# edge-cut (DEFAULT) — arms ONLY the surgical DenyNetworkPath edge-cut, the
Expand All @@ -75,6 +75,18 @@ enforceScope:
# entry quarantine and the compromised-workload quarantine (both still the
# same additive/reversible network-deny mechanism, ADR-0010). Escalate here
# only after baking confidence on edge-cut.
# node — an explicit THIRD opt-in, strictly above quarantine (ADR-0040 §6): makes
# ContainNode (cordon the Host a proven pod-boundary break is scheduled on,
# plus a co-resident default-deny sweep) ELIGIBLE to be PROPOSED — protector's
# first node-write RBAC. ContainNode is PROPOSE-ONLY: ADR-0040 §5 makes a node
# cut propose-first BY CONSTRUCTION (a real node always has alive collateral),
# so it is NEVER auto-applied at any rung — the deterministic rails (never the
# control plane, at most one node, a two-schedulable-worker floor,
# ownership-gated revert) are a bound on the proposal, not a replacement for
# human review; an actual cordon needs an explicit human action outside
# protector. Requires mode: enforce with a non-empty enforceScope, exactly
# like every other rung — the engine refuses to start otherwise. Escalate here
# only after baking confidence on quarantine.
enforceRung: edge-cut

image:
Expand Down
Loading