diff --git a/charts/protector/README.md b/charts/protector/README.md index ece94d0a..d388b394 100644 --- a/charts/protector/README.md +++ b/charts/protector/README.md @@ -23,7 +23,7 @@ reads or transmits any cluster data, and the **engine itself stays zero-egress** | Property | Default | Why | | ------------------- | -------------------------------------- | --------------------------------------------------------------- | -| Operating posture | **`mode: audit`** (`enforceScope` empty) | Everything observes & proposes; nothing blocks or acts (ADR-0021). Signature + mesh audit-only, engine shadow. Flip `mode: enforce` + `enforceScope` to arm all three surfaces at once. | +| Operating posture | **`mode: audit`** (`enforceScope` empty, `enforceRung: edge-cut`) | Everything observes & proposes; nothing blocks or acts (ADR-0021). Signature + mesh audit-only, engine shadow. Flip `mode: enforce` + `enforceScope` to arm the webhooks and the engine's cut up to `enforceRung` (ADR-0035, default `edge-cut` — the surgical cut alone). | | Webhook scope | **audit every namespace** (`webhook.excludeNamespaces: []`) | The fail-open audit webhook observes Pod creates cluster-wide, including kube-system / cert-manager / linkerd / argocd / protector. List names in `excludeNamespaces` to opt some out. | | Webhook failure | audit `failurePolicy: Ignore`; enforcing webhook `Fail` but **scoped to nothing** | The audit webhook never blocks API writes (so auditing every namespace is safe even for kube-system); the fail-closed enforcing webhook matches no namespace until `mode: enforce` + `enforceScope` opt one in. | | Ingest auth | **on** (`ingestAuth.enabled: true`) | The :9999 runtime ingest requires a bearer token (mounted file only); engine + agent share a chart-provisioned Secret. | @@ -73,11 +73,11 @@ Nothing below that **acts on or blocks** your workloads is enabled by default (t default-on item is the read-only **feed-fetcher** egress, covered below). Arm in this order and review the decision journal / audit log at each step. -### Enforce: one scope arms all three surfaces (ADR-0021) +### Enforce: one scope arms the webhooks + the engine's cut up to one rung (ADR-0021/0035) -Enforcement is **two settings**: `mode` + `enforceScope`. Flipping `mode: enforce` -arms all three enforcement surfaces together — signature-webhook deny, mesh-webhook -deny, and the engine's reversible network cut — each confined to *exactly* +Enforcement is `mode` + `enforceScope` + `enforceRung`. Flipping `mode: enforce` +arms the signature-webhook deny and the mesh-webhook deny together, and arms the +engine's reversible network cut **up to `enforceRung`** — each confined to *exactly* `enforceScope`: ```sh @@ -98,6 +98,19 @@ enforced in any namespace): There is **no enforce-everywhere wildcard**: `mode: enforce` with an empty `enforceScope` is refused (by both helm and the engine at startup). +**The engine's cut arms on an ORDERED LADDER, not all at once (ADR-0035).** The default +`enforceRung: edge-cut` arms *only* the surgical, most-reversible `DenyNetworkPath` +edge-cut — the broader entry/workload quarantines stay propose-only. Escalate to the +quarantines only as an explicit second opt-in, after baking confidence on the edge-cut +rung: + +```sh +--set enforceRung=quarantine +``` + +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. + **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 @@ -212,13 +225,17 @@ your own `kev.json` / `epss.csv` into the engine container at `/var/lib/protecto (e.g. via a ConfigMap/Secret/PVC you manage) and set `PROTECTOR_KEV_FILE` / `PROTECTOR_EPSS_FILE` accordingly. -### The engine's live cut is armed by `mode: enforce` +### The engine's live cut is armed by `mode: enforce` + `enforceRung` -The engine's reversible network cut is one of the three surfaces `mode: enforce` arms -(above) — there is no separate engine arming switch. In `mode: audit` the engine is -always dry-run; under `mode: enforce` it applies its cut on a corroborated attack path +The engine's reversible network cut is armed together with the webhook surfaces by +`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 -from the same `mode` (they arm together). Choose the CNI mechanism the cut renders with: +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: ```sh --set engine.actuator=networkpolicy # default — any NetworkPolicy-enforcing CNI (ADR-0010) @@ -255,9 +272,10 @@ Requires the `protector-agent` image and probes load-tested on your kernel (see | Key | Default | Notes | | ---------------------------- | ------------------------------------ | -------------------------------------------------- | -| `mode` | `audit` | **The posture switch** (ADR-0021). `enforce` arms all three surfaces in `enforceScope`. | +| `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`. | | `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). | diff --git a/charts/protector/templates/NOTES.txt b/charts/protector/templates/NOTES.txt index 568f05a6..834dc6cf 100644 --- a/charts/protector/templates/NOTES.txt +++ b/charts/protector/templates/NOTES.txt @@ -1,13 +1,13 @@ protector is installed in namespace {{ .Release.Namespace }}. -Posture (safe-by-default — two settings, ADR-0021): +Posture (safe-by-default — mode + enforceScope + enforceRung, ADR-0021/0035): {{- $enforce := eq .Values.mode "enforce" }} {{- $scope := "" }} {{- if .Values.enforceScope.namespaces }}{{ $scope = printf "namespaces=[%s]" (join "," .Values.enforceScope.namespaces) }}{{ end }} {{- if .Values.enforceScope.labels }}{{ $scope = printf "%s labels=[%s]" $scope (include "protector.enforceScopeLabels" .) }}{{ end }} Mode : {{ if $enforce }}ENFORCE in {{ $scope }} — signature+mesh DENY and the engine actuates (via '{{ .Values.engine.actuator }}'), confined to that scope{{ else }}AUDIT (everything observes & proposes; nothing blocks or acts — shadow){{ end }} Webhook failurePolicy : {{ .Values.webhook.failurePolicy }} (audit) / {{ .Values.webhook.enforcedFailurePolicy }} (enforcing, scoped to enforceScope) - Engine : {{ if .Values.engine.enabled }}{{ if $enforce }}ARMED (reversible network cut inside enforceScope){{ else }}SHADOW (propose-only, touches nothing){{ end }}{{ else }}disabled (webhook only){{ end }} + Engine : {{ if .Values.engine.enabled }}{{ if $enforce }}ARMED at rung '{{ .Values.enforceRung }}' (inside enforceScope){{ else }}SHADOW (propose-only, touches nothing){{ end }}{{ else }}disabled (webhook only){{ end }} Model : {{ if .Values.engine.model.endpoint }}{{ .Values.engine.model.endpoint }}{{ else }}none (deterministic enumerator only){{ end }} Breach notifier : {{ if .Values.engine.notify.url }}configured (egress to your sink){{ else }}off (zero egress){{ end }} Rekor lane : {{ if .Values.rekor.enabled }}enabled (opt-in transparency-log egress){{ else }}off (zero egress){{ end }} @@ -31,4 +31,6 @@ Verify cert-manager injected the webhook caBundle: {{- end }} Arming is a single, deliberate flip: set `mode: enforce` + `enforceScope` (one scope -arms all three surfaces). There is no enforce-everywhere wildcard. +arms the signature/mesh webhooks and the engine's cut, up to `enforceRung` — an ORDERED +ladder, ADR-0035: `edge-cut` (default) arms only the surgical cut, `quarantine` is a +second explicit opt-in). There is no enforce-everywhere wildcard. diff --git a/charts/protector/templates/deployment.yaml b/charts/protector/templates/deployment.yaml index d820f59a..cb4e1e28 100644 --- a/charts/protector/templates/deployment.yaml +++ b/charts/protector/templates/deployment.yaml @@ -7,6 +7,11 @@ {{- if and (eq .Values.mode "enforce") (not .Values.enforceScope.namespaces) (not .Values.enforceScope.labels) }} {{- fail "mode: enforce requires a non-empty enforceScope (namespaces and/or labels) — there is no enforce-everywhere wildcard (ADR-0021)" }} {{- 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) }} +{{- end }} {{- /* Feed-fetcher sidecar wiring (JEF-238, JEF-243). When feedSync is ON (the default), a native sidecar (an initContainer with restartPolicy: Always) fetches TWO public read-only feeds @@ -238,6 +243,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` + # 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. + - name: PROTECTOR_ENFORCE_RUNG + value: {{ .Values.enforceRung | quote }} {{- if .Values.rekor.enabled }} # Rekor transparency-log lane (ADR-0020 §4): the one opt-in egress carve-out on # the signing lane. Off unless enabled here; timeouts/caps use code defaults. @@ -283,10 +295,11 @@ spec: {{- end }} {{- if .Values.engine.enabled }} # --- mitigation engine --- - # Arming is derived from the top-level `mode`/`enforceScope` (ADR-0021), NOT a - # per-engine switch: `mode: enforce` arms the reversible network cut confined to - # `enforceScope`; `mode: audit` (default) is dry-run (shadow). `actuator` only - # selects the CNI mechanism used when it does actuate. + # Arming is derived from the top-level `mode`/`enforceScope`/`enforceRung` + # (ADR-0021/0035), NOT a per-engine switch: `mode: enforce` arms the reversible + # network cut(s) up to `enforceRung`, confined to `enforceScope`; `mode: audit` + # (default) is dry-run (shadow). `actuator` only selects the CNI mechanism used + # when it does actuate. - name: PROTECTOR_ENGINE_ACTUATOR value: {{ .Values.engine.actuator | quote }} {{- if .Values.engine.falco.enabled }} diff --git a/charts/protector/values.yaml b/charts/protector/values.yaml index f18d05ed..2717a31e 100644 --- a/charts/protector/values.yaml +++ b/charts/protector/values.yaml @@ -7,10 +7,13 @@ # The signature + mesh webhooks audit everywhere (log + # meter, never deny) and the engine runs in shadow # (proposes cuts, applies none). This is the whole default. -# * mode: enforce + enforceScope {namespaces/labels}: arms ALL THREE -# enforcement surfaces together, confined to EXACTLY that +# * mode: enforce + enforceScope {namespaces/labels}: arms the signature + +# mesh webhook denies together, confined to EXACTLY that # scope — unsigned/regressed images denied, unmeshed Pods -# denied, and the engine applies its reversible network cut. +# denied. The engine's network cut arms up to enforceRung +# below (ADR-0035): edge-cut (DEFAULT) arms ONLY the +# surgical DenyNetworkPath cut; quarantine is a SECOND +# explicit opt-in that also arms the broader quarantines. # The fail-closed enforcing webhook + the actuation RBAC are # DERIVED from `enforceScope` (they can't drift). There is no # per-surface toggle and NO enforce-everywhere wildcard. @@ -39,12 +42,13 @@ replicaCount: 1 -# === OPERATING POSTURE (ADR-0021) — the two settings that govern enforcement === +# === OPERATING POSTURE (ADR-0021 + ADR-0035) — mode + enforceScope + enforceRung === # # mode: audit | enforce # audit (DEFAULT) — observe & propose everywhere; nothing blocks or acts (shadow). -# enforce — arm all three enforcement surfaces (signature webhook deny, mesh webhook -# deny, engine live network-cut) confined to EXACTLY enforceScope below. +# enforce — arm the signature webhook deny, the mesh webhook deny, and — up to +# enforceRung below (ADR-0035) — the engine's live network-cut, confined +# to EXACTLY enforceScope below. mode: audit # The ONE enforced scope, honoured identically by all three surfaces and by the derived # fail-closed webhook selector + actuation RBAC. Used only when mode: enforce. @@ -62,6 +66,17 @@ enforceScope: namespaces: [] labels: {} +# The ORDERED network-cut 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 +# narrowest, most-reversible cut. The quarantines stay propose-only. +# quarantine — an explicit SECOND opt-in beyond edge-cut: also arms the default-deny +# 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. +enforceRung: edge-cut + image: repository: ghcr.io/thejefflarson/protector pullPolicy: IfNotPresent diff --git a/docs/adr/0035-per-cut-class-arming-ladder.md b/docs/adr/0035-per-cut-class-arming-ladder.md index 4ed11798..3c22810f 100644 --- a/docs/adr/0035-per-cut-class-arming-ladder.md +++ b/docs/adr/0035-per-cut-class-arming-ladder.md @@ -1,6 +1,6 @@ # 0035. Per-cut-class arming granularity: an ordered ladder under `enforce` -- Status: Proposed +- Status: Accepted - Date: 2026-07-31 ## Context diff --git a/engine/src/engine/respond/actuator/arming_ladder.rs b/engine/src/engine/respond/actuator/arming_ladder.rs new file mode 100644 index 00000000..255ec8ed --- /dev/null +++ b/engine/src/engine/respond/actuator/arming_ladder.rs @@ -0,0 +1,101 @@ +//! The `enforce` arming ladder (ADR-0035): an ORDERED position over the live network +//! cuts, not a menu of independent per-cut toggles. ADR-0021 collapsed enforcement to +//! `mode` + `enforceScope`, but a single `enforce` flip armed all three network cuts — +//! the surgical [`DenyNetworkPath`] edge-cut *and* both quarantines — at once, so there +//! was nothing to arm one class at a time against. This module is the fix: it maps a +//! single ordered [`ArmingRung`] to the [`EnabledActions`] it implies. +//! +//! The ladder is deliberately **one position, not N flags** — escalating a rung always +//! implies its narrower predecessors, so there is still exactly one thing for an +//! operator to reason about (ADR-0021's anti-drift intent, preserved). It answers only +//! "how far up the cut-severity ladder is `enforce` armed" — `enforceScope` (the *where* +//! dial) and `mode` (the shadow-vs-act gate) are untouched and orthogonal to it. + +use super::EnabledActions; +use crate::engine::respond::ProposedAction; + +/// How far up the network-cut severity ladder `mode: enforce` is armed. Ordered: +/// each rung implies its narrower predecessor(s) — [`Quarantine`](Self::Quarantine) +/// still arms the edge-cut, it never replaces it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ArmingRung { + /// Rung 1 — the narrowest, most-reversible cut, and the `enforce` default: only the + /// surgical [`DenyNetworkPath`] edge-cut is armed. The broader quarantines stay + /// propose-only until [`Quarantine`](Self::Quarantine) is explicitly opted into. + #[default] + EdgeCut, + /// Rung 2 — an explicit second opt-in beyond the edge-cut rung: also arms the + /// default-deny entry quarantine ([`QuarantineEntry`](ProposedAction::QuarantineEntry), + /// ADR-0010) and the compromised-workload quarantine + /// ([`QuarantineWorkload`](ProposedAction::QuarantineWorkload)). + Quarantine, +} + +impl ArmingRung { + /// Parse the operator-facing rung name (`PROTECTOR_ENFORCE_RUNG` / the chart's + /// `enforceRung`). Unknown or empty values fall back to the narrowest rung + /// (`edge-cut`) — the safe direction when a rung isn't recognized. + pub fn from_name(name: &str) -> Self { + match name.trim() { + "quarantine" => Self::Quarantine, + _ => Self::EdgeCut, + } + } + + /// The [`EnabledActions`] this rung arms — the ordered ladder, so higher rungs + /// always include every action their narrower predecessors arm. + pub fn enabled_actions(self) -> EnabledActions { + let armed = EnabledActions::none().enable(ProposedAction::DenyNetworkPath); + match self { + Self::EdgeCut => armed, + Self::Quarantine => armed + .enable(ProposedAction::QuarantineEntry) + .enable(ProposedAction::QuarantineWorkload), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edge_cut_is_the_default_and_arms_only_the_surgical_cut() { + assert_eq!(ArmingRung::default(), ArmingRung::EdgeCut); + let armed = ArmingRung::EdgeCut.enabled_actions(); + assert!(armed.is_enabled(ProposedAction::DenyNetworkPath)); + assert!(!armed.is_enabled(ProposedAction::QuarantineEntry)); + assert!(!armed.is_enabled(ProposedAction::QuarantineWorkload)); + } + + #[test] + fn quarantine_rung_implies_the_edge_cut_and_adds_both_quarantines() { + let armed = ArmingRung::Quarantine.enabled_actions(); + assert!(armed.is_enabled(ProposedAction::DenyNetworkPath)); + assert!(armed.is_enabled(ProposedAction::QuarantineEntry)); + assert!(armed.is_enabled(ProposedAction::QuarantineWorkload)); + } + + #[test] + fn unknown_or_empty_names_fall_back_to_the_narrowest_rung() { + assert_eq!(ArmingRung::from_name(""), ArmingRung::EdgeCut); + assert_eq!(ArmingRung::from_name("bogus"), ArmingRung::EdgeCut); + assert_eq!( + ArmingRung::from_name(" quarantine "), + ArmingRung::Quarantine + ); + } + + #[test] + fn neither_rung_arms_a_non_network_action_class() { + // The ladder only ever governs the two live-actuatable network classes — no + // rung enables a subtractive/irreversible class regardless of position. + for rung in [ArmingRung::EdgeCut, ArmingRung::Quarantine] { + let armed = rung.enabled_actions(); + assert!(!armed.is_enabled(ProposedAction::RevokeRbacGrant)); + assert!(!armed.is_enabled(ProposedAction::RemoveSecretMount)); + assert!(!armed.is_enabled(ProposedAction::RebindIdentity)); + assert!(!armed.is_enabled(ProposedAction::RemoveEscapePrimitive)); + } + } +} diff --git a/engine/src/engine/respond/actuator/mod.rs b/engine/src/engine/respond/actuator/mod.rs index 89e81115..2809bed9 100644 --- a/engine/src/engine/respond/actuator/mod.rs +++ b/engine/src/engine/respond/actuator/mod.rs @@ -40,6 +40,8 @@ use crate::engine::graph::{Node, Relation, SecurityGraph}; use crate::engine::observe::health::{Health, HealthReport}; use render::workload_namespace; +pub mod arming_ladder; + /// Map an operator-facing enable name to the action class(es) it arms. Only `network` /// is accepted, because only a network deny is **live-actuatable**: an additive, /// engine-owned `NetworkPolicy`/`AuthorizationPolicy` the engine can apply and @@ -53,6 +55,12 @@ use render::workload_namespace; /// those names here would be a lie: the engine still *proposes* those cuts (routed to a /// human / durable-fix PR), you just can't "enable" them. /// +/// This is a generic name→class(es) grouping, kept for [`EnabledActions::from_names`] +/// (test convenience). It is **not** what derives production arming from `enforce`: +/// that is [`arming_ladder`]'s ordered [`ArmingRung`](arming_ladder::ArmingRung) +/// (ADR-0035), which arms the two network classes one rung at a time instead of both +/// at once. +/// /// [`DenyNetworkPath`]: ProposedAction::DenyNetworkPath /// [`QuarantineEntry`]: ProposedAction::QuarantineEntry fn actions_from_name(name: &str) -> &'static [ProposedAction] { diff --git a/engine/src/main.rs b/engine/src/main.rs index 162fbd4e..0355dc06 100644 --- a/engine/src/main.rs +++ b/engine/src/main.rs @@ -11,7 +11,7 @@ use protector::engine::observe::epss::EpssStore; use protector::engine::observe::exploit_intel::KevCatalog; use protector::engine::observe::feed_reload::ReloadableFeed; use protector::engine::policy_log::PolicyDecisionLog; -use protector::engine::respond::ProposedAction; +use protector::engine::respond::actuator::arming_ladder::ArmingRung; use protector::engine::respond::actuator::{ActuationScope, EnabledActions}; use protector::engine::state::SharedSigningBaseline; use protector::metrics::Metrics; @@ -65,8 +65,9 @@ fn env_pairs(key: &str) -> Vec<(String, String)> { } /// The two-setting operating posture (ADR-0021): `audit` (the default — everything -/// observes and proposes, nothing blocks or acts) or `enforce` (arm all three surfaces -/// — signature webhook deny, mesh webhook deny, engine live cut — confined to exactly +/// observes and proposes, nothing blocks or acts) or `enforce` (arm the signature +/// webhook deny, the mesh webhook deny, and — up to the ordered [`ArmingRung`] +/// (ADR-0035) — the engine's live network cut, all confined to exactly /// `enforceScope`). Parsed once at startup and derived into the internal /// `EnforceScope`/`ActuationScope`/`EnabledActions`; there is no per-surface toggle and /// no enforce-everywhere wildcard. @@ -76,12 +77,16 @@ struct Posture { /// The single enforced scope. Empty (namespaces + labels) is audit-everywhere. namespaces: HashSet, labels: Vec<(String, String)>, + /// How far up the network-cut arming ladder `enforce` is armed (ADR-0035). Only + /// consulted when `enforce` is true; irrelevant under `audit`. + rung: ArmingRung, } impl Posture { /// Resolve `PROTECTOR_MODE` + `PROTECTOR_ENFORCE_SCOPE_NAMESPACES` / - /// `PROTECTOR_ENFORCE_SCOPE_LABELS`. `mode: enforce` with an empty scope is refused - /// — enforcing everywhere is the footgun ADR-0021 guards against (no wildcard). + /// `PROTECTOR_ENFORCE_SCOPE_LABELS` + `PROTECTOR_ENFORCE_RUNG`. `mode: enforce` + /// with an empty scope is refused — enforcing everywhere is the footgun ADR-0021 + /// guards against (no wildcard). fn from_env() -> Result { let mode = env_or("PROTECTOR_MODE", "audit") .trim() @@ -100,10 +105,12 @@ impl Posture { wildcard (ADR-0021). List the namespaces/labels to enforce." ); } + let rung = ArmingRung::from_name(&env_or("PROTECTOR_ENFORCE_RUNG", "edge-cut")); Ok(Self { enforce, namespaces, labels, + rung, }) } @@ -118,19 +125,17 @@ impl Posture { } } - /// What the engine may auto-actuate. Under `enforce`: the reversible network cuts are - /// armed — the surgical edge-cut (`DenyNetworkPath`), the default-deny entry quarantine - /// (`QuarantineEntry`), and the compromised-workload quarantine (`QuarantineWorkload`, - /// JEF-284), all additive/reversible network denies (ADR-0010) — confined to - /// `enforceScope` (namespaces or Pod labels). Under `audit`: nothing armed (dry-run) and - /// unscoped — the shadow default. + /// What the engine may auto-actuate. Under `enforce`: the reversible network cuts + /// (ADR-0010) armed by `self.rung` — the ORDERED arming ladder (ADR-0035). The + /// narrowest rung arms only the surgical edge-cut (`DenyNetworkPath`) alone; the + /// quarantines (`QuarantineEntry`, `QuarantineWorkload`) require the explicit + /// second rung, which still implies the edge-cut. Either way, confined to + /// `enforceScope` (namespaces or Pod labels). Under `audit`: nothing armed + /// (dry-run) and unscoped — the shadow default. fn engine_arming(&self) -> (EnabledActions, ActuationScope) { if self.enforce { ( - EnabledActions::none() - .enable(ProposedAction::DenyNetworkPath) - .enable(ProposedAction::QuarantineEntry) - .enable(ProposedAction::QuarantineWorkload), + self.rung.enabled_actions(), ActuationScope::new(self.namespaces.clone(), self.labels.clone()), ) } else { @@ -492,13 +497,25 @@ async fn run() -> Result<()> { #[cfg(test)] mod tests { use super::Posture; + use crate::ArmingRung; use protector::engine::respond::ProposedAction; use protector::policy::EnforceScope; use std::collections::HashSet; /// Build a Posture directly, bypassing env, so the derivation is tested without - /// touching process-global env. + /// touching process-global env. Defaults to the narrowest rung (`edge-cut`) — the + /// `enforce` default (ADR-0035) — so existing callers exercise the safe default. fn posture(enforce: bool, namespaces: &[&str], labels: &[(&str, &str)]) -> Posture { + posture_with_rung(enforce, namespaces, labels, ArmingRung::EdgeCut) + } + + /// Same as [`posture`] but with an explicit arming rung (ADR-0035). + fn posture_with_rung( + enforce: bool, + namespaces: &[&str], + labels: &[(&str, &str)], + rung: ArmingRung, + ) -> Posture { Posture { enforce, namespaces: namespaces.iter().map(|s| s.to_string()).collect(), @@ -506,6 +523,7 @@ mod tests { .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), + rung, } } @@ -525,6 +543,15 @@ mod tests { ); } + #[test] + fn audit_arms_nothing_even_at_the_quarantine_rung() { + // ADR-0035: the rung is only ever consulted under `enforce` — `audit` stays + // byte-identical shadow behavior no matter what rung is configured. + let p = posture_with_rung(false, &["payments"], &[], ArmingRung::Quarantine); + let (active, _scope) = p.engine_arming(); + assert!(active.is_empty(), "audit arms nothing, regardless of rung"); + } + #[test] fn enforce_posture_arms_all_surfaces_in_exactly_the_scope() { // `mode: enforce` + a namespace scope arms both webhook gates (same EnforceScope) @@ -548,6 +575,35 @@ mod tests { ); } + #[test] + fn enforce_narrowest_rung_arms_only_the_edge_cut() { + // ADR-0035: the narrowest rung (the `enforce` default) arms the surgical + // `DenyNetworkPath` edge-cut ALONE — the quarantines stay propose-only. + let p = posture_with_rung(true, &["payments"], &[], ArmingRung::EdgeCut); + let (active, _scope) = p.engine_arming(); + assert!(active.is_enabled(ProposedAction::DenyNetworkPath)); + assert!( + !active.is_enabled(ProposedAction::QuarantineEntry), + "the entry quarantine stays propose-only until the second rung is opted in" + ); + assert!( + !active.is_enabled(ProposedAction::QuarantineWorkload), + "the workload quarantine stays propose-only until the second rung is opted in" + ); + } + + #[test] + fn enforce_quarantine_rung_adds_both_quarantines_and_still_implies_the_edge_cut() { + // ADR-0035: the second rung is an explicit opt-in that ADDS the quarantines — it + // is not a replacement, so the edge-cut stays armed too (a rung implies its + // narrower predecessors; this is one ordered position, not independent toggles). + let p = posture_with_rung(true, &["payments"], &[], ArmingRung::Quarantine); + let (active, _scope) = p.engine_arming(); + assert!(active.is_enabled(ProposedAction::DenyNetworkPath)); + assert!(active.is_enabled(ProposedAction::QuarantineEntry)); + assert!(active.is_enabled(ProposedAction::QuarantineWorkload)); + } + #[test] fn labels_behave_like_namespaces() { // A label-only enforceScope still arms enforcement (the in-process gate matches @@ -597,4 +653,34 @@ mod tests { std::env::remove_var("PROTECTOR_ENFORCE_SCOPE_NAMESPACES"); } } + + #[test] + fn enforce_rung_env_var_selects_the_ladder_position() { + // ADR-0035: `PROTECTOR_ENFORCE_RUNG` unset defaults to the narrowest rung + // (edge-cut-only); `quarantine` opts into the second rung. + // SAFETY: single-threaded within this test; vars are set + cleared here only. + unsafe { + std::env::set_var("PROTECTOR_MODE", "enforce"); + std::env::set_var("PROTECTOR_ENFORCE_SCOPE_NAMESPACES", "payments"); + std::env::remove_var("PROTECTOR_ENFORCE_RUNG"); + } + let default_rung = Posture::from_env().expect("scoped enforce is accepted"); + assert_eq!( + default_rung.rung, + ArmingRung::EdgeCut, + "unset PROTECTOR_ENFORCE_RUNG is the safe narrowest default" + ); + + unsafe { + std::env::set_var("PROTECTOR_ENFORCE_RUNG", "quarantine"); + } + let quarantine_rung = Posture::from_env().expect("quarantine rung is accepted"); + assert_eq!(quarantine_rung.rung, ArmingRung::Quarantine); + + unsafe { + std::env::remove_var("PROTECTOR_MODE"); + std::env::remove_var("PROTECTOR_ENFORCE_SCOPE_NAMESPACES"); + std::env::remove_var("PROTECTOR_ENFORCE_RUNG"); + } + } }