diff --git a/README.md b/README.md index e88e907..2116778 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,15 @@ The source and GitHub release downloads are public; no repository credentials are needed to clone the project or install the CLI. Live [Ratify/Gatekeeper enforcement (#95)](https://github.com/microsoft/brewlet/issues/95) -validation remains pending. Real CPU HPA scale-up/down has passed twice on fresh -disposable clusters with a **fixed-shim candidate** over the 0.5.0 components. +passed its required matrix twice on fresh local disposable clusters with the +released verifier/publisher, a corrected Verifier manifest and the fixed shim. +Real CPU HPA scale-up/down has also passed twice on fresh disposable clusters +with a **fixed-shim candidate** over the 0.5.0 components. The unmodified release exposed a packed-layer GC failure during scale-out; [metrics-driven scaling (#94)](https://github.com/microsoft/brewlet/issues/94) and the [live runbook](docs/live-validation.md) distinguish that baseline from -the candidate and its remaining cold-start limits. +the candidate, fixture-only admission settings and remaining cold-start limits. +Neither result is an unmodified 0.5.0 pass or production certification. See [preview status and validation](https://brewlet.sh/docs/#preview-status-and-validation) for the distinction between release smoke, component, and live cluster coverage. diff --git a/admission/README.md b/admission/README.md index e6066fd..dbd5a47 100644 --- a/admission/README.md +++ b/admission/README.md @@ -5,13 +5,14 @@ signatures and identities** before a workload runs. It admits a pod on the Brewlet runtime only when the Pod image resolves to a digest with a valid, trusted final-image managed-dependency attestation. -> **Preview: live admission validation pending.** Evaluate only in a disposable -> cluster. The component tests below use real verification and policy logic but -> substitute registry access and plugin transport. Live registry discovery, -> external plugin execution, Ratify/Gatekeeper wiring, and Kubernetes admission -> enforcement remain unproven by this suite; see -> [#95](https://github.com/microsoft/brewlet/issues/95). This is not a -> production-readiness claim. +> **Preview: live candidate validation, not production certification.** Two +> consecutive fresh local arm64 clusters passed the real registry, external +> verifier, Ratify/Gatekeeper and Kubernetes admission matrix, including serving +> JavaApplication-generated Pods. These runs use the released 0.5.0 verifier and +> publisher, a fixed-shim candidate and the corrected Verifier manifest, not +> unmodified 0.5.0. See [#95](https://github.com/microsoft/brewlet/issues/95) and +> the [live runbook](../docs/live-validation.md) for evidence and fixture-only +> cache, registry and TLS configuration. Evaluate only in a disposable cluster. It provides the cluster-side enforcement that the managed-dependency-bundles design (specification §4.5) leaves to admission policy: requiring a valid, @@ -99,7 +100,10 @@ Deliver the binary to Ratify one of two ways: startup. 2. **Baked image**: place the binary at `/home/nonroot/.ratify/plugins/brewlet-managed-dependencies` in a custom - Ratify image and drop `spec.source`. + Ratify image, set `RATIFY_CONFIG=/home/nonroot/.ratify` so Ratify discovers + that plugin directory, and drop `spec.source`. Pin the resulting image by + digest from its first deployment. This is the delivery route exercised by + the live scenario. The plugin binary links Ratify's oras store, and therefore its full dependency tree (oras-go plus cloud registry auth SDKs). Build it with the same toolchain @@ -111,6 +115,17 @@ Prerequisites: Ratify v1.4.x installed as a Gatekeeper external-data provider (`ratify-provider`), Gatekeeper installed, and a registry that exposes the OCI 1.1 Referrers API. +The pinned Ratify v1.4.5 chart CRD selects the plugin through `spec.name`; +it rejects `Verifier.spec.type`, even though the SDK exposes a Type field. +The shipped manifest omits that unsupported field. + +Enable Gatekeeper external data and configure its validation webhook with +`failurePolicy: Fail`. Its rules must cover Pod CREATE/UPDATE and the +`pods/ephemeralcontainers` UPDATE subresource. Provider timeouts must be shorter +than the webhook timeout; the live fixture uses 20 and 30 seconds respectively. +`enforcementAction: deny` does not itself make webhook transport errors fail +closed when the webhook has `failurePolicy: Ignore`. + ```bash # 1. Edit deploy/20-ratify-verifier.yaml: set trustedPublicKey and # expectedBuilderIdentity, and pin the plugin source by digest. @@ -317,6 +332,12 @@ is admitted. claims, no cross-candidate trust merging, and unrelated/overlapping verifier success in either selection order. These are component tests, not subprocess, registry, or Kubernetes deployment tests. +- The independently runnable live scenario uses pinned Zot native referrers, + Ratify v1.4.5, Gatekeeper v3.18.3 and the real external verifier. Its 47 required + assertions cover trusted serving, all invalid-evidence classes, signer + rotation with fresh candidate reports, competing verifiers, regular/init/ + ephemeral admission requests, exclusions, registry failures and provider + outage. It never treats missing prerequisites as a skip. Run: @@ -324,3 +345,6 @@ Run: ( cd core && go test ./pkg/attest/... ) ( cd admission/ratify-verifier && go test ./... ) ``` + +For the live invocation, release/candidate distinction, diagnostics and remaining +limits, use the [disposable live-validation runbook](../docs/live-validation.md). diff --git a/admission/deploy/20-ratify-verifier.yaml b/admission/deploy/20-ratify-verifier.yaml index 74f4cb2..9f27741 100644 --- a/admission/deploy/20-ratify-verifier.yaml +++ b/admission/deploy/20-ratify-verifier.yaml @@ -36,7 +36,6 @@ metadata: name: verifier-brewlet-managed-dependencies spec: name: brewlet-managed-dependencies - type: brewlet-managed-dependencies version: 1.0.0 artifactTypes: application/vnd.brewlet.attestation.v1+json # Option 1: download the plugin binary from a registry at startup. Pin by diff --git a/admission/ratify-verifier/deploy_test.go b/admission/ratify-verifier/deploy_test.go new file mode 100644 index 0000000..786f136 --- /dev/null +++ b/admission/ratify-verifier/deploy_test.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package main + +import ( + "os" + "testing" + + "sigs.k8s.io/yaml" +) + +func TestDeployedVerifierUsesV145ChartFields(t *testing.T) { + raw, err := os.ReadFile("../deploy/20-ratify-verifier.yaml") + if err != nil { + t.Fatal(err) + } + var manifest struct { + Spec map[string]any `json:"spec"` + } + if err := yaml.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + // The pinned v1.4.5 chart CRD omits type, even though its Go SDK has it. + // The live API server rejects the entire Verifier when this field is set. + if _, exists := manifest.Spec["type"]; exists { + t.Fatal("Ratify v1.4.5 chart CRD rejects spec.type; select the plugin with spec.name") + } + if manifest.Spec["name"] != pluginName { + t.Fatalf("spec.name = %v, want %q", manifest.Spec["name"], pluginName) + } +} diff --git a/docs/README.md b/docs/README.md index f665735..adbda33 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,7 +36,7 @@ Implemented functionality and live end-to-end validation are different: |---|---|---| | Public release access and local use | The Pages release smoke exercises the released CLI, local Java example, Maven plugin, anonymous chart download, component manifest access, and release provenance. | It renders but does not install the chart or provision nodes. | | Kubernetes runtime | The source-built E2E tiers exercise provisioning, serving, manual scaling, and runtime telemetry. | These scenarios do not establish the admission or autoscaling loops below, or production readiness. | -| Managed-dependency admission | Component tests exercise real DSSE verification and Ratify policy logic with substituted registry access and plugin transport. | Live registry discovery, external plugin execution, Ratify/Gatekeeper wiring, and Kubernetes admission outcomes remain pending in [#95](https://github.com/microsoft/brewlet/issues/95). | +| Managed-dependency admission | Component tests use substituted registry access and plugin transport; two fresh local arm64 clusters additionally passed 47 real registry, external verifier, Ratify/Gatekeeper and API assertions, including serving JavaApplication Pods. | Live candidate validation uses the released verifier/publisher, corrected Verifier manifest, fixed shim and fixture-only uncached settings. It is not an unmodified 0.5.0 pass or ordinary ephemeral-debug execution support; see [#95](https://github.com/microsoft/brewlet/issues/95) and the [runbook](live-validation.md). | | CPU autoscaling | Alongside HPA creation and simulated HPA ownership tests, two fresh local arm64 clusters passed real metrics-server-driven 1-to-3-to-1 scaling, Ready Pods, serving endpoints and three ownership reconciliations with a fixed-shim candidate. | Unmodified 0.5.0 exposed a packed-layer GC scale-out failure. The candidate fixes warm reuse, not cold startup after missing-source GC; see [#94](https://github.com/microsoft/brewlet/issues/94) and the [runbook](live-validation.md). | [The validation tracker (#93)](https://github.com/microsoft/brewlet/issues/93) @@ -46,8 +46,9 @@ The existing E2E harness permits skips; a successful run alone is not evidence that every assertion executed. Broader strict-mode work is tracked in [#13](https://github.com/microsoft/brewlet/issues/13). -Admission remains a coverage gap. CPU validation demonstrated both a release -defect and the scoped candidate correction; it is not an unmodified 0.5.0 pass. +Admission exposed an unsupported Verifier manifest field; CPU validation exposed +a packed-layer GC defect. Both passes use explicitly identified candidate +corrections rather than an unmodified 0.5.0 installation. Do not treat component tests or release smoke results as proof of either live loop, or these disposable-cluster runs as production certification. diff --git a/docs/admission-enforcement.md b/docs/admission-enforcement.md index f27ae92..ca78d9a 100644 --- a/docs/admission-enforcement.md +++ b/docs/admission-enforcement.md @@ -7,16 +7,17 @@ valid, trusted final-image managed-dependency attestation. It combines a verifies Brewlet's native OCI 1.1 referrer in place so the runtime executes that same admitted image. -!!! warning "Preview: live admission validation pending" +!!! warning "Preview: live candidate validation, not production certification" Brewlet is a pre-1.0 preview; evaluate this integration only in a disposable - cluster. Component tests cover real DSSE verification and Ratify policy - decisions, but substitute registry access and plugin transport. They do not - prove live referrer discovery, external plugin delivery/execution, - Ratify/Gatekeeper wiring, or Kubernetes admission enforcement. - [Issue #95](https://github.com/microsoft/brewlet/issues/95) tracks that - end-to-end evidence. The policy contract below is implemented, not a - production-readiness certification. + cluster. Two consecutive fresh local arm64 clusters passed live referrer + discovery, external plugin execution, Ratify/Gatekeeper enforcement and + serving JavaApplication-generated Pods. The candidate uses the released + 0.5.0 verifier/publisher, the fixed shim and a corrected Verifier manifest; + this is not an unmodified 0.5.0 pass. + [Issue #95](https://github.com/microsoft/brewlet/issues/95) and the + [runbook](live-validation.md) retain the exact evidence and fixture-only + cache, registry and TLS settings. This is not production certification. The plugin verifies Brewlet's native evidence directly, reusing Brewlet's own DSSE and predicate verification code instead of requiring evidence to be @@ -88,7 +89,9 @@ satisfies the complete contract; claims are never combined across candidates. - Ratify v1.4.x installed as the `ratify-provider` Gatekeeper external-data provider. -- Gatekeeper installed. +- Gatekeeper installed with external data enabled, validation webhook + `failurePolicy: Fail`, and rules covering Pod CREATE/UPDATE and + `pods/ephemeralcontainers` UPDATE. - A registry that implements the **OCI 1.1 Referrers API**. - A digest-pinned Brewlet application image with a signed final-image managed-dependency attestation. @@ -105,6 +108,11 @@ For private registries, configure the oras store's `authProvider`, such as a `k8Secrets` provider backed by a Docker-config Secret. Evidence that cannot be authenticated or fetched cannot grant admission. +Keep provider timeouts shorter than webhook timeouts; the live fixture uses +20 seconds for the provider and 30 seconds for the validating webhook. +The constraint's `enforcementAction: deny` cannot compensate for webhook +`failurePolicy: Ignore` during transport failures. + --- ## Build and deliver the verifier plugin @@ -143,8 +151,10 @@ Choose one delivery model: /home/nonroot/.ratify/plugins/brewlet-managed-dependencies ``` - Use a digest-pinned custom Ratify image and remove `spec.source` from the - Verifier resource. + Set `RATIFY_CONFIG=/home/nonroot/.ratify` to select that plugin directory. + Use a digest-pinned custom Ratify image from the first deployment and remove + `spec.source` from the Verifier resource. The live scenario exercises this + delivery route. The plugin links Ratify's oras store and its registry-auth dependencies. Build it with a toolchain compatible with the Ratify installation. @@ -184,6 +194,10 @@ The resources configure: | [`40-gatekeeper-constrainttemplate.yaml`](https://github.com/microsoft/brewlet/blob/main/admission/deploy/40-gatekeeper-constrainttemplate.yaml) | Gatekeeper ConstraintTemplate | Sends regular, init, and ephemeral container images from Brewlet-runtime pods to Ratify. | | [`50-gatekeeper-constraint.yaml`](https://github.com/microsoft/brewlet/blob/main/admission/deploy/50-gatekeeper-constraint.yaml) | Gatekeeper Constraint | Applies the check to Pod CREATE and UPDATE requests, with explicit namespace exclusions. | +Ratify v1.4.5's shipped chart CRD rejects `Verifier.spec.type`; use `spec.name` +to select the plugin, as the corrected resource does. This defect was exposed +by live API deployment and is covered by a manifest regression test. + Observe warnings and test known-good and known-bad images before changing the constraint to `enforcementAction: deny`. @@ -234,6 +248,20 @@ different key or unsigned image to confirm the expected pass and fail paths. ## Production trust model and limitations +### Live fixture boundaries + +The [live scenario](live-validation.md) disables Gatekeeper response, Ratify +provider and ORAS discovery caches, and directs the ORAS content cache to an +immutable empty layout. This makes every candidate fetch real and avoids a +concurrent content-cache index failure observed with the pinned Ratify version. +It is a fixture configuration, not validation of production cache concurrency +or revocation latency. Do not infer a key-rotation or outage guarantee for +previously cached images from tests of fresh verification requests. + +Regular and init image requests and valid ephemeral-container subresource +updates are exercised at the API boundary. This does **not** establish support +for running ordinary-image ephemeral debug containers through Brewlet. + ### Key distribution and rotation The trust anchor is a bare ECDSA P-256 public key. Brewlet does not use diff --git a/docs/concepts.md b/docs/concepts.md index 40405f2..b9c0db9 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -84,7 +84,7 @@ directories. Each implementation maps to a section of the | **`brewlet-node-provisioner`** | Privileged DaemonSet. On opted-in nodes it installs the shim, materializes JDK roots + launcher layers, registers the containerd runtime, and labels the node ready. | Source: [`provisioner/`](https://github.com/microsoft/brewlet/tree/main/provisioner); deployment: [`kubernetes/deploy/node-provisioner.yaml`](https://github.com/microsoft/brewlet/blob/main/kubernetes/deploy/node-provisioner.yaml); spec §5 | | **`brewlet-operator`** | Node lifecycle controller. Watches opted-in nodes, manages the provisioner DaemonSet + the `brewlet` RuntimeClass, and tracks node readiness. | [`kubernetes/cmd/manager/`](https://github.com/microsoft/brewlet/tree/main/kubernetes/cmd/manager), spec §8.1 | | **`brewlet-admission`** | Mutating+validating webhook. Overwrites compatibility hints from the selected Pod image onto brewlet pods and matches/steers requested JDK/launcher onto compatible nodes. | [`kubernetes/cmd/admission/`](https://github.com/microsoft/brewlet/tree/main/kubernetes/cmd/admission), spec §8.3 | -| **Ratify/Gatekeeper enforcement** | Optional policy requiring a valid, trusted final-image managed-dependency attestation for every image on a Brewlet-runtime pod; live enforcement validation is pending in [#95](https://github.com/microsoft/brewlet/issues/95). | [Admission enforcement](admission-enforcement.md), [`admission/`](https://github.com/microsoft/brewlet/tree/main/admission) | +| **Ratify/Gatekeeper enforcement** | Optional policy requiring a valid, trusted final-image managed-dependency attestation for every image on a Brewlet-runtime pod; [#95](https://github.com/microsoft/brewlet/issues/95) records live candidate validation with a corrected manifest and fixture-only settings, not production certification. | [Admission enforcement](admission-enforcement.md), [`admission/`](https://github.com/microsoft/brewlet/tree/main/admission) | | **`RuntimeClass/brewlet`** | Routes pods to the shim handler; its `nodeSelector` keeps workloads on ready nodes. | [`deploy/runtimeclass.yaml`](https://github.com/microsoft/brewlet/blob/main/kubernetes/deploy/runtimeclass.yaml), spec §7 | | **`JavaApplication` CRD** | The higher-level developer-facing deployment descriptor, reconciled by the operator's `JavaApplication` controller (§8.2). | [`deploy/javaapplication-crd.yaml`](https://github.com/microsoft/brewlet/blob/main/kubernetes/deploy/javaapplication-crd.yaml), spec §9 | | **Helm chart** | SpinKube-style single-command activation of the operator + provisioner RBAC + webhook. | [`charts/brewlet/`](https://github.com/microsoft/brewlet/tree/main/kubernetes/charts/brewlet/) | @@ -141,8 +141,8 @@ directories. Each implementation maps to a section of the `nodeAffinity`) onto a node with a compatible JDK/launcher. The optional [Ratify/Gatekeeper admission integration](admission-enforcement.md) provides a policy requiring a trusted final-image managed-dependency attestation. - Its live enforcement path remains pending validation; use disposable - evaluation clusters only. + Its live candidate validation uses a corrected manifest and fixture-only + settings; use disposable evaluation clusters only. 5. The **containerd shim** requires the CRI-recorded requested image to be digest-pinned, resolves that exact target from containerd's content store, verifies its selected platform manifest against CRI's image-config digest, diff --git a/docs/deploying-workloads.md b/docs/deploying-workloads.md index 4eac584..b988f32 100644 --- a/docs/deploying-workloads.md +++ b/docs/deploying-workloads.md @@ -16,8 +16,10 @@ The optional [managed-dependency admission integration](admission-enforcement.md provides a policy for gating either form in a disposable evaluation cluster. The Gatekeeper policy applies to every pod with `runtimeClassName: brewlet`, including pods generated by `JavaApplication`, and requires each image to be -digest-pinned with a trusted final-image attestation. Live enforcement validation -is pending in [#95](https://github.com/microsoft/brewlet/issues/95). +digest-pinned with a trusted final-image attestation. Live candidate validation +passed twice on fresh local clusters; [#95](https://github.com/microsoft/brewlet/issues/95) +and the [runbook](live-validation.md) distinguish its corrected manifest and +fixture-only settings from unmodified 0.5.0. --- diff --git a/docs/live-validation.md b/docs/live-validation.md index f595005..3f333bb 100644 --- a/docs/live-validation.md +++ b/docs/live-validation.md @@ -7,9 +7,12 @@ contracts. They are not production certification, a performance benchmark, or the broader zero-skip rewrite tracked in [#13](https://github.com/microsoft/brewlet/issues/13). -**Coverage status:** CPU HPA passed twice on fresh local arm64 clusters with the -fixed-shim candidate described below. Admission remains pending. The consolidated -acceptance work remains tracked in +**Coverage status:** CPU HPA passed twice on fresh local arm64 clusters and +twice on hosted amd64 with the fixed-shim candidate described below. Admission +passed its 47-assertion matrix twice on fresh local arm64 clusters and twice on +hosted amd64 with that shim and the corrected Verifier manifest. Neither is an +unmodified 0.5.0 pass. +The acceptance work and delivery are tracked in [#93](https://github.com/microsoft/brewlet/issues/93), [#94](https://github.com/microsoft/brewlet/issues/94), and [#95](https://github.com/microsoft/brewlet/issues/95). A harness implementation or @@ -22,11 +25,12 @@ coverage boundary. Use an otherwise idle Docker engine with at least **4 CPUs and 7 GiB RAM**. The fixture bounds its single kind node to 3 CPUs/6 GiB and the registry to 0.5 CPU/256 MiB. Run the two scenarios sequentially on that host. Linux amd64 -is the hosted-workflow target; local native Linux/arm64 and macOS Docker Desktop -are supported fixture targets, not additional architecture acceptance claims. +is the hosted-workflow target; the recorded local runs use Linux/arm64 nodes +on macOS Docker Desktop. Other host/architecture combinations are fixture +targets, not additional acceptance claims. Cross-architecture emulation is deliberately not selected. -Required host tools: Python 3, Docker, **kind 0.30.0**, kubectl, Helm, Go +Required host tools: Python 3.12+, Docker, **kind 0.30.0**, kubectl, Helm, Go (the toolchain required by the pinned verifier), JDK 21 or newer, Maven, Git, curl, tar, OpenSSL, and `htpasswd` (Apache utilities, for admission's private registry fixture). The demo is compiled with `--release 21`. Internet @@ -168,8 +172,84 @@ ordinary Deployments with `runtimeClassName: brewlet`. Custom metrics, KEDA, scale-to-zero, JVM scaling algorithms and node/cluster autoscaling are outside this validation. +## Native admission contract and configuration + +Scenario A replaces only the empty invocation-owned Distribution registry with +**Zot 2.1.8**, pinned to +`sha256:cd2aea942f428630bcb4190542be6abd35e14177aab84fc7ccad0dca8ecb363d`. +Distribution 3.0.0 was demonstrated to return 404 for the native Referrers API; +fallback tags cannot substitute for discovery. Zot uses the same private network +and loopback host port, explicit container identity/ownership and an isolated +0700 data directory owned by the invoking UID/GID. + +The released Maven plugin publishes a signed dependency bundle, a runnable +thin-JAR application and its native final-image DSSE/in-toto referrer. Negative +fixtures retain discoverable referrers and exercise wrong key, builder, subject, +malformed, tampered, incomplete and split-across-candidate evidence. Each +denial must be the named Gatekeeper policy's response to a valid API request, +not an invalid object, missing runtime or scheduling failure. + +The verifier is compiled from exact Brewlet 0.5.0 source and delivered by the +documented **baked image** route, over digest-pinned Ratify **1.4.5**. Set +`RATIFY_CONFIG=/home/nonroot/.ratify` to discover the baked plugin directory. +Ratify's **1.15.6** chart is read from source commit +`f5fd56fe58ba0a604eca247276acb7899e026435`. The fixture installs +digest-pinned Gatekeeper **3.18.3** from commit +`5be06a95665624a619a8082677dcf942043bf514`. Assertion records capture the exact +base images, generated image and verifier binary digests. + +Live deployment found that the pinned chart CRD rejects `Verifier.spec.type`. +The shipped resource is corrected to select the same plugin through `spec.name`. +The scenario starts from the release resources and records that one-field +candidate correction explicitly; trust, predicate and verifier-identity rules +are unchanged. + +Gatekeeper has external data enabled, cache TTL 0 and validation +`failurePolicy: Fail`. The Ratify provider timeout is 20 seconds, inside the +30-second validating webhook timeout. Gatekeeper and Ratify each have bounded +240-second rollout waits. Ratify uses a test-only `Recreate` rollout; current +Ready Pods and Service endpoints must agree +before enforcement tests proceed. Report transport uses a unique fixture CA and +verified SANs, never `curl --insecure`. + +Provider/discovery caches are disabled. The pinned Ratify version also exposed +concurrent writes to its shared ORAS content-cache index, so this test uses an +empty root-owned read-only OCI layout for that cache. Every verification still +fetches and verifies actual registry data. These runs do not establish +production content-cache concurrency or cache-revocation behavior. Ratify 1.4.5 +omits `errorReason` in its aggregated plugin report; the fixture also captures +the unchanged plugin's real subprocess output for rejection diagnostics. +Actual Ratify decisions and actual API admission outcomes remain mandatory. + +Current-only and current-plus-obsolete evidence admit the **same subject**; +obsolete-only evidence denies it. Fresh reports must contain every real +candidate. Another verifier's success cannot substitute, its failure cannot +veto a complete valid Brewlet candidate, and partial claims cannot be combined. +Fresh subjects exercise missing evidence, real registry authentication/fetch +failures and unavailable Ratify. Valid CREATE/UPDATE requests cover regular and +init images; ephemeral images use the proper UPDATE subresource. Non-Brewlet +behavior and all namespace exclusions are checked separately. + ## Evidence and failure diagnosis +### Recorded hosted acceptance + +Both jobs ran twice consecutively on independent fresh clusters from clean +committed source. The later documentation-only commits do not change the +archived fixture/runtime source hashes. + +| Scenario | Source commit | Hosted evidence | Fresh cluster suffixes | Required assertions | +|---|---|---|---|---| +| CPU HPA | `35b7c1c1ee9e7b91ed4665086d8d97a6c09f67e5` | [Run 35419764860](https://github.com/microsoft/brewlet/actions/runs/35419764860), artifact `live-hpa-1` | `b17f0615694a`, `d8482594728a` | 11 per run | +| Native admission | `b2684abda75c35289096c0dafca1939f770ecdd2` | [Run 35423006340](https://github.com/microsoft/brewlet/actions/runs/35423006340), artifact `live-admission-1` | `c47da5e70141`, `c616823c6d60` | 47 per run | + +Both pairs reported successful cleanup with no diagnostic-capture errors. +The source archives, per-file manifests and identical verifier/shim binary +hashes within each architecture's pair were checked against the reported +SHA-256 values. Hosted artifacts follow the workflow's 14-day retention; +rerun the pinned scenario rather than treating an expired artifact link as +new evidence. + The printed per-invocation directory contains `versions.json`, owned resource identities, `assertions.json`, `result.json`, and failure diagnostics. `result.json` reports success only after the scenario and cleanup succeed. diff --git a/docs/security.md b/docs/security.md index 9544b7c..95fe8ec 100644 --- a/docs/security.md +++ b/docs/security.md @@ -140,10 +140,12 @@ provides a policy requiring the final-image attestation for `runtimeClassName: brewlet` pods. It requires an OCI 1.1 Referrers-API registry and digest-pinned images. Its implemented policy denies images without a complete valid candidate, including discovery or verification failures. -Component coverage is not proof of live cluster enforcement: registry access, -external plugin execution, and Kubernetes admission outcomes remain pending -validation in [#95](https://github.com/microsoft/brewlet/issues/95). Evaluate only -in a disposable cluster; do not treat this preview as a production trust boundary. +Component coverage alone is not proof of live cluster enforcement. Separate live +candidate validation passed twice on fresh local clusters using the released +0.5.0 verifier/publisher, corrected Verifier manifest, fixed shim and fixture-only +uncached settings. See [#95](https://github.com/microsoft/brewlet/issues/95) and +the [runbook](live-validation.md) for evidence and limits. Evaluate only in a +disposable cluster; do not treat this preview as a production trust boundary. --- diff --git a/integration-tests/e2e/live/admission.py b/integration-tests/e2e/live/admission.py new file mode 100644 index 0000000..ca14bfa --- /dev/null +++ b/integration-tests/e2e/live/admission.py @@ -0,0 +1,887 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Scenario A: native Brewlet evidence through live Ratify/Gatekeeper admission. + +Run from any directory with Python 3. See the live runbook for prerequisites. +Every assertion is mandatory; infrastructure or inconclusive denials fail. +""" + +import base64 +import contextlib +import copy +import hashlib +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tarfile +import urllib.error +import urllib.request + +from common import Fixture, OWNER_LABEL, ROOT, redact, run, wait +from admission_helpers import ( + ARTIFACT, BUILDER, VERIFIER, Registry, assert_admission, assert_candidates, + assert_discoverable, assert_rejection_reasons, digest, encoded, extract_result, signed_envelope, + variant_statement, +) +from admission_registry import native_registry + + +HERE = Path(__file__).resolve().parent +RATIFY_COMMIT = "f5fd56fe58ba0a604eca247276acb7899e026435" +RATIFY_IMAGE = ("ghcr.io/notaryproject/ratify:v1.4.5@sha256:" + "6607d1f84bf314dcaea51c6b7f4cbe30dfc85cdd8feb8fa40094e0bdb067b43c") +GATEKEEPER_COMMIT = "5be06a95665624a619a8082677dcf942043bf514" +GATEKEEPER_IMAGE = ("openpolicyagent/gatekeeper:v3.18.3@sha256:" + "20e9c73472d39644de0fa5941a06894ae79dab41359996c4e0e352b6fb1a62cd") +RELEASE_SOURCE = "f0b9334f7b29177d2ba4b49b044163ef69e16af7" +PLUGIN_GOAL = "sh.brewlet:brewlet-maven-plugin:0.5.0:" +REPOSITORY = "apps/admission" +CONTENT_CACHE = "/admission-no-content-cache" +REJECTION_REASONS = { + "wrong-key": "DSSE key ID is not trusted", + "obsolete": "DSSE key ID is not trusted", + "wrong-builder": "identity does not match", + "wrong-subject": "in-toto subject digest mismatch", + "malformed": "decode DSSE envelope", + "tampered": "DSSE signature verification failed", + "incomplete": "applicationJarDigest is not a sha256 digest", +} + + +def download(url, destination): + with urllib.request.urlopen(url, timeout=90) as response: + destination.write_bytes(response.read()) + return hashlib.sha256(destination.read_bytes()).hexdigest() + + +class Admission: + def __init__(self, fixture): + self.f = fixture + self.registry = Registry(fixture) + self.expected = {} + self.reasons = {} + self.subjects = {} + self.port = None + self.ca = None + self.successful = set() + + def command(self, name, argv, **kwargs): + result = self.f.run(argv, check=False, **kwargs) + self.f.save(name + ".log", result.stdout + result.stderr) + if result.returncode: + raise RuntimeError(f"{name} failed ({result.returncode}); see {self.f.work}") + return result + + def kube_json(self, *argv): + return json.loads(self.f.kube(*argv).stdout) + + def manifest(self, filename): + # kubectl's own YAML decoder avoids a second Python package dependency. + result = self.f.kube("create", "--dry-run=client", "--validate=false", + "-f", str(self.f.source / "admission/deploy" / filename), + "-o", "json") + return json.loads(result.stdout) + + def setup_registry_route(self): + self.registry_config, self.registry_credentials, self.registry_storage = native_registry(self.f) + self.f.own_container(self.f.node) + node_id = self.f.node_id + self.f.own_container(self.f.registry_id) + internal = self.f.registry_internal + path = "/etc/containerd/certs.d/" + internal + host = f'server = "http://{internal}"\n[host."http://{internal}"]\n capabilities = ["pull", "resolve"]\n' + self.f.run(["docker", "exec", node_id, "mkdir", "-p", path]) + self.f.run(["docker", "exec", "-i", node_id, "tee", path + "/hosts.toml"], input=host) + address = self.f.registry_ip + self.registry_ip = address + self.f.record("admission-fixture-network", { + "registry": self.f.registry, "registryInternal": internal, + "registryIP": address, "plainHTTP": "only the invocation-owned registry", + "containerdHosts": path, "tls": "fixture CA verified; no insecure TLS mode", + }) + + def keys(self): + self.current = self.f.private / "admission-current.pem" + self.obsolete = self.f.private / "admission-obsolete.pem" + self.public = self.f.private / "admission-current.pub" + for key in [self.current, self.obsolete]: + run(["openssl", "genpkey", "-algorithm", "EC", "-pkeyopt", + "ec_paramgen_curve:P-256", "-out", str(key)]) + key.chmod(0o600) + run(["openssl", "pkey", "-in", str(self.current), "-pubout", + "-out", str(self.public)]) + + def publish(self): + project = self.f.private / "admission-project" + project.mkdir() + fixtures = ROOT / "integration-tests/fixtures" + for name in ["managed-dependency-bom", "managed-dependency-bundle", "demo-app"]: + shutil.copytree(fixtures / name, project / name, + ignore=shutil.ignore_patterns("target", "*.class")) + def maven(name, project_name, *arguments): + self.command(name, [*self.f.maven_args, "-f", + str(project / project_name / "pom.xml"), *arguments], + timeout=900) + maven("admission-bom", "managed-dependency-bom", "install") + bundle = self.f.registry + "/platform/admission:release" + maven("admission-bundle", "managed-dependency-bundle", "package", + PLUGIN_GOAL + "dependency-bundle", + "-Dbrewlet.dependencyBundleImage=" + bundle, + "-Dbrewlet.sourceBom=com.example.platform:approved-bom:1.0.0", + "-Dbrewlet.signingKey=" + str(self.current), + "-Dbrewlet.signerIdentity=live-platform-builder") + app = self.f.registry + "/" + REPOSITORY + ":release" + maven("admission-application", "demo-app", "-Pmanaged-dependencies", "package", + PLUGIN_GOAL + "push", "-Dbrewlet.image=" + app, + "-Dbrewlet.dependencyBundle=" + bundle, + "-Dbrewlet.mainClass=com.example.Hello", + "-Dbrewlet.signingKey=" + str(self.current), + "-Dbrewlet.trustedPublicKey=" + str(self.public), + "-Dbrewlet.trustedSignerIdentity=live-platform-builder", + "-Dbrewlet.builderIdentity=" + BUILDER) + raw, manifest = self.registry.manifest(REPOSITORY, "release") + self.release_subject = {"mediaType": manifest["mediaType"], "digest": digest(raw), + "size": len(raw)} + index = self.registry.referrers(REPOSITORY, digest(raw)) + candidates = [item for item in index["manifests"] if item.get("artifactType") == ARTIFACT] + if len(candidates) != 1: + raise AssertionError(f"released Maven plugin must publish one native attestation: {index}") + candidate = candidates[0]["digest"] + _, evidence = self.registry.manifest(REPOSITORY, candidate) + envelope = self.registry.blob(REPOSITORY, evidence["layers"][0]["digest"]) + self.statement = json.loads(base64.b64decode(json.loads(envelope)["payload"])) + if self.statement["predicate"]["finalImageDigest"] != digest(raw): + raise AssertionError("Maven evidence does not bind the final published index") + self.expected["release"] = {candidate: True} + self.subjects["release"] = self.release_subject + self.f.save("admission-release-referrers.json", index) + self.f.save("admission-release-statement.json", self.statement) + self.f.record("admission-release-publication", { + "version": "0.5.0", "source": RELEASE_SOURCE, + "publisher": PLUGIN_GOAL + "push", "subject": digest(raw), "candidate": candidate, + }) + + def fixture(self, name, kinds): + subject = self.registry.clone_subject(REPOSITORY, "release", name) + expected = {} + for number, kind in enumerate(kinds): + statement = variant_statement(self.statement, subject, kind) + key = self.obsolete if kind in ("wrong-key", "obsolete") else self.current + envelope = signed_envelope(statement, key) + if kind == "malformed": + envelope = b'{"payload":not-json}' + elif kind == "tampered": + document = json.loads(envelope) + payload = json.loads(base64.b64decode(document["payload"])) + payload["predicate"]["sourceBom"] = "tampered:after-signing:1" + document["payload"] = base64.b64encode(encoded(payload)).decode() + envelope = encoded(document) + candidate = self.registry.publish(REPOSITORY, subject, envelope, + f"{name}-{number}") + expected[candidate] = kind == "valid" + if kind in REJECTION_REASONS: + self.reasons[candidate] = REJECTION_REASONS[kind] + self.subjects[name] = subject + self.expected[name] = expected + self.discovery(name) + return self.image(name) + + def discovery(self, name): + index = self.registry.referrers(REPOSITORY, self.subjects[name]["digest"]) + self.f.save(f"admission-{name}-discovery.json", index) + assert_discoverable(index, self.expected[name]) + return index + + def image(self, name): + return self.f.registry_internal + "/" + REPOSITORY + "@" + self.subjects[name]["digest"] + + def install_gatekeeper(self): + target = self.f.private / "admission-gatekeeper.yaml" + checksum = download( + f"https://raw.githubusercontent.com/open-policy-agent/gatekeeper/{GATEKEEPER_COMMIT}/deploy/gatekeeper.yaml", + target) + text = target.read_text().replace("openpolicyagent/gatekeeper:v3.18.3", + GATEKEEPER_IMAGE) + # The controller must have external-data enabled from its first start. + text = text.replace("- --operation=webhook", + "- --enable-external-data\n" + " - --external-data-provider-response-cache-ttl=0\n" + " - --operation=webhook") + text = text.replace("replicas: 3", "replicas: 1") + self.f.kube("apply", "-f", "-", input=text, timeout=180) + self.f.kube("-n", "gatekeeper-system", "rollout", "status", + "deployment/gatekeeper-controller-manager", "--timeout=240s", timeout=260) + self.f.kube("wait", "--for=condition=Established", + "crd/providers.externaldata.gatekeeper.sh", "--timeout=120s") + webhook = self.f.get("validatingwebhookconfiguration", + "gatekeeper-validating-webhook-configuration") + for item in webhook["webhooks"]: + if item["name"] == "validation.gatekeeper.sh": + item["failurePolicy"] = "Fail" + item["timeoutSeconds"] = 30 + self.f.apply(webhook) + self.f.record("admission-gatekeeper", { + "sourceCommit": GATEKEEPER_COMMIT, "sourceSHA256": checksum, + "image": GATEKEEPER_IMAGE, "externalDataCacheTTL": 0, + "failurePolicy": "Fail", "replicas": 1, + }) + + def build_ratify(self): + build = self.f.private / "admission-image" + build.mkdir() + module = self.f.source / "admission/ratify-verifier" + environment = ["env", "GOOS=linux", "GOARCH=" + self.f.arch, "CGO_ENABLED=0"] + self.command("admission-build-verifier", + environment + ["go", "build", "-trimpath", "-o", + str(build / VERIFIER), "."], cwd=module, timeout=900) + self.command("admission-build-other", + environment + ["go", "build", "-trimpath", "-o", + str(build / "admission-other"), + str(HERE / "admission_other.go")], + cwd=module, timeout=900) + cache = build / "admission-no-content-cache" + (cache / "blobs/sha256").mkdir(parents=True) + (cache / "blobs/sha256/.keep").write_text("") + (cache / "index.json").write_text('{"schemaVersion":2,"manifests":[]}') + (cache / "oci-layout").write_text('{"imageLayoutVersion":"1.0.0"}') + (build / "Dockerfile").write_text( + f"FROM {RATIFY_IMAGE}\n" + "ENV RATIFY_CONFIG=/home/nonroot/.ratify\n" + "COPY --chown=0:0 --chmod=0555 admission-no-content-cache /admission-no-content-cache\n" + f"COPY --chown=65532:65532 --chmod=0555 {VERIFIER} admission-other " + "/home/nonroot/.ratify/plugins/\n") + self.baked_repository = "brewlet.local/" + self.f.name + "-ratify" + image = self.baked_repository + ":fixture" + self.command("admission-build-image", ["docker", "build", "--provenance=false", + "--platform", "linux/" + self.f.arch, + "--label", f"{OWNER_LABEL}={self.f.name}", + "-t", image, str(build)], timeout=600) + self.baked_image_id = json.loads(self.f.run( + ["docker", "image", "inspect", image]).stdout)[0]["Id"] + self.f.load_image(image) + self.f.own_container(self.f.node) + node_id = self.f.node_id + rows = self.f.run(["docker", "exec", node_id, "ctr", "-n", "k8s.io", + "images", "ls"]).stdout.splitlines() + self.baked_digest = next(row.split()[2] for row in rows if row.split()[0] == image) + self.baked_image = self.baked_repository + "@" + self.baked_digest + self.f.run(["docker", "exec", node_id, "ctr", "-n", "k8s.io", + "images", "tag", image, self.baked_image]) + self.f.record("admission-verifier-delivery", { + "route": "documented baked-image", "baseImage": RATIFY_IMAGE, + "source": RELEASE_SOURCE, "binarySHA256": hashlib.sha256( + (build / VERIFIER).read_bytes()).hexdigest(), + "image": self.baked_image, + "otherVerifier": "test-only competing verifier; never an attestation substitute", + }) + + def certificates(self): + self.ca = self.f.private / "admission-ca.pem" + ca_key = self.f.private / "admission-ca.key" + tls_key = self.f.private / "admission-tls.key" + csr = self.f.private / "admission-tls.csr" + cert = self.f.private / "admission-tls.pem" + ext = self.f.private / "admission-tls.ext" + ext.write_text("subjectAltName=DNS:ratify.ratify-service," + "DNS:ratify.ratify-service.svc,DNS:localhost,IP:127.0.0.1\n" + "extendedKeyUsage=serverAuth\n") + run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", str(ca_key), "-out", str(self.ca), "-days", "2", + "-subj", "/CN=Brewlet disposable admission fixture"]) + run(["openssl", "req", "-newkey", "rsa:2048", "-nodes", + "-keyout", str(tls_key), "-out", str(csr), + "-subj", "/CN=ratify.ratify-service"]) + run(["openssl", "x509", "-req", "-in", str(csr), "-CA", str(self.ca), + "-CAkey", str(ca_key), "-CAcreateserial", "-out", str(cert), + "-days", "2", "-extfile", str(ext)]) + for key in (ca_key, tls_key): + key.chmod(0o600) + return {"crt": cert.read_text(), "key": tls_key.read_text(), + "caCert": self.ca.read_text(), "caKey": ca_key.read_text(), + "cabundle": base64.b64encode(self.ca.read_bytes()).decode()} + + def install_ratify(self): + archive = self.f.private / "admission-ratify.tar.gz" + checksum = download(f"https://codeload.github.com/notaryproject/ratify/tar.gz/{RATIFY_COMMIT}", + archive) + extracted = self.f.private / "admission-ratify-source" + extracted.mkdir() + with tarfile.open(archive) as tar: + members = [member for member in tar.getmembers() + if "/charts/ratify/" in member.name] + for member in members: + if not (member.isfile() or member.isdir()) or ".." in Path(member.name).parts: + raise RuntimeError("unsafe Ratify archive member") + tar.extract(member, extracted, filter="data") + chart = extracted / ("ratify-" + RATIFY_COMMIT) / "charts/ratify" + values = { + "image": {"repository": self.baked_repository, + "tag": "fixture@" + self.baked_digest, "pullPolicy": "Never"}, + "notation": {"enabled": False}, "cosign": {"enabled": False}, + "upgradeCRDs": {"enabled": False}, + "provider": {"tls": self.certificates(), "cache": {"enabled": False}, + "enableMutation": False, + "timeout": {"validationTimeoutSeconds": 20}}, + "oras": {"useHttp": True, "cache": {"enabled": False}}, + "policy": {"useRego": True}, "logger": {"level": "debug"}, + "resources": {"requests": {"cpu": "100m", "memory": "256Mi"}, + "limits": {"cpu": "1000m", "memory": "768Mi"}}, + } + valuefile = self.f.private / "admission-ratify-values.json" + valuefile.write_text(json.dumps(values)) + valuefile.chmod(0o600) + self.command("admission-ratify-install", [ + "helm", "--kubeconfig", str(self.f.kubeconfig), "--kube-context", + self.f.context, "upgrade", "--install", "ratify", str(chart), + "--namespace", "ratify-service", "--create-namespace", + "--values", str(valuefile), "--timeout", "240s"], timeout=300) + deployment = self.f.get("deployment", "ratify", "-n", "ratify-service") + deployment["spec"]["strategy"] = {"type": "Recreate", "rollingUpdate": None} + podspec = deployment["spec"]["template"]["spec"] + podspec["containers"][0]["image"] = self.baked_image + podspec["hostAliases"] = [ + {"ip": self.registry_ip, "hostnames": [self.f.registry_internal.split(":")[0]]}] + self.f.apply(deployment) + self.f.kube("-n", "ratify-service", "rollout", "status", "deployment/ratify", + "--timeout=240s", timeout=260) + wait("Ratify current Pod and Service endpoints agree", self.ratify_endpoints_ready, + timeout=120, interval=1) + self.f.record("admission-ratify", { + "sourceCommit": RATIFY_COMMIT, "archiveSHA256": checksum, + "chartVersion": "1.15.6", "appVersion": "v1.4.5", + "tls": "unique fixture CA; SAN validated by Gatekeeper and report client", + "providerCache": False, "orasCache": False, + }) + + def policies(self): + store = self.manifest("10-ratify-store.yaml") + store["spec"]["parameters"].update( + cacheEnabled=False, useHttp=True, localCachePath=CONTENT_CACHE) + self.f.apply(store) + verifier = self.manifest("20-ratify-verifier.yaml") + verifier["spec"].pop("source") + # Ratify v1.4.5's live CRD rejects spec.type; spec.name selects the plugin. + # Kept explicit as a candidate correction, never an unmodified-release pass. + verifier["spec"].pop("type") + verifier["spec"]["parameters"] = { + "trustedPublicKey": self.public.read_text(), "expectedBuilderIdentity": BUILDER} + self.f.apply(verifier) + self.f.apply(self.manifest("30-ratify-policy.yaml")) + self.f.apply(self.manifest("40-gatekeeper-constrainttemplate.yaml")) + def constraint_crd_ready(): + result = self.f.kube("get", "crd", + "brewletmanageddependencies.constraints.gatekeeper.sh", + "--ignore-not-found", "-o", "json") + if not result.stdout.strip(): + return False + return any(condition["type"] == "Established" and condition["status"] == "True" + for condition in json.loads(result.stdout).get("status", {}).get("conditions", [])) + wait("Gatekeeper generated constraint CRD established", constraint_crd_ready, + timeout=180, interval=1) + self.f.apply(self.manifest("50-gatekeeper-constraint.yaml")) + self.f.record("admission-shipped-resources", { + "source": RELEASE_SOURCE, + "substitutions": ["fixture public key and builder identity", + "baked plugin (no source.artifact)", + "candidate correction: remove unsupported Verifier.spec.type", + "RATIFY_CONFIG selects baked plugin directory", + "ORAS content cache uses immutable empty OCI layout, forcing remote fetch", + "owned-registry HTTP; discovery/provider caches disabled"], + "enforcement": "deny; unchanged verifier identity and predicate semantics", + }) + + @contextlib.contextmanager + def report_transport(self): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + self.port = listener.getsockname()[1] + logfile = self.f.work / "admission-ratify-port-forward.log" + with logfile.open("w") as output: + process = subprocess.Popen( + ["kubectl", "--kubeconfig", str(self.f.kubeconfig), "--context", + self.f.context, "-n", "ratify-service", "port-forward", + "service/ratify", f"{self.port}:6001", "--address=127.0.0.1"], + stdout=output, stderr=subprocess.STDOUT) + self.f.children.append(process) + try: + def ready(): + if process.poll() is not None: + raise RuntimeError("Ratify port-forward stopped; " + logfile.read_text()) + try: + with socket.create_connection(("127.0.0.1", self.port), timeout=1): + return True + except OSError: + return False + wait("Ratify TLS report transport", ready, timeout=60, interval=1) + yield + finally: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=10) + self.port = None + + def report(self, name, suffix="", allowed=None, other=None): + image = self.image(name) + request = {"apiVersion": "externaldata.gatekeeper.sh/v1beta1", + "kind": "ProviderRequest", "request": {"keys": [image]}} + response = run([ + "curl", "--fail-with-body", "--silent", "--show-error", "--max-time", "30", + "--cacert", str(self.ca), "-H", "Content-Type: application/json", + "--data-binary", "@-", + f"https://127.0.0.1:{self.port}/ratify/gatekeeper/v1/verify"], + input=json.dumps(request), check=False, timeout=40) + if response.returncode: + self.f.save(f"admission-{name}{suffix}-report-error.log", + response.stdout + response.stderr) + raise RuntimeError(f"Ratify TLS report request failed: {response.stderr}") + document = json.loads(response.stdout) + self.f.save(f"admission-{name}{suffix}-report.json", document) + result = extract_result(document, image) + if allowed is None: + allowed = any(self.expected[name].values()) + assert_candidates(result, self.expected[name], allowed, other, + subject_digest=self.subjects[name]["digest"]) + # Ratify 1.4.5's subprocess decoder drops errorReason. Supplement its + # real digest-bound reports with raw stdout from the unchanged plugin. + raw_reports = {} + for reference in self.expected[name]: + if reference in self.reasons: + raw_reports[reference] = {"verifierReports": [self.probe(name, reference, suffix)]} + assert_rejection_reasons(raw_reports, {reference: self.reasons[reference] + for reference in raw_reports}) + return result + + def probe(self, name, reference, suffix): + index = self.registry.referrers(REPOSITORY, self.subjects[name]["digest"]) + descriptor = next(item for item in index["manifests"] if item["digest"] == reference) + request = { + "config": {"name": VERIFIER, "type": VERIFIER, "artifactTypes": ARTIFACT, + "trustedPublicKey": self.public.read_text(), "expectedBuilderIdentity": BUILDER}, + "storeConfig": {"version": "1.0.0", "pluginBinDirs": None, + "store": {"name": "oras", "useHttp": True, "cacheEnabled": False, + "cosignEnabled": False, "localCachePath": CONTENT_CACHE}}, + "referenceDesc": descriptor, + } + response = self.f.kube("-n", "ratify-service", "exec", "-i", "deployment/ratify", + "--", "/home/nonroot/.ratify/plugins/admission-other", + "--probe", self.image(name), input=json.dumps(request)) + raw = json.loads(response.stdout) + self.f.save(f"admission-{name}{suffix}-plugin-{reference.split(':')[1][:12]}.json", raw) + if raw.get("isSuccess") is not False or raw.get("verifierName") != VERIFIER: + raise AssertionError(f"supplemental plugin result disagrees with actual failure: {raw}") + return raw + + def pod(self, name, image=None, namespace=None, brewlet=True): + container = {"name": "application", "image": image or self.image(name), + "securityContext": {"allowPrivilegeEscalation": False, + "capabilities": {"drop": ["ALL"]}}, + "resources": {"requests": {"cpu": "100m", "memory": "128Mi"}, + "limits": {"cpu": "500m", "memory": "256Mi"}}} + spec = {"containers": [container], "restartPolicy": "Never", + "securityContext": {"runAsNonRoot": True, "runAsUser": 10001, + "seccompProfile": {"type": "RuntimeDefault"}}} + if brewlet: + spec["runtimeClassName"] = "brewlet" + return {"apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "admission-" + name, + "namespace": namespace or self.f.namespace}, + "spec": spec} + + def admit(self, name, document, allowed, update=False, persist=False, subresource=None): + args = ["replace" if update else "create", "-f", "-", "-o", "json"] + if not persist: + args.append("--dry-run=server") + if subresource: + args.append("--subresource=" + subresource) + result = self.f.kube(*args, input=json.dumps(document), check=False, timeout=45) + self.f.save("admission-" + name + "-api.log", result.stdout + result.stderr) + evidence = assert_admission(result, allowed) + self.f.record("admission-" + name, evidence) + self.successful.add(name) + return json.loads(result.stdout) if result.returncode == 0 else None + + def assert_enforcement_ready(self): + self.fixture("readiness", []) + def enforcing(): + response = self.f.kube("create", "--dry-run=server", "-f", "-", + input=json.dumps(self.pod("readiness")), check=False) + self.f.save("admission-enforcement-readiness.log", response.stdout + response.stderr) + try: + assert_admission(response, False) + return True + except AssertionError: + return False + wait("Brewlet Gatekeeper deny policy synchronized", enforcing, timeout=180, interval=3) + + def positive(self): + self.discovery("release") + with self.report_transport(): + def registered(): + try: + return self.report("release") + except (AssertionError, urllib.error.URLError, RuntimeError) as error: + self.f.save("admission-verifier-registration-error.log", str(error)) + return False + wait("Brewlet external verifier registered", registered, timeout=180, interval=3) + image = self.image("release") + self.admit("trusted-create", self.pod("release"), True) + self.f.java_application("admission-trusted", image) + wait("JavaApplication generated Deployment", lambda: bool(self.f.kube( + "get", "deployment", "admission-trusted", "-n", self.f.namespace, + "--ignore-not-found", "-o", "name").stdout.strip()), timeout=120, interval=1) + self.f.wait_ready("admission-trusted") + pods = self.f.get("pods", "-n", self.f.namespace) + actual = [pod for pod in pods["items"] + if pod["metadata"]["name"].startswith("admission-trusted-")] + if not actual: + raise AssertionError("JavaApplication produced no pods") + for pod in actual: + if pod["spec"].get("runtimeClassName") != "brewlet": + raise AssertionError("JavaApplication did not select real Brewlet runtime") + if any(container["image"] != image for container in pod["spec"]["containers"]): + raise AssertionError("generated Pod image is not the admitted final digest") + if not any(condition["type"] == "Ready" and condition["status"] == "True" + for condition in pod.get("status", {}).get("conditions", [])): + raise AssertionError("generated Pod is not Ready") + body = self.f.service_get("admission-trusted") + text = body.stdout if hasattr(body, "stdout") else str(body) + if "Hello" not in text: + raise AssertionError("trusted image did not serve the real application: " + text) + self.f.save("admission-trusted-service.log", text) + self.f.record("admission-trusted-runtime", { + "image": image, "readyPods": [pod["metadata"]["name"] for pod in actual], + "served": text, + }) + + def negatives(self): + for kind in ["unsigned", "wrong-key", "wrong-builder", "wrong-subject", + "malformed", "tampered", "incomplete"]: + self.fixture(kind, [] if kind == "unsigned" else [kind]) + with self.report_transport(): + self.report(kind) + self.admit(kind, self.pod(kind), False) + self.fixture("cross-candidate", ["wrong-builder", "incomplete"]) + with self.report_transport(): + self.report("cross-candidate") + self.admit("cross-candidate", self.pod("cross-candidate"), False) + self.fixture("valid-plus-malformed", ["valid", "malformed"]) + with self.report_transport(): + self.report("valid-plus-malformed") + self.admit("valid-plus-malformed", self.pod("valid-plus-malformed"), True) + + def rotation(self): + self.fixture("rotation", ["valid"]) + current = next(iter(self.expected["rotation"])) + with self.report_transport(): + first = self.report("rotation", "-current") + self.admit("rotation-current", self.pod("rotation"), True) + statement = variant_statement(self.statement, self.subjects["rotation"], "obsolete") + obsolete = self.registry.publish(REPOSITORY, self.subjects["rotation"], + signed_envelope(statement, self.obsolete), "obsolete") + self.expected["rotation"][obsolete] = False + self.reasons[obsolete] = REJECTION_REASONS["obsolete"] + self.discovery("rotation") + with self.report_transport(): + second = self.report("rotation", "-both") + self.admit("rotation-both", self.pod("rotation"), True) + self.registry.delete_candidate(REPOSITORY, current) + del self.expected["rotation"][current] + self.discovery("rotation") + with self.report_transport(): + third = self.report("rotation", "-obsolete") + self.admit("rotation-obsolete", self.pod("rotation"), False) + timestamps = [result.get("timestamp") for result in (first, second, third)] + if not all(timestamps) or len(set(timestamps)) != 3: + raise AssertionError("rotation reports do not prove three fresh evaluations") + self.f.record("admission-rotation-evidence", { + "image": self.image("rotation"), "currentCandidate": current, + "obsoleteCandidate": obsolete, "timestamps": timestamps, + "allCachesDisabled": True, + }) + + def competing_verifier(self): + other = {"apiVersion": "config.ratify.deislabs.io/v1beta1", "kind": "Verifier", + "metadata": {"name": "admission-other"}, + "spec": {"name": "admission-other", + "version": "1.0.0", "artifactTypes": ARTIFACT, + "parameters": {"success": True}}} + self.f.apply(other) + self.fixture("other-success", ["wrong-key"]) + # Rollouts guarantee the CRD informer and subprocess configuration changed; + # no cached verification decision is used as a readiness shortcut. + self.restart_ratify() + with self.report_transport(): + self.report("other-success", other=True) + self.admit("other-success-not-substitute", self.pod("other-success"), False) + other["spec"]["parameters"]["success"] = False + self.f.apply(other) + self.fixture("other-failure", ["valid"]) + self.restart_ratify() + with self.report_transport(): + self.report("other-failure", other=False) + self.admit("other-failure-not-veto", self.pod("other-failure"), True) + self.f.kube("delete", "verifier", "admission-other") + self.restart_ratify() + + def restart_ratify(self): + self.f.kube("-n", "ratify-service", "rollout", "restart", "deployment/ratify") + self.f.kube("-n", "ratify-service", "rollout", "status", + "deployment/ratify", "--timeout=180s", timeout=200) + wait("Ratify rollout old Pods removed and Service endpoints synchronized", + self.ratify_endpoints_ready, timeout=120, interval=1) + def admission_ready(): + response = self.f.kube("create", "--dry-run=server", "-f", "-", "-o", "name", + input=json.dumps(self.pod("provider-ready", self.image("release"))), + check=False) + self.f.save("admission-provider-readiness.log", response.stdout + response.stderr) + if response.returncode == 0: + return True + assert_admission(response, False) + return False + wait("Gatekeeper reaches the restarted Ratify Service", admission_ready, + timeout=120, interval=2) + + def ratify_endpoints_ready(self): + pods = self.f.get("pods", "-n", "ratify-service", + "-l", "app.kubernetes.io/name=ratify")["items"] + if len(pods) != 1 or pods[0]["metadata"].get("deletionTimestamp"): + return False + if not any(condition["type"] == "Ready" and condition["status"] == "True" + for condition in pods[0].get("status", {}).get("conditions", [])): + return False + addresses = {address["ip"] + for subset in self.f.get("endpoints", "ratify", "-n", "ratify-service").get("subsets", []) + for address in subset.get("addresses", [])} + return addresses == {pods[0]["status"]["podIP"]} + + def api_paths(self): + self.fixture("api-paths", ["valid"]) + self.fixture("api-paths-unsigned", []) + valid, invalid = self.image("api-paths"), self.image("api-paths-unsigned") + for name, image, allowed in [("init-valid", valid, True), ("init-invalid", invalid, False)]: + pod = self.pod("api-paths") + pod["spec"]["initContainers"] = [{"name": "initialize", "image": image}] + self.admit(name, pod, allowed) + # A scheduling gate makes the persisted UPDATE subject a valid, inert Pod. + # No ordinary-image init/ephemeral execution is asserted. + pod = self.pod("updates", image=valid) + pod["spec"]["schedulingGates"] = [{"name": "live.brewlet.invalid/admission-only"}] + pod["spec"]["initContainers"] = [{"name": "initialize", "image": valid}] + self.admit("update-base-create", pod, True, persist=True) + original = self.f.get("pod", "admission-updates", "-n", self.f.namespace) + for label, container in [("regular", "containers"), ("init", "initContainers")]: + change = copy.deepcopy(original) + change["spec"][container][0]["image"] = invalid + self.admit(label + "-update-invalid", change, False, update=True) + change = copy.deepcopy(original) + change["spec"][container][0]["image"] = self.image("release") + self.admit(label + "-update-valid", change, True, update=True) + for label, image, allowed in [("ephemeral-valid", valid, True), + ("ephemeral-invalid", invalid, False)]: + change = copy.deepcopy(original) + change["spec"]["ephemeralContainers"] = [ + {"name": "inspect", "image": image, "targetContainerName": "application"}] + self.admit(label, change, allowed, update=True, subresource="ephemeralcontainers") + self.admit("non-brewlet-create", self.pod("non-brewlet", invalid, brewlet=False), True) + ordinary = self.pod("ordinary-update", invalid, brewlet=False) + ordinary["spec"]["schedulingGates"] = [{"name": "live.brewlet.invalid/admission-only"}] + self.admit("non-brewlet-base", ordinary, True, persist=True) + ordinary = self.f.get("pod", "admission-ordinary-update", "-n", self.f.namespace) + ordinary["metadata"].setdefault("labels", {})["live.brewlet.invalid/updated"] = "yes" + self.admit("non-brewlet-update", ordinary, True, update=True) + for namespace in ["kube-system", "gatekeeper-system", "ratify-service"]: + self.admit("excluded-" + namespace, + self.pod("excluded", invalid, namespace=namespace), True) + # The Brewlet component namespace is deliberately not an exclusion. + self.admit("brewlet-namespace-not-excluded", + self.pod("not-excluded", invalid, namespace="brewlet"), False) + + def fetch_failure(self): + self.fixture("fetch-failure", ["valid"]) + candidate = next(iter(self.expected["fetch-failure"])) + _, manifest = self.registry.manifest(REPOSITORY, candidate) + blob = manifest["layers"][0]["digest"] + # Zot intentionally rejects blob DELETE. Remove only this invocation's + # exact, hash-checked evidence file to inject a real registry fetch fault. + self.f.own_container(self.f.registry_id) + path = self.registry_storage / REPOSITORY / "blobs/sha256" / blob.split(":")[1] + if digest(path.read_bytes()) != blob: + raise AssertionError("refusing to remove a nonmatching fixture evidence blob") + path.unlink() + try: + self.registry.blob(REPOSITORY, blob) + except urllib.error.HTTPError as error: + if error.code not in (404, 500): + raise + status = error.code + else: + raise AssertionError("fault injection did not produce a real HTTP blob fetch failure") + self.f.save("admission-fetch-fault.json", + {"candidate": candidate, "removedEvidenceBlob": blob, "httpStatus": status}) + self.discovery("fetch-failure") + self.expected["fetch-failure"][candidate] = False + self.reasons[candidate] = "fetch DSSE envelope blob" + with self.report_transport(): + result = self.report("fetch-failure") + self.admit("registry-fetch-failure", self.pod("fetch-failure"), False) + + def authentication_failure(self): + self.fixture("authentication", ["valid"]) + self.discovery("authentication") + # A real registry htpasswd challenge, not a mock provider response. + # Changing only the invocation-owned registry config preserves its data. + self.f.own_container(self.f.registry_id) + original = self.registry_config.read_text() + password = base64.urlsafe_b64encode(os.urandom(24)).decode() + entry = run(["htpasswd", "-niB", "live-fixture"], input=password + "\n").stdout + self.registry_credentials.write_text(entry) + protected = json.loads(original) + protected["http"]["auth"] = {"htpasswd": {"path": "/etc/zot/htpasswd"}, "failDelay": 0} + try: + self.registry_config.write_text(json.dumps(protected)) + self.f.run(["docker", "restart", self.f.registry_id]) + def challenges(): + try: + self.registry.request("GET", "/v2/") + return False + except urllib.error.HTTPError as error: + return error.code == 401 and "Basic" in error.headers.get("WWW-Authenticate", "") + except OSError: + return False + wait("owned registry requires real authentication", challenges, timeout=60, interval=1) + self.admit("registry-authentication-failure", self.pod("authentication"), False) + self.f.record("admission-authentication-challenge", + {"status": 401, "scheme": "Basic", "subject": self.image("authentication"), + "previouslyVerified": False}) + finally: + self.f.own_container(self.f.registry_id) + self.registry_config.write_text(original) + self.f.run(["docker", "restart", self.f.registry_id]) + def restored(): + try: + return self.registry.request("GET", "/v2/") + except OSError: + return False + wait("owned registry restored", restored, timeout=60, interval=1) + + def unavailable_provider(self): + self.fixture("unavailable", ["valid"]) + self.f.kube("-n", "ratify-service", "scale", "deployment/ratify", "--replicas=0") + def no_endpoints(): + endpoints = self.f.get("endpoints", "ratify", "-n", "ratify-service") + return not any(subset.get("addresses") for subset in endpoints.get("subsets", [])) + try: + wait("Ratify has no serving endpoints", no_endpoints, timeout=90, interval=2) + self.admit("ratify-unavailable", self.pod("unavailable"), False) + self.f.record("admission-provider-outage", { + "subject": self.image("unavailable"), "previouslyVerified": False, + "endpoints": self.f.get("endpoints", "ratify", "-n", "ratify-service")}) + finally: + self.f.kube("-n", "ratify-service", "scale", "deployment/ratify", "--replicas=1") + self.f.kube("-n", "ratify-service", "rollout", "status", "deployment/ratify", + "--timeout=180s", timeout=200) + + def diagnostics(self): + errors = [] + commands = { + "ratify": ["-n", "ratify-service", "logs", "deployment/ratify", + "--all-containers", "--tail=1500"], + "gatekeeper": ["-n", "gatekeeper-system", "logs", + "deployment/gatekeeper-controller-manager", + "--all-containers", "--tail=1000"], + "constraint": ["get", "brewletmanageddependencies", "-o", "yaml"], + "template": ["get", "constrainttemplate", "brewletmanageddependencies", "-o", "yaml"], + "events": ["get", "events", "-A", "-o", "json"], + "workloads": ["get", "pods,deployments,javaapplications", "-A", "-o", "json"], + "provider": ["get", "providers", "-o", "json"], + } + for name, argv in commands.items(): + try: + result = self.f.kube(*argv, check=False, timeout=30) + self.f.save("admission-diag-" + name + ".log", result.stdout + result.stderr) + if result.returncode: + errors.append({"surface": name, "returncode": result.returncode, + "error": result.stderr or result.stdout}) + except Exception as error: + errors.append({"surface": name, "error": str(error)}) + try: + self.f.own_container(self.f.registry_id) + result = self.f.run(["docker", "logs", "--tail", "1500", self.f.registry_id], + check=False, timeout=30) + self.f.save("admission-diag-registry.log", result.stdout + result.stderr) + if result.returncode: + errors.append({"surface": "registry", "returncode": result.returncode, + "error": result.stderr or result.stdout}) + except Exception as error: + errors.append({"surface": "registry", "error": str(error)}) + try: + self.f.save("admission-diagnostics-result.json", + {"complete": not errors, "errors": errors}) + except Exception as error: + errors.append({"surface": "diagnostic-result", "error": str(error)}) + if errors: + print(redact("Admission diagnostic capture failures: " + json.dumps(errors)), + file=sys.stderr, flush=True) + return errors + + def execute(self): + try: + self.setup_registry_route() + self.keys() + self.publish() + self.build_ratify() + self.install_gatekeeper() + self.install_ratify() + self.policies() + self.assert_enforcement_ready() + self.positive() + self.negatives() + self.rotation() + self.competing_verifier() + self.api_paths() + self.fetch_failure() + self.authentication_failure() + self.unavailable_provider() + self.f.record("admission-scenario-complete", { + "mandatoryAssertions": sorted(self.successful), + "ordinaryEphemeralExecutionClaimed": False, + "release": "0.5.0", "candidateProductChanges": [ + "remove unsupported Verifier.spec.type"] + ( + [] if self.f.candidate == "release" else [self.f.candidate]), + }) + finally: + primary_failure = sys.exc_info()[0] is not None + errors = self.diagnostics() + try: + if getattr(self, "baked_image_id", None): + image = json.loads(self.f.run( + ["docker", "image", "inspect", self.baked_image_id]).stdout)[0] + if image["Config"].get("Labels", {}).get(OWNER_LABEL) != self.f.name: + raise RuntimeError("refusing to remove a foreign baked Ratify image") + self.f.run(["docker", "image", "rm", self.baked_image_id]) + except Exception as error: + errors.append({"surface": "baked-image-cleanup", "error": str(error)}) + print(redact("Admission baked-image cleanup failed: " + str(error)), + file=sys.stderr, flush=True) + if errors and not primary_failure: + raise RuntimeError(redact("Required admission diagnostics/cleanup failed: " + json.dumps(errors))) + + +def main(): + for name in ("htpasswd", "go"): + if not shutil.which(name): + raise SystemExit(f"Scenario A prerequisite missing: {name}; htpasswd comes from Apache utilities") + with Fixture("admission") as fixture: + Admission(fixture).execute() + + +if __name__ == "__main__": + main() diff --git a/integration-tests/e2e/live/admission_helpers.py b/integration-tests/e2e/live/admission_helpers.py new file mode 100644 index 0000000..f1b62ef --- /dev/null +++ b/integration-tests/e2e/live/admission_helpers.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Strict evidence checks and native OCI/DSSE fixtures for live admission.""" + +import base64 +import copy +import hashlib +import json +import re +import subprocess +import urllib.error +import urllib.parse +import urllib.request +import uuid + + +ARTIFACT = "application/vnd.brewlet.attestation.v1+json" +DSSE = "application/vnd.dsse.envelope.v1+json" +MANIFEST = "application/vnd.oci.image.manifest.v1+json" +INDEX = "application/vnd.oci.image.index.v1+json" +VERIFIER = "brewlet-managed-dependencies" +BUILDER = "https://live.brewlet.invalid/application-builder" + + +def encoded(value): + return json.dumps(value, separators=(",", ":"), sort_keys=True).encode() + + +def digest(data): + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def assert_admission(result, allowed): + """A malformed API request or an unrelated webhook error is never a pass.""" + text = result.stdout + result.stderr + denied = (result.returncode != 0 + and 'admission webhook "validation.gatekeeper.sh" denied the request' in text + and "[brewlet-managed-dependencies]" in text + and "Brewlet admission:" in text) + if allowed: + if result.returncode: + raise AssertionError(f"expected admission, got API failure:\n{text}") + elif not denied: + raise AssertionError(f"expected identifiable Brewlet Gatekeeper denial:\n{text}") + return {"allowed": allowed, "returncode": result.returncode, "response": text} + + +def extract_result(response, image): + if response.get("response", {}).get("systemError"): + raise AssertionError(f"Ratify provider error: {response}") + items = response.get("response", {}).get("items", []) + matching = [item for item in items if item.get("key") == image] + if len(matching) != 1 or matching[0].get("error"): + raise AssertionError(f"expected one real report for {image}: {response}") + result = matching[0].get("value") + if not isinstance(result, dict) or not isinstance(result.get("isSuccess"), bool): + raise AssertionError(f"missing explicit Ratify decision: {response}") + return result + + +def assert_candidates(result, expected, allowed, other=None, subject_digest=None): + """Match every candidate digest to its own named-verifier result.""" + if result.get("isSuccess") is not allowed: + raise AssertionError(f"wrong Ratify decision: {result}") + reports = {} + for report in result.get("verifierReports", []): + if report.get("artifactType") != ARTIFACT: + continue + reference = report.get("referenceDigest") + if reference in reports: + raise AssertionError(f"duplicate candidate report: {reference}") + reports[reference] = report + if set(reports) != set(expected): + raise AssertionError( + f"candidate discovery/report mismatch: expected {expected}, got {reports}") + for reference, success in expected.items(): + candidates = [vr for vr in reports[reference].get("verifierReports", []) + if vr.get("verifierName") == VERIFIER] + if len(candidates) != 1 or candidates[0].get("isSuccess", False) is not success: + raise AssertionError(f"wrong Brewlet candidate result: {reports[reference]}") + if success: + bound = candidates[0].get("extensions", {}).get("finalImageDigest") + if not bound or (subject_digest is not None and bound != subject_digest): + raise AssertionError(f"success lacks bound predicate: {candidates[0]}") + elif not candidates[0].get("message"): + raise AssertionError(f"failure lacks verifier message: {candidates[0]}") + if other is not None: + unrelated = [vr for vr in reports[reference].get("verifierReports", []) + if vr.get("verifierName") == "admission-other"] + if len(unrelated) != 1 or unrelated[0].get("isSuccess", False) is not other: + raise AssertionError(f"other verifier not actually run: {reports[reference]}") + return reports + + +def assert_discoverable(index, expected): + candidates = [entry["digest"] for entry in index.get("manifests", []) + if entry.get("artifactType") == ARTIFACT] + if len(candidates) != len(set(candidates)) or set(candidates) != set(expected): + raise AssertionError(f"native OCI referrers mismatch: {candidates} != {expected}") + + +def assert_rejection_reasons(reports, reasons): + for reference, expected in reasons.items(): + report = reports[reference] + actual = [item.get("errorReason", "") for item in report["verifierReports"] + if item.get("verifierName") == VERIFIER] + if len(actual) != 1 or expected not in actual[0]: + raise AssertionError( + f"candidate {reference} failed for the wrong reason: {actual}; expected {expected}") + + +class Registry: + """HTTP is restricted to the invocation-owned loopback registry.""" + + def __init__(self, fixture): + if not re.fullmatch(r"localhost:\d+", fixture.registry): + raise ValueError("fixture registry must be a loopback endpoint") + self.fixture = fixture + self.base = "http://" + fixture.registry + + def request(self, method, path, body=None, content_type=None): + url = urllib.parse.urljoin(self.base, path) + if urllib.parse.urlsplit(url).netloc != self.fixture.registry: + raise ValueError(f"registry redirect escaped fixture: {url}") + headers = {"Accept": ", ".join([INDEX, MANIFEST, + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.docker.distribution.manifest.v2+json"])} + if content_type: + headers["Content-Type"] = content_type + with urllib.request.urlopen(urllib.request.Request( + url, data=body, headers=headers, method=method), timeout=30) as response: + return response.read(), response.headers + + def manifest(self, repository, reference): + raw, _ = self.request("GET", f"/v2/{repository}/manifests/{reference}") + return raw, json.loads(raw) + + def blob(self, repository, reference): + return self.request("GET", f"/v2/{repository}/blobs/{reference}")[0] + + def put_blob(self, repository, raw): + reference = digest(raw) + _, headers = self.request("POST", f"/v2/{repository}/blobs/uploads/", b"") + location = headers["Location"] + parts = urllib.parse.urlsplit(location) + # Distribution uses its container-facing name in some Location headers. + # Only the path/query are used; never follow a registry-supplied host. + path = urllib.parse.urlunsplit(("", "", parts.path, parts.query, "")) + path += ("&" if parts.query else "?") + urllib.parse.urlencode({"digest": reference}) + self.request("PUT", path, raw, "application/octet-stream") + return reference + + def put_manifest(self, repository, tag, document): + raw = encoded(document) + self.request("PUT", f"/v2/{repository}/manifests/{tag}", raw, + document.get("mediaType", MANIFEST)) + return {"mediaType": document.get("mediaType", MANIFEST), + "digest": digest(raw), "size": len(raw)} + + def referrers(self, repository, subject): + try: + raw, _ = self.request("GET", f"/v2/{repository}/referrers/{subject}") + except urllib.error.HTTPError as error: + if error.code == 404: + raise RuntimeError( + "Scenario A requires the native OCI 1.1 Referrers API; registry " + f"/v2/{repository}/referrers/{subject} returned 404. Distribution " + "3.0.0 does not provide this API. Brewlet fallback tags cannot " + "substitute for native Ratify discovery.") from error + raise + return json.loads(raw) + + def clone_subject(self, repository, reference, label): + """Keep runnable bytes intact, give every failure a new image digest.""" + _, manifest = self.manifest(repository, reference) + manifest.setdefault("annotations", {})["live.brewlet.invalid/case"] = ( + label + "-" + uuid.uuid4().hex) + return self.put_manifest(repository, label, manifest) + + def publish(self, repository, subject, envelope, label): + blob = self.put_blob(repository, envelope) + config = b"{}" + config_digest = self.put_blob(repository, config) + return self.put_manifest(repository, "evidence-" + label, { + "schemaVersion": 2, "mediaType": MANIFEST, "artifactType": ARTIFACT, + "subject": subject, + "config": {"mediaType": "application/vnd.oci.empty.v1+json", + "digest": config_digest, "size": len(config)}, + "layers": [{"mediaType": DSSE, "digest": blob, "size": len(envelope)}], + })["digest"] + + def delete_candidate(self, repository, reference): + self.request("DELETE", f"/v2/{repository}/manifests/{reference}") + + +def signed_envelope(statement, private_key): + payload_type = b"application/vnd.in-toto+json" + payload = encoded(statement) + pae = (b"DSSEv1 " + str(len(payload_type)).encode() + b" " + payload_type + + b" " + str(len(payload)).encode() + b" " + payload) + signature = subprocess.run( + ["openssl", "dgst", "-sha256", "-sign", str(private_key)], + input=pae, capture_output=True, check=True, timeout=30).stdout + public = subprocess.run( + ["openssl", "pkey", "-in", str(private_key), "-pubout", "-outform", "DER"], + capture_output=True, check=True, timeout=30).stdout + return encoded({"payloadType": payload_type.decode(), + "payload": base64.b64encode(payload).decode(), + "signatures": [{"keyid": digest(public), + "sig": base64.b64encode(signature).decode()}]}) + + +def variant_statement(original, subject, kind): + statement = copy.deepcopy(original) + statement["subject"][0]["digest"]["sha256"] = subject["digest"].split(":")[1] + statement["predicate"]["finalImageDigest"] = subject["digest"] + if kind == "wrong-builder": + statement["predicate"]["builderIdentity"] = BUILDER + "/untrusted" + elif kind == "wrong-subject": + statement["subject"][0]["digest"]["sha256"] = "0" * 64 + statement["predicate"]["finalImageDigest"] = "sha256:" + "0" * 64 + elif kind == "incomplete": + del statement["predicate"]["applicationJarDigest"] + return statement diff --git a/integration-tests/e2e/live/admission_other.go b/integration-tests/e2e/live/admission_other.go new file mode 100644 index 0000000..3382f01 --- /dev/null +++ b/integration-tests/e2e/live/admission_other.go @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This deliberately untrusted competing verifier is used only to prove that its +// success cannot replace Brewlet verification and its failure cannot veto it. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + + "github.com/ratify-project/ratify/pkg/common" + "github.com/ratify-project/ratify/pkg/ocispecs" + "github.com/ratify-project/ratify/pkg/referrerstore" + _ "github.com/ratify-project/ratify/pkg/referrerstore/oras" + "github.com/ratify-project/ratify/pkg/verifier" + "github.com/ratify-project/ratify/pkg/verifier/plugin/skel" +) + +func verify(args *skel.CmdArgs, subject common.Reference, reference ocispecs.ReferenceDescriptor, store referrerstore.ReferrerStore) (*verifier.VerifierResult, error) { + var input struct { + Config struct { + Success bool `json:"success"` + } `json:"config"` + } + if err := json.Unmarshal(args.StdinData, &input); err != nil { + return nil, err + } + // Fetch the real candidate rather than manufacture an executor report. + if _, err := store.GetReferenceManifest(context.Background(), subject, reference); err != nil { + return nil, err + } + return &verifier.VerifierResult{ + IsSuccess: input.Config.Success, Name: "admission-other", + VerifierName: "admission-other", Type: "admission-other", + VerifierType: "admission-other", Message: "live competing verifier", + }, nil +} + +func main() { + if len(os.Args) == 3 && os.Args[1] == "--probe" { + // Ratify 1.4.5 drops errorReason in GetVerifierResult. Preserve the real + // plugin's unmodified stdout in a supplementary subprocess diagnostic. + command := exec.Command("/home/nonroot/.ratify/plugins/brewlet-managed-dependencies") + command.Env = append(os.Environ(), "RATIFY_VERIFIER_COMMAND=VERIFY", + "RATIFY_VERIFIER_VERSION=1.0.0", "RATIFY_VERIFIER_SUBJECT="+os.Args[2]) + command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := command.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + skel.PluginMain("admission-other", "1.0.0", verify, []string{"1.0.0"}) +} diff --git a/integration-tests/e2e/live/admission_registry.py b/integration-tests/e2e/live/admission_registry.py new file mode 100644 index 0000000..1aba7c2 --- /dev/null +++ b/integration-tests/e2e/live/admission_registry.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Invocation-owned native OCI 1.1 registry for admission (not fallback tags).""" + +import json +import os + +from common import OWNER_LABEL, wait + + +ZOT_IMAGE = ("ghcr.io/project-zot/zot:v2.1.8@sha256:" + "cd2aea942f428630bcb4190542be6abd35e14177aab84fc7ccad0dca8ecb363d") + + +def native_registry(fixture): + """Replace only the empty, identity-checked fixture registry before publishing.""" + old = fixture.own_container(fixture.registry_name) + config = fixture.private / "admission-zot.json" + credentials = fixture.private / "admission-htpasswd" + storage = fixture.private / "admission-zot-data" + storage.mkdir(mode=0o700) + credentials.write_text("") + credentials.chmod(0o600) + document = { + "distSpecVersion": "1.1.1", + "storage": {"rootDirectory": "/var/lib/zot", "gc": False, "dedupe": False}, + "http": {"address": "0.0.0.0", "port": "5000", "realm": "brewlet-live-fixture"}, + "log": {"level": "debug"}, + } + config.write_text(json.dumps(document)) + config.chmod(0o600) + port = fixture.registry.split(":")[1] + fixture.run(["docker", "rm", "-f", "--volumes", old["Id"]]) + fixture.registry_id = None + try: + fixture.run([ + "docker", "create", "--platform", "linux/" + fixture.arch, + "--user", f"{os.getuid()}:{os.getgid()}", + "--name", fixture.registry_name, "--label", f"{OWNER_LABEL}={fixture.name}", + "--network", fixture.name, "--cpus", "0.5", "--memory", "256m", + "-p", f"127.0.0.1:{port}:5000", + "--mount", f"type=bind,src={config},dst=/etc/zot/config.json,readonly", + "--mount", f"type=bind,src={credentials},dst=/etc/zot/htpasswd,readonly", + "--mount", f"type=bind,src={storage},dst=/var/lib/zot", + ZOT_IMAGE, "serve", "/etc/zot/config.json"], timeout=300) + finally: + result = fixture.run(["docker", "inspect", fixture.registry_name], check=False) + if result.returncode == 0: + info = json.loads(result.stdout)[0] + if info["Config"].get("Labels", {}).get(OWNER_LABEL) != fixture.name: + raise RuntimeError("Refusing to adopt a foreign native registry") + fixture.registry_id = info["Id"] + fixture.run(["docker", "start", fixture.registry_id]) + info = fixture.own_container(fixture.registry_name) + fixture.registry_ip = info["NetworkSettings"]["Networks"][fixture.name]["IPAddress"] + wait("native-referrers registry /v2/", lambda: fixture.run( + ["curl", "-fsS", "--max-time", "3", f"http://{fixture.registry}/v2/"], + check=False).returncode == 0, timeout=60, interval=1) + fixture.save("registry-identity.json", { + "id": fixture.registry_id, "name": fixture.registry_name, "mounts": info["Mounts"], + "image": ZOT_IMAGE, "replacedEmptyDistribution": old["Id"], + }) + fixture.record("admission-native-registry", { + "image": ZOT_IMAGE, "nativeOCIReferrers": True, + "containerUser": f"{os.getuid()}:{os.getgid()}", + "reason": "Distribution 3.0.0 returns 404 for native referrers; no fallback substitution", + "host": fixture.registry, "containerHost": fixture.registry_internal, + "plainHTTP": "only this invocation-owned registry", "garbageCollection": False, + }) + return config, credentials, storage diff --git a/integration-tests/e2e/live/test_admission.py b/integration-tests/e2e/live/test_admission.py new file mode 100644 index 0000000..db82623 --- /dev/null +++ b/integration-tests/e2e/live/test_admission.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""CI-discoverable offline safeguards; not a replacement for the live scenario.""" + +import copy +import subprocess +import unittest +from unittest.mock import Mock, patch +from types import SimpleNamespace +from urllib.error import HTTPError + +from admission_helpers import ( + ARTIFACT, BUILDER, VERIFIER, assert_admission, assert_candidates, + assert_discoverable, assert_rejection_reasons, extract_result, variant_statement, Registry, +) + + +CURRENT = "sha256:" + "1" * 64 +OBSOLETE = "sha256:" + "2" * 64 +IMAGE = "fixture.invalid/app@" + "sha256:" + "3" * 64 + + +def completed(code, message): + return subprocess.CompletedProcess(["kubectl"], code, "", message) + + +def report(candidates, allowed): + return { + "isSuccess": allowed, + "verifierReports": [ + {"artifactType": ARTIFACT, "referenceDigest": reference, + "verifierReports": [ + {"verifierName": VERIFIER, "isSuccess": success, + "message": "verified" if success else "verification failed", + "extensions": {"finalImageDigest": IMAGE.split("@")[1]} if success else {}, + "errorReason": "" if success else "invalid signature"}]} + for reference, success in candidates.items()], + } + + +class AdmissionClassificationTests(unittest.TestCase): + def test_node_mutations_use_verified_id_and_isolated_runner(self): + from admission import Admission + fixture = SimpleNamespace( + registry="localhost:5000", node="owned-node-name", node_id="captured-node-id", + registry_id="captured-registry-id", registry_internal="owned-registry:5000", + registry_ip="172.18.0.2", record=Mock()) + verified = [] + fixture.own_container = Mock(side_effect=lambda value: verified.append(value)) + + def mutate(argv, **_kwargs): + self.assertIn(fixture.node, verified) + self.assertIn("captured-node-id", argv) + self.assertNotIn("owned-node-name", argv) + fixture.run = Mock(side_effect=mutate) + with patch("admission.native_registry", return_value=("config", "credentials", "storage")): + Admission(fixture).setup_registry_route() + self.assertEqual(fixture.run.call_count, 2) + + def test_excluded_namespace_requests_satisfy_restricted_pod_security(self): + from admission import Admission + fixture = SimpleNamespace(registry="localhost:5000", namespace="live-e2e") + pod = Admission(fixture).pod("excluded", IMAGE, namespace="gatekeeper-system") + self.assertTrue(pod["spec"]["securityContext"]["runAsNonRoot"]) + self.assertEqual(pod["spec"]["securityContext"]["seccompProfile"]["type"], "RuntimeDefault") + security = pod["spec"]["containers"][0]["securityContext"] + self.assertFalse(security["allowPrivilegeEscalation"]) + self.assertEqual(security["capabilities"]["drop"], ["ALL"]) + + def test_explicit_success(self): + self.assertTrue(assert_admission(completed(0, "pod created (server dry run)"), True)["allowed"]) + + def test_only_the_named_brewlet_policy_denial_counts(self): + denial = ('Error from server (Forbidden): admission webhook ' + '"validation.gatekeeper.sh" denied the request: ' + '[brewlet-managed-dependencies] Brewlet admission: image has no successful response') + self.assertFalse(assert_admission(completed(1, denial), False)["allowed"]) + for message in ( + "pod is invalid: Forbidden", + 'admission webhook "validation.gatekeeper.sh" denied the request: [another-policy] denial', + 'failed calling webhook "validation.gatekeeper.sh": connection refused', + 'admission webhook "brewlet.sh" denied the request', + "FailedScheduling: runtimeclass not found", + denial.replace("Brewlet admission:", "unrelated error:"), + ): + with self.subTest(message=message), self.assertRaises(AssertionError): + assert_admission(completed(1, message), False) + with self.assertRaises(AssertionError): + assert_admission(completed(0, denial), False) + + def test_denial_never_counts_as_admission(self): + with self.assertRaises(AssertionError): + assert_admission(completed(1, "API failure"), True) + + +class CandidateEvidenceTests(unittest.TestCase): + def test_rotation_requires_each_actual_candidate(self): + assert_candidates(report({CURRENT: True}, True), {CURRENT: True}, True) + assert_candidates(report({CURRENT: True, OBSOLETE: False}, True), + {CURRENT: True, OBSOLETE: False}, True) + assert_candidates(report({OBSOLETE: False}, False), {OBSOLETE: False}, False) + + def test_cached_current_only_cannot_prove_rotation(self): + with self.assertRaises(AssertionError): + assert_candidates(report({CURRENT: True}, True), + {CURRENT: True, OBSOLETE: False}, True) + + def test_success_cannot_hide_wrong_or_missing_candidate(self): + for candidates in ({}, {OBSOLETE: True}, {CURRENT: False}): + with self.subTest(candidates=candidates), self.assertRaises(AssertionError): + assert_candidates(report(candidates, True), {CURRENT: True}, True) + + def test_foreign_verifier_cannot_substitute(self): + result = report({CURRENT: True}, True) + result["verifierReports"][0]["verifierReports"][0]["verifierName"] = "admission-other" + with self.assertRaises(AssertionError): + assert_candidates(result, {CURRENT: True}, True) + + def test_other_verifier_must_really_execute(self): + result = report({CURRENT: False}, False) + with self.assertRaises(AssertionError): + assert_candidates(result, {CURRENT: False}, False, other=True) + result["verifierReports"][0]["verifierReports"].append( + {"verifierName": "admission-other", "isSuccess": True}) + assert_candidates(result, {CURRENT: False}, False, other=True) + + def test_ambiguous_duplicate_report_rejected(self): + result = report({CURRENT: True}, True) + result["verifierReports"].append(copy.deepcopy(result["verifierReports"][0])) + with self.assertRaises(AssertionError): + assert_candidates(result, {CURRENT: True}, True) + + def test_decision_must_be_explicit_boolean(self): + for decision in (None, 1, "true", False): + result = report({CURRENT: True}, decision) + with self.subTest(decision=decision), self.assertRaises(AssertionError): + assert_candidates(result, {CURRENT: True}, True) + + def test_missing_evidence_requires_empty_reports(self): + assert_candidates(report({}, False), {}, False) + with self.assertRaises(AssertionError): + assert_candidates(report({CURRENT: False}, False), {}, False) + + def test_failure_must_have_real_message(self): + result = report({CURRENT: False}, False) + del result["verifierReports"][0]["verifierReports"][0]["message"] + with self.assertRaises(AssertionError): + assert_candidates(result, {CURRENT: False}, False) + + def test_success_must_show_bound_digest(self): + result = report({CURRENT: True}, True) + result["verifierReports"][0]["verifierReports"][0]["extensions"] = {} + with self.assertRaises(AssertionError): + assert_candidates(result, {CURRENT: True}, True) + + def test_success_cannot_bind_a_different_image(self): + result = report({CURRENT: True}, True) + with self.assertRaises(AssertionError): + assert_candidates(result, {CURRENT: True}, True, subject_digest=CURRENT) + assert_candidates(result, {CURRENT: True}, True, subject_digest=IMAGE.split("@")[1]) + + def test_fetch_failure_cannot_masquerade_as_signature_rejection(self): + result = report({CURRENT: False}, False) + reports = assert_candidates(result, {CURRENT: False}, False) + assert_rejection_reasons(reports, {CURRENT: "invalid signature"}) + reports[CURRENT]["verifierReports"][0]["errorReason"] = "fetch DSSE envelope blob: 404" + with self.assertRaises(AssertionError): + assert_rejection_reasons(reports, {CURRENT: "invalid signature"}) + + +class ProviderResponseTests(unittest.TestCase): + def document(self): + return {"response": {"items": [{"key": IMAGE, "value": report({CURRENT: True}, True)}]}} + + def test_exact_image_response(self): + self.assertTrue(extract_result(self.document(), IMAGE)["isSuccess"]) + + def test_missing_partial_mismatched_or_failed_response_rejected(self): + for response in [ + {}, + {"response": {"items": []}}, + {"response": {"systemError": "connection refused"}}, + {"response": {"items": [{"key": "different", "value": {"isSuccess": True}}]}}, + {"response": {"items": [{"key": IMAGE, "error": "unauthorized"}]}}, + {"response": {"items": [{"key": IMAGE, "value": {}}]}}, + {"response": {"items": [{"key": IMAGE, "value": {"isSuccess": "true"}}]}}, + ]: + with self.subTest(response=response), self.assertRaises(AssertionError): + extract_result(response, IMAGE) + + def test_duplicate_key_response_rejected(self): + response = self.document() + response["response"]["items"] *= 2 + with self.assertRaises(AssertionError): + extract_result(response, IMAGE) + + +class RegistryDiscoveryTests(unittest.TestCase): + def test_registry_without_native_api_is_actionable_failure(self): + registry = Registry(SimpleNamespace(registry="localhost:5000")) + with patch.object(registry, "request", side_effect=HTTPError( + "http://localhost:5000", 404, "Not Found", {}, None)): + with self.assertRaisesRegex(RuntimeError, "requires the native OCI 1.1 Referrers API"): + registry.referrers("fixture", CURRENT) + + def test_native_negative_candidates_must_remain_visible(self): + index = {"manifests": [{"digest": CURRENT, "artifactType": ARTIFACT}]} + assert_discoverable(index, {CURRENT: False}) + for document in ({}, {"manifests": []}, + {"manifests": [{"digest": CURRENT, "artifactType": "other"}]}): + with self.subTest(document=document), self.assertRaises(AssertionError): + assert_discoverable(document, {CURRENT: False}) + + def test_discovery_alone_cannot_prove_wrong_candidate(self): + index = {"manifests": [{"digest": OBSOLETE, "artifactType": ARTIFACT}]} + with self.assertRaises(AssertionError): + assert_discoverable(index, {CURRENT: False}) + + def test_duplicate_candidates_rejected(self): + index = {"manifests": [{"digest": CURRENT, "artifactType": ARTIFACT}] * 2} + with self.assertRaises(AssertionError): + assert_discoverable(index, {CURRENT: False}) + + +class DiagnosticCaptureTests(unittest.TestCase): + def fixture(self): + return SimpleNamespace( + registry="localhost:5000", registry_id="owned-registry", candidate="release", + record=Mock(), save=Mock(), own_container=Mock(), + run=Mock(return_value=completed(0, "")), + kube=Mock(return_value=completed(0, ""))) + + def test_capture_continues_and_reports_every_failure(self): + from admission import Admission + fixture = self.fixture() + fixture.kube.side_effect = [ + RuntimeError("Ratify logs unreachable"), completed(3, "Gatekeeper logs unavailable"), + *[completed(0, "") for _ in range(5)]] + fixture.own_container.side_effect = RuntimeError("registry identity changed") + with patch("sys.stderr"): + errors = Admission(fixture).diagnostics() + self.assertEqual(fixture.kube.call_count, 7) + self.assertEqual({error["surface"] for error in errors}, {"ratify", "gatekeeper", "registry"}) + fixture.run.assert_not_called() + fixture.save.assert_any_call("admission-diagnostics-result.json", + {"complete": False, "errors": errors}) + + def stub_execution(self): + from admission import Admission + admission = Admission(self.fixture()) + for phase in ("setup_registry_route", "keys", "publish", "build_ratify", + "install_gatekeeper", "install_ratify", "policies", + "assert_enforcement_ready", "positive", "negatives", "rotation", + "competing_verifier", "api_paths", "fetch_failure", + "authentication_failure", "unavailable_provider"): + setattr(admission, phase, Mock()) + admission.diagnostics = Mock(return_value=[{"surface": "events", "error": "unavailable"}]) + return admission + + def test_diagnostics_failure_cannot_make_green_run(self): + admission = self.stub_execution() + with self.assertRaisesRegex(RuntimeError, "Required admission diagnostics/cleanup failed"): + admission.execute() + + def test_diagnostics_failure_preserves_original_failure(self): + admission = self.stub_execution() + admission.positive.side_effect = ValueError("original admission failure") + with self.assertRaisesRegex(ValueError, "original admission failure"): + admission.execute() + admission.diagnostics.assert_called_once() + +class StatementFixtureTests(unittest.TestCase): + def test_mutations_preserve_original_and_bind_new_subject(self): + original = {"subject": [{"digest": {"sha256": "0" * 64}}], + "predicate": {"finalImageDigest": "sha256:" + "0" * 64, + "builderIdentity": BUILDER, "applicationJarDigest": CURRENT}} + before = copy.deepcopy(original) + subject = {"digest": OBSOLETE} + valid = variant_statement(original, subject, "valid") + self.assertEqual(valid["predicate"]["finalImageDigest"], OBSOLETE) + self.assertEqual(valid["subject"][0]["digest"]["sha256"], "2" * 64) + self.assertNotIn("applicationJarDigest", + variant_statement(original, subject, "incomplete")["predicate"]) + self.assertNotEqual(BUILDER, + variant_statement(original, subject, "wrong-builder")["predicate"]["builderIdentity"]) + self.assertEqual(original, before) + + +class RatifyReadinessTests(unittest.TestCase): + def pod(self, deleting=False): + return {"metadata": {"deletionTimestamp": "now"} if deleting else {}, + "status": {"podIP": "10.0.0.2", + "conditions": [{"type": "Ready", "status": "True"}]}} + + def ready(self, pods, addresses): + from admission import Admission + fixture = SimpleNamespace(registry="localhost:5000", get=Mock(side_effect=[ + {"items": pods}, {"subsets": [{"addresses": [{"ip": ip} for ip in addresses]}]}])) + return Admission(fixture).ratify_endpoints_ready() + + def test_terminating_rollout_pod_cannot_count_as_ready(self): + self.assertFalse(self.ready([self.pod(deleting=True)], ["10.0.0.2"])) + self.assertFalse(self.ready([self.pod(), self.pod(deleting=True)], ["10.0.0.2"])) + + def test_service_endpoints_must_match_current_ready_pod(self): + self.assertFalse(self.ready([self.pod()], [])) + self.assertFalse(self.ready([self.pod()], ["10.0.0.1"])) + self.assertTrue(self.ready([self.pod()], ["10.0.0.2"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/site/index-value-prop.html b/site/index-value-prop.html index e042397..e272c73 100644 --- a/site/index-value-prop.html +++ b/site/index-value-prop.html @@ -405,7 +405,7 @@

Make the approved path explicit.

Enforce trusted publication.
The optional Ratify/Gatekeeper integration requires a trusted final-image managed-dependency attestation. It needs configured keys, policy, and an OCI Referrers-API registry; it is not automatic or general-purpose signature admission. - Live enforcement validation is pending in #95. + Live candidate validation passed with a corrected manifest and fixture-only settings. Admission enforcement
@@ -443,7 +443,7 @@

Evaluate the changes that matter.

What needs attention in an evaluation?

Brewlet is a pre-1.0 preview. Use a disposable evaluation environment. - Live admission enforcement remains pending; CPU scaling passed with a fixed-shim candidate, not unmodified 0.5.0; + Live admission and CPU scaling passed with explicit candidate corrections and fixture-only settings, not unmodified 0.5.0; see validation coverage and remaining gaps. Privileged node provisioning adds platform responsibilities. Current capability admission does not support waking a completely zero-sized pool; diff --git a/site/index.html b/site/index.html index acb4400..cfffeaa 100644 --- a/site/index.html +++ b/site/index.html @@ -489,7 +489,7 @@

FAQ

Which supply-chain verification is available? -

Component releases carry verifiable build provenance. A managed-dependency admission example verifies Brewlet's DSSE attestations in component tests; live Ratify/Gatekeeper enforcement validation is pending in #95. General cosign or standard SLSA admission for application images is not implemented; see the supported admission contract and limitations.

+

Component releases carry verifiable build provenance. Managed-dependency admission verifies Brewlet's DSSE attestations; live candidate validation passed with a corrected Verifier manifest and fixture-only settings, not unmodified 0.5.0. General cosign or standard SLSA admission for application images is not implemented; see the supported admission contract and limitations.

Can I still use a regular container when I need one? diff --git a/site/scripts/test_site_contracts.py b/site/scripts/test_site_contracts.py index 6ca3ca4..51f5750 100644 --- a/site/scripts/test_site_contracts.py +++ b/site/scripts/test_site_contracts.py @@ -215,13 +215,15 @@ def test_validation_status_distinguishes_smoke_component_and_live_coverage(self) "simulated HPA ownership", "fixed-shim candidate", "packed-layer GC scale-out failure", + "corrected Verifier manifest", + "47 real registry", "two consecutive fresh disposable clusters", "existing E2E harness permits skips"): self.assertIn(boundary, text) for issue in (13, 93, 94, 95): self.assertIn(f"https://github.com/microsoft/brewlet/issues/{issue}", document) - def test_operational_guides_distinguish_pending_and_candidate_validation(self): + def test_operational_guides_scope_live_candidate_validation(self): guides = { "docs/admission-enforcement.md": 95, "admission/README.md": 95, @@ -233,11 +235,12 @@ def test_operational_guides_distinguish_pending_and_candidate_validation(self): with self.subTest(document=filename): text = " ".join((ROOT / filename).read_text(encoding="utf-8").split()) self.assertIn(f"https://github.com/microsoft/brewlet/issues/{issue}", text) + self.assertIn("0.5.0", text) if issue == 95: - self.assertIn("pending", text) + self.assertIn("corrected Verifier manifest", text) + self.assertIn("fixture-only", text) else: self.assertIn("fixed-shim candidate", text) - self.assertIn("0.5.0", text) self.assertIn("cold startup", text) self.assertIn("disposable", text) self.assertNotIn("production admission integration", text) diff --git a/specs/SPECIFICATION.md b/specs/SPECIFICATION.md index abd939b..93b8a4b 100644 --- a/specs/SPECIFICATION.md +++ b/specs/SPECIFICATION.md @@ -663,9 +663,15 @@ admission — reusing Brewlet's own DSSE/predicate verification through a Ratify external verifier plugin and Gatekeeper policy — is provided in [`admission/`](../admission/); it requires a registry that exposes the OCI 1.1 Referrers API. Component tests substitute registry access and external plugin -transport. Live registry, plugin, Ratify/Gatekeeper, and Kubernetes admission -validation remains pending in [#95](https://github.com/microsoft/brewlet/issues/95); -the shipped example is not a production-readiness certification. +transport. Separate live candidate validation passed twice on fresh local arm64 +clusters, exercising real native referrers, the external verifier subprocess, +Ratify/Gatekeeper and Kubernetes API enforcement. The runs use the released +0.5.0 verifier/publisher, the fixed shim and a corrected Verifier manifest +(`spec.name`, without the `spec.type` rejected by Ratify v1.4.5's chart CRD). +[#95](https://github.com/microsoft/brewlet/issues/95) and the +[runbook](../docs/live-validation.md) record fixture-only cache, registry and +TLS settings. This is not an unmodified 0.5.0 pass, ordinary-image ephemeral +execution support, or a production-readiness certification. ---