浏览代码

Removed pin-digest

Efren Yevale Varela 3 周之前
父节点
当前提交
232be93493

+ 2 - 36
.github/workflows/supply-chain.yml

@@ -7,11 +7,10 @@
 # no QEMU), each pushed by digest with SBOM + provenance; the `merge` job then
 # no QEMU), each pushed by digest with SBOM + provenance; the `merge` job then
 # assembles the multi-arch manifest (`imagetools create`), publishes it under
 # assembles the multi-arch manifest (`imagetools create`), publishes it under
 # the mutable `latest` tag, scans it (trivy/grype gate HIGH/CRITICAL, syft SBOM
 # the mutable `latest` tag, scans it (trivy/grype gate HIGH/CRITICAL, syft SBOM
-# artifact), keyless-signs the index with cosign, and `pin-digest` writes the
-# index digest into `deploy/k8s/kustomization.yaml` (strict lint re-run).
+# artifact), and keyless-signs the index with cosign.
 # Release tags (`v*`): the merged multi-arch image is published under semver
 # Release tags (`v*`): the merged multi-arch image is published under semver
 # tags (X.Y.Z, X.Y, X, `latest`; a pre-release tag gets only X.Y.Z) and a GitHub
 # tags (X.Y.Z, X.Y, X, `latest`; a pre-release tag gets only X.Y.Z) and a GitHub
-# Release is created. `pin-digest` does not run on a tag (it tracks master).
+# Release is created.
 # No `sha-<commit>` tag is published: a master push only moves `latest`, and
 # No `sha-<commit>` tag is published: a master push only moves `latest`, and
 # GHCR keeps the superseded (now-untagged) manifests until they are pruned.
 # GHCR keeps the superseded (now-untagged) manifests until they are pruned.
 # Scheduled legs (weekly rebuild, Monday 04:00 UTC): rebuild + rescan + resign
 # Scheduled legs (weekly rebuild, Monday 04:00 UTC): rebuild + rescan + resign
@@ -217,39 +216,6 @@ jobs:
         run: |
         run: |
           cosign sign "${REGISTRY_IMAGE}@${{ steps.merge.outputs.digest }}"
           cosign sign "${REGISTRY_IMAGE}@${{ steps.merge.outputs.digest }}"
 
 
-  pin-digest:
-    name: pin-digest
-    needs: [merge]
-    # Only master/scheduled builds advance the committed digest; a release tag
-    # must not rewrite kustomization.
-    if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')
-    runs-on: ubuntu-24.04
-    steps:
-      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
-        with:
-          python-version: "3.x"
-      - name: Pin kustomization to the built digest + strict lint
-        env:
-          DIGEST: ${{ needs.merge.outputs.digest }}
-        run: |
-          pip install pyyaml
-          python3 - "${DIGEST}" <<'EOF'
-          import sys
-          from pathlib import Path
-          import yaml
-          digest = sys.argv[1]
-          assert digest.startswith("sha256:"), f"bad digest: {digest}"
-          path = Path("deploy/k8s/kustomization.yaml")
-          doc = yaml.safe_load(path.read_text())
-          for img in doc.get("images", []):
-              if img.get("name") == "ghcr.io/syavne/d-oauth-server":
-                  img.pop("newTag", None)
-                  img["digest"] = digest
-          path.write_text(yaml.safe_dump(doc, sort_keys=False))
-          EOF
-          python3 scripts/check_manifests.py --strict-digest
-
   release:
   release:
     name: release
     name: release
     needs: [merge]
     needs: [merge]

+ 19 - 23
RUNBOOK.md

@@ -183,39 +183,35 @@ correlation. No step below writes a secret to a log.
 - Do not paste the leaked registration tokens, client secrets, or the pepper
 - Do not paste the leaked registration tokens, client secrets, or the pepper
   into a ticket, chat, or log.
   into a ticket, chat, or log.
 
 
-## 6. Deploy provenance (signed image + digest record)
+## 6. Deploy provenance (signed `ghcr.io` image)
 
 
 Every rollout ships the signed `ghcr.io/syavne/d-oauth-server` image the
 Every rollout ships the signed `ghcr.io/syavne/d-oauth-server` image the
-`supply-chain.yml` `merge` job signed (step `Keyless sign (cosign, OIDC)`)
-and the `pin-digest` job pinned into `deploy/k8s/kustomization.yaml`
-(step `Pin kustomization to the built digest + strict lint`). Nothing ships
-from an unverified local tarball. The full path is in
-[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#deploy-path-use-the-signed-ghcrio-image).
-
-- [ ] Read the digest the `pin-digest` job committed:
-  `grep -A2 'name: ghcr.io/syavne/d-oauth-server' deploy/k8s/kustomization.yaml`.
-- [ ] Verify it before applying (placeholder `sha256:<digest>` stands for the
-  value from the previous step; no real digest is recorded here; signatures are
-  cosign v3 OCI 1.1 referrers, so use a cosign v3+ client):
+`supply-chain.yml` `merge` job signed (step `Keyless sign (cosign, OIDC)`).
+Nothing ships from an unverified local tarball. The full path is in
+[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#deploy-path-use-the-ghcrio-image).
+
+- [ ] Verify the signature before applying (the `latest` tag resolves to the
+  current digest at verify time; signatures are cosign v3 OCI 1.1 referrers, so
+  use a cosign v3+ client):
 
 
   ```sh
   ```sh
   cosign verify \
   cosign verify \
     --certificate-oidc-issuer https://token.actions.githubusercontent.com \
     --certificate-oidc-issuer https://token.actions.githubusercontent.com \
     --certificate-identity-regexp 'supply-chain' \
     --certificate-identity-regexp 'supply-chain' \
-    'ghcr.io/syavne/d-oauth-server@sha256:<digest>'
+    'ghcr.io/syavne/d-oauth-server:latest'
   kustomize build deploy/k8s
   kustomize build deploy/k8s
-  python3 scripts/check_manifests.py --strict-digest
+  python3 scripts/check_manifests.py
   kubectl apply --dry-run=server -k deploy/k8s
   kubectl apply --dry-run=server -k deploy/k8s
   kubectl apply -k deploy/k8s
   kubectl apply -k deploy/k8s
   ```
   ```
 
 
-- [ ] Record the rollout: the shipped `sha256:<digest>`, the `build` run that
-  produced the `sbom.spdx.json` artifact and `trivy.sarif`, and the
-  `cosign verify` result. A rollout with no matching record is unverified:
-  stop and re-verify.
+- [ ] Record the rollout: the digest the `latest` tag resolved to (from the
+  `cosign verify` output), the `build` run that produced the `sbom.spdx.json`
+  artifact and `trivy.sarif`, and the `cosign verify` result. A rollout with no
+  matching record is unverified: stop and re-verify.
 - [ ] Local-build fallback only (registry unreachable): build with
 - [ ] Local-build fallback only (registry unreachable): build with
-  `docker buildx bake image --set image.platform=linux/amd64`, sign with
-  `cosign sign --key <local-key-ref>`, confirm with
-  `cosign verify --key <local-pub-ref>`, then record the local digest as an
-  explicitly local, non-`ghcr.io` deploy. Never
-  `docker save | docker load` an unsigned image onto a live host.
+  `docker buildx bake image --set image.platform=linux/amd64`, sign and verify
+  the image with `cosign sign --key <local-key-ref> 'd-oauth-server:latest'`
+  and `cosign verify --key <local-pub-ref> 'd-oauth-server:latest'`, then load
+  the verified image and record the deploy as explicitly local, non-`ghcr.io`.
+  Never `docker save | docker load` an unsigned image onto a live host.

+ 6 - 12
deploy/k8s/deployment.yaml

@@ -25,11 +25,9 @@
 #   but this deployment never sets them, so no secret enters the process
 #   but this deployment never sets them, so no secret enters the process
 #   environment (`/proc/self/environ`, children, crash dumps, `docker
 #   environment (`/proc/self/environ`, children, crash dumps, `docker
 #   inspect`) — a `kubectl exec` reads nothing from the environment.
 #   inspect`) — a `kubectl exec` reads nothing from the environment.
-# - The image is digest-pinned. The all-zero digest below is a deliberate
-#   placeholder: the `pin-digest` job in `.github/workflows/supply-chain.yml`
-#   writes the real signed-build digest into the `images` entry of
-#   `kustomization.yaml`, which overrides this field on every workload.
-#   `:latest` is never a shippable reference.
+# - The image reference is the mutable `:latest` tag. Nodes pull it on first
+#   use (`imagePullPolicy: IfNotPresent`); pin a digest instead when
+#   reproducible production rollouts are required.
 apiVersion: apps/v1
 apiVersion: apps/v1
 kind: Deployment
 kind: Deployment
 metadata:
 metadata:
@@ -72,13 +70,9 @@ spec:
           type: RuntimeDefault
           type: RuntimeDefault
       containers:
       containers:
         - name: server
         - name: server
-          # Digest-pinned reference. The all-zero digest
-          # is a deliberate placeholder, not a shippable reference: it fails
-          # closed instead of resolving a mutable tag. The `pin-digest` job in
-          # `.github/workflows/supply-chain.yml` writes the real signed-build
-          # digest into the `images` entry of `kustomization.yaml`; kustomize
-          # then applies that digest to this container and the migrate Job.
-          image: ghcr.io/syavne/d-oauth-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
+          # Mutable `:latest` reference: the newest published image is what
+          # pods run once the node pulls it.
+          image: ghcr.io/syavne/d-oauth-server:latest
           imagePullPolicy: IfNotPresent
           imagePullPolicy: IfNotPresent
           ports:
           ports:
             - name: public-tls
             - name: public-tls

+ 3 - 15
deploy/k8s/kustomization.yaml

@@ -3,14 +3,9 @@
 # Validate: `kustomize build deploy/k8s`
 # Validate: `kustomize build deploy/k8s`
 # Dry-run against a cluster: `kubectl apply --dry-run=server -k deploy/k8s`
 # Dry-run against a cluster: `kubectl apply --dry-run=server -k deploy/k8s`
 #
 #
-# Release pinning: the `images` entry below carries a
-# placeholder digest, never a floating tag. The `pin-digest` job in
-# `.github/workflows/supply-chain.yml` overwrites `images[].digest` with the
-# digest of the signed multi-arch build (`docker buildx bake`) after every
-# push to main and re-runs `scripts/check_manifests.py --strict-digest`. The
-# per-workload `image:` fields in `deployment.yaml` / `migrate-job.yaml` also
-# carry the placeholder; this entry overrides them in `kustomize build`.
-# `:latest` is never a shippable reference.
+# Release images: the workloads in `deployment.yaml` / `migrate-job.yaml`
+# carry the mutable `:latest` tag directly, and no `images:` override is
+# applied, so `kustomize build` emits `:latest` as-is.
 apiVersion: kustomize.config.k8s.io/v1beta1
 apiVersion: kustomize.config.k8s.io/v1beta1
 kind: Kustomization
 kind: Kustomization
 namespace: d-oauth
 namespace: d-oauth
@@ -23,13 +18,6 @@ resources:
   - migrate-job.yaml
   - migrate-job.yaml
   - pdb.yaml
   - pdb.yaml
   - networkpolicy.yaml
   - networkpolicy.yaml
-images:
-  - name: ghcr.io/syavne/d-oauth-server
-    newName: ghcr.io/syavne/d-oauth-server
-    # Placeholder (all zeroes), not a pullable reference: a rollout without
-    # the pin step fails closed instead of silently floating on a tag. CI
-    # replaces this with the released `sha256:<digest>` after signing.
-    digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
 # NOTE: no Ingress resource is shipped on purpose. In-process mTLS
 # NOTE: no Ingress resource is shipped on purpose. In-process mTLS
 # verification needs L4/TCP passthrough (NLB gateway / TLSRoute); an L7
 # verification needs L4/TCP passthrough (NLB gateway / TLSRoute); an L7
 # ingress that terminates TLS strips the client certificate. Adding an
 # ingress that terminates TLS strips the client certificate. Adding an

+ 4 - 6
deploy/k8s/migrate-job.yaml

@@ -12,9 +12,8 @@
 # read-only rootfs, ALL capabilities dropped, `automountServiceAccountToken:
 # read-only rootfs, ALL capabilities dropped, `automountServiceAccountToken:
 # false`, and every secret (token pepper, TOTP key, mandatory PII key, DB/Redis
 # false`, and every secret (token pepper, TOTP key, mandatory PII key, DB/Redis
 # passwords) mounted as 0400 tmpfs files under `/run/secrets` (read through the
 # passwords) mounted as 0400 tmpfs files under `/run/secrets` (read through the
-# `*_FILE` variables) instead of env vars. The image carries the same placeholder
-# digest; `pin-digest` rewrites the real digest in `kustomization.yaml` for
-# every workload.
+# `*_FILE` variables) instead of env vars. It runs the same mutable `:latest`
+# image reference as the Deployment.
 apiVersion: batch/v1
 apiVersion: batch/v1
 kind: Job
 kind: Job
 metadata:
 metadata:
@@ -46,9 +45,8 @@ spec:
           type: RuntimeDefault
           type: RuntimeDefault
       containers:
       containers:
         - name: migrate
         - name: migrate
-          # Same pinning rule as the Deployment: placeholder digest, never a
-          # shippable `:latest`; CI resolves it via `kustomization.yaml`.
-          image: ghcr.io/syavne/d-oauth-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
+          # Same image reference as the Deployment: mutable `:latest`.
+          image: ghcr.io/syavne/d-oauth-server:latest
           imagePullPolicy: IfNotPresent
           imagePullPolicy: IfNotPresent
           # Overrides the image ENTRYPOINT (["/d-oauth-server","serve"]).
           # Overrides the image ENTRYPOINT (["/d-oauth-server","serve"]).
           command: ["/d-oauth-server", "migrate"]
           command: ["/d-oauth-server", "migrate"]

+ 43 - 46
docs/DEPLOYMENT.md

@@ -275,9 +275,10 @@ A correct Deployment has these properties:
   environment or baked into the image.
   environment or baked into the image.
 - A `NetworkPolicy` that default-denies pod egress except DNS, Postgres, and
 - A `NetworkPolicy` that default-denies pod egress except DNS, Postgres, and
   Redis, and admits ingress only on the two pod ports (see below).
   Redis, and admits ingress only on the two pod ports (see below).
-- A digest-pinned image reference. The release pipeline replaces the committed
-  placeholder digest in `kustomization.yaml` with the signed build digest;
-  `:latest` is never a shippable reference.
+- The `ghcr.io/syavne/d-oauth-server:latest` image reference that
+  `kustomization.yaml` applies. `:latest` is mutable, so a rollout is only as
+  fresh as the last pull; that is a deliberate convenience tradeoff, not an
+  immutable pin.
 - Client-CA material mounted read-only if in-process mTLS is used. The Postgres
 - Client-CA material mounted read-only if in-process mTLS is used. The Postgres
   CA for `sslmode=verify-full` is mounted at `/certs/db-ca.crt` and pinned with
   CA for `sslmode=verify-full` is mounted at `/certs/db-ca.crt` and pinned with
   `database.root_cert_path` (`d-oauth-tls` also carries a `db-ca.crt` key).
   `database.root_cert_path` (`d-oauth-tls` also carries a `db-ca.crt` key).
@@ -527,9 +528,8 @@ on:
   publishes `<X.Y.Z>`, `<X.Y>`, `<X>`, and `latest`, and creates a GitHub
   publishes `<X.Y.Z>`, `<X.Y>`, `<X>`, and `latest`, and creates a GitHub
   Release with generated notes (a pre-release such as `v1.2.3-rc1` gets only
   Release with generated notes (a pre-release such as `v1.2.3-rc1` gets only
   `<X.Y.Z>`). No `sha-<commit>` tags are published. These tags are convenience
   `<X.Y.Z>`). No `sha-<commit>` tags are published. These tags are convenience
-  references only; a production rollout still pins the digest (see
-  [Deploy path](#deploy-path-use-the-signed-ghcrio-image)). `pin-digest` runs
-  only on master/scheduled builds, never on a tag.
+  references; the deploy path applies the mutable `latest` tag (see
+  [Deploy path](#deploy-path-use-the-ghcrio-image)).
 - Registry cleanup (job `cleanup`, weekly + manual): GHCR has no retention and
 - Registry cleanup (job `cleanup`, weekly + manual): GHCR has no retention and
   keeps superseded manifests as untagged versions, so the job deletes untagged
   keeps superseded manifests as untagged versions, so the job deletes untagged
   manifests and the legacy `sha-*` tags, keeping `latest` and every release tag.
   manifests and the legacy `sha-*` tags, keeping `latest` and every release tag.
@@ -544,40 +544,31 @@ on:
   `Keyless sign (cosign, OIDC)`: `cosign sign` on the merged index digest,
   `Keyless sign (cosign, OIDC)`: `cosign sign` on the merged index digest,
   stored as an OCI 1.1 referrer/bundle; the per-arch SBOM and SLSA provenance
   stored as an OCI 1.1 referrer/bundle; the per-arch SBOM and SLSA provenance
   attestations are attached by the `build` legs).
   attestations are attached by the `build` legs).
-- Digest pinning (job `pin-digest`, step
-  `Pin kustomization to the built digest + strict lint`): the signed merge
-  digest overwrites `images[].digest` in `deploy/k8s/kustomization.yaml`,
-  then re-runs `python3 scripts/check_manifests.py --strict-digest`, with a
-  periodic rebuild so base digests advance.
 - `cargo deny check` in CI, which enforces the ring-only TLS policy in
 - `cargo deny check` in CI, which enforces the ring-only TLS policy in
   `deny.toml`.
   `deny.toml`.
 - A per-PR fuzz build plus a scheduled bounded run of every fuzz target
 - A per-PR fuzz build plus a scheduled bounded run of every fuzz target
   (`.github/workflows/ci.yml`); a crash uploads its artifact and fails the run.
   (`.github/workflows/ci.yml`); a crash uploads its artifact and fails the run.
 
 
-### Deploy path: use the signed `ghcr.io` image
+### Deploy path: use the `ghcr.io` image
 
 
 The only shippable artifact is the signed registry image, never a locally
 The only shippable artifact is the signed registry image, never a locally
-built tarball. Every rollout verifies provenance before applying anything,
-then records the exact digest it shipped:
+built tarball. Every rollout verifies provenance before applying anything:
 
 
 ```bash
 ```bash
-# 1. Read the pinned digest the `pin-digest` job committed.
-grep -A2 'name: ghcr.io/syavne/d-oauth-server' deploy/k8s/kustomization.yaml
-
-# 2. Verify the keyless cosign signature from the `merge` job's
+# 1. Verify the keyless cosign signature from the `merge` job's
 #    `Keyless sign (cosign, OIDC)` step before pulling or applying.
 #    `Keyless sign (cosign, OIDC)` step before pulling or applying.
-#    Match the identity to the signing workflow; the placeholder below
-#    stands for the real digest from step 1 (no real digest is recorded here).
+#    Match the identity to the signing workflow. The tag resolves to the
+#    current digest at verify time.
 cosign verify \
 cosign verify \
   --certificate-oidc-issuer https://token.actions.githubusercontent.com \
   --certificate-oidc-issuer https://token.actions.githubusercontent.com \
   --certificate-identity-regexp 'supply-chain' \
   --certificate-identity-regexp 'supply-chain' \
-  'ghcr.io/syavne/d-oauth-server@sha256:<digest>'
+  'ghcr.io/syavne/d-oauth-server:latest'
 
 
-# 3. Confirm the manifests resolve to that digest and pass policy.
+# 2. Confirm the manifests resolve and pass policy.
 kustomize build deploy/k8s
 kustomize build deploy/k8s
-python3 scripts/check_manifests.py --strict-digest
+python3 scripts/check_manifests.py
 
 
-# 4. Roll out (migration Job first; the Deployment readiness gate waits for it).
+# 3. Roll out (migration Job first; the Deployment readiness gate waits for it).
 kubectl apply --dry-run=server -k deploy/k8s
 kubectl apply --dry-run=server -k deploy/k8s
 kubectl apply -k deploy/k8s
 kubectl apply -k deploy/k8s
 ```
 ```
@@ -587,11 +578,16 @@ Sigstore bundles, not the legacy `sha256-<digest>.sig` tag; verify with a
 cosign v3+ client (cosign v2.6+ can read them with `--new-bundle-format`). The
 cosign v3+ client (cosign v2.6+ can read them with `--new-bundle-format`). The
 `cosign verify` flags above are unchanged from v2.
 `cosign verify` flags above are unchanged from v2.
 
 
-Record the shipped `sha256:<digest>` in [RUNBOOK.md](../RUNBOOK.md)
-(deploy provenance entry: digest, `sbom.spdx.json` / `trivy.sarif` run, and
-the `cosign verify` result). A rollout with no matching record is an
-unverified deploy: stop and re-verify. `:latest` is never a shippable
-reference.
+The manifests apply `ghcr.io/syavne/d-oauth-server:latest`, a mutable tag. A
+rollout picks up whatever digest the tag points at when the node pulls it, so
+two nodes can briefly run different builds mid-rollout, and a later re-apply
+can pull a newer image than the one you verified. That is the deliberate
+tradeoff for a simple, low-ceremony admin flow; treat the tag as a convenience
+reference, not an immutable pin. Record the rollout in
+[RUNBOOK.md](../RUNBOOK.md) (deploy provenance entry: the digest the tag
+resolved to, the `sbom.spdx.json` / `trivy.sarif` run, and the `cosign verify`
+result). A rollout with no matching record is an unverified deploy: stop and
+re-verify.
 
 
 ### Fallback: local build (unsigned host, sign and verify locally)
 ### Fallback: local build (unsigned host, sign and verify locally)
 
 
@@ -603,21 +599,20 @@ live host:
 ```bash
 ```bash
 # 1. Build the same target CI signs.
 # 1. Build the same target CI signs.
 docker buildx bake image --set image.platform=linux/amd64
 docker buildx bake image --set image.platform=linux/amd64
-docker images --digests d-oauth-server
 
 
-# 2. Sign the local digest and verify it before loading or running.
+# 2. Sign the local image and verify it before loading or running.
 #    (Keyless OIDC is unavailable off CI, so a local key stands in;
 #    (Keyless OIDC is unavailable off CI, so a local key stands in;
 #    keep the private key off the target host.)
 #    keep the private key off the target host.)
-cosign sign --key <local-key-ref> 'd-oauth-server@sha256:<digest>'
-cosign verify --key <local-pub-ref> 'd-oauth-server@sha256:<digest>'
+cosign sign --key <local-key-ref> 'd-oauth-server:latest'
+cosign verify --key <local-pub-ref> 'd-oauth-server:latest'
 
 
-# 3. Record the local `sha256:<digest>` and the verify result in RUNBOOK.md
-#    as an explicitly local, non-`ghcr.io` deploy.
+# 3. Load the verified image and record the deploy in RUNBOOK.md as an
+#    explicitly local, non-`ghcr.io` rollout.
 ```
 ```
 
 
 Prefer the signed `ghcr.io` path for every production rollout; the fallback
 Prefer the signed `ghcr.io` path for every production rollout; the fallback
-exists so an air-gapped host still gets a sign/verify step and a recorded
-digest, not an anonymous tarball.
+exists so an air-gapped host still gets a sign/verify step, not an anonymous
+tarball.
 
 
 ## Security properties
 ## Security properties
 
 
@@ -651,13 +646,16 @@ digest, not an anonymous tarball.
   the process.
   the process.
 - A CNI that does not enforce NetworkPolicy leaves pod egress unrestricted;
 - A CNI that does not enforce NetworkPolicy leaves pod egress unrestricted;
   verify the cluster CNI applies the policy before relying on it.
   verify the cluster CNI applies the policy before relying on it.
-- Shipping the committed placeholder digest without running the release
-  pipeline fails closed: the image cannot be pulled. Run `pin-digest` first.
+- Re-applying the manifests after a new push rolls forward: `:latest` is
+  mutable, so an unchanged manifest can still pull a newer build than the one
+  you verified.
 
 
 ## Operational runbook
 ## Operational runbook
 
 
-- Roll out a new image by updating the Deployment digest; the migration Job
-  runs first.
+- Roll out a new image by re-applying the manifests; they track
+  `ghcr.io/syavne/d-oauth-server:latest`, and the migration Job runs first. To
+  roll a specific build instead, point the `images` entry at a release tag such
+  as `<X.Y.Z>`.
 - Probe commands inside the pod network:
 - Probe commands inside the pod network:
 
 
 ```bash
 ```bash
@@ -687,8 +685,7 @@ d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
   SBOM + provenance, a `merge` job that assembles the multi-arch manifest
   SBOM + provenance, a `merge` job that assembles the multi-arch manifest
   (`docker buildx imagetools create`) and runs the trivy/grype HIGH/CRITICAL
   (`docker buildx imagetools create`) and runs the trivy/grype HIGH/CRITICAL
   gates (uploading the trivy SARIF to code scanning) plus keyless cosign
   gates (uploading the trivy SARIF to code scanning) plus keyless cosign
-  signing, digest pinning of
-  `deploy/k8s/kustomization.yaml`, and the weekly rebuild.
+  signing, and the weekly rebuild.
 - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
 - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
   (non-secret settings only, including the passwordless DSNs), `secret.yaml`
   (non-secret settings only, including the passwordless DSNs), `secret.yaml`
   (placeholder secrets — pepper, TOTP key, PII key, database and Redis
   (placeholder secrets — pepper, TOTP key, PII key, database and Redis
@@ -701,9 +698,9 @@ d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
   `networkpolicy.yaml` (default-deny egress except DNS + Postgres/Redis scoped
   `networkpolicy.yaml` (default-deny egress except DNS + Postgres/Redis scoped
   to their namespace, no `0.0.0.0/0` peer; ingress to the two pod ports),
   to their namespace, no `0.0.0.0/0` peer; ingress to the two pod ports),
   `pdb.yaml` (`minAvailable: 2`), `kustomization.yaml`
   `pdb.yaml` (`minAvailable: 2`), `kustomization.yaml`
-  (committed placeholder digest; the release pipeline pins the real image
-  digest here), and `tls-secret.example.yaml` (how to provision `/certs`,
-  never applied). No Ingress is shipped: mTLS requires L4/TCP passthrough.
+  (an `images` entry pointing at `ghcr.io/syavne/d-oauth-server:latest`), and
+  `tls-secret.example.yaml` (how to provision `/certs`, never applied). No
+  Ingress is shipped: mTLS requires L4/TCP passthrough.
 - `compose.yaml` for the local 3-replica proof (one Postgres + one Redis,
 - `compose.yaml` for the local 3-replica proof (one Postgres + one Redis,
   one-shot `migrate`, file-mounted dev secrets under `/run/secrets`, random
   one-shot `migrate`, file-mounted dev secrets under `/run/secrets`, random
   host ports so `--scale app=3` never collides).
   host ports so `--scale app=3` never collides).

+ 8 - 7
docs/SECURITY.md

@@ -69,15 +69,16 @@ class, or runtime setting).
 | Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379) scoped to the namespace hosting them — no internet-wide `0.0.0.0/0` peer; any `ipBlock` peer excludes `169.254.0.0/16`; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. A managed database outside the cluster needs a specific CIDR or a CNI FQDN policy. The default-deny also blocks the JAR `request_uri` HTTPS fetch — remote request objects are unsupported unless the deployment adds a scoped allowance (see `docs/DEPLOYMENT.md`). |
 | Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379) scoped to the namespace hosting them — no internet-wide `0.0.0.0/0` peer; any `ipBlock` peer excludes `169.254.0.0/16`; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. A managed database outside the cluster needs a specific CIDR or a CNI FQDN policy. The default-deny also blocks the JAR `request_uri` HTTPS fetch — remote request objects are unsupported unless the deployment adds a scoped allowance (see `docs/DEPLOYMENT.md`). |
 | Operator `exec` into a running pod | No shell and a non-root, read-only-rootfs container limit what runs; every secret is a file the container user already reads, and no secret is in the environment | `pods/exec` (and `docker exec`) bypass the application's own authorization; restrict it with RBAC and audit it (`docs/OPERATIONS.md`). |
 | Operator `exec` into a running pod | No shell and a non-root, read-only-rootfs container limit what runs; every secret is a file the container user already reads, and no secret is in the environment | `pods/exec` (and `docker exec`) bypass the application's own authorization; restrict it with RBAC and audit it (`docs/OPERATIONS.md`). |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
-| Image/registry compromise | Digest pinning, keyless cosign v3 signing (OCI 1.1 referrer bundles) in the `supply-chain.yml` `merge` job (`Keyless sign (cosign, OIDC)`), verify-before-rollout with `cosign verify` on the pinned `ghcr.io/syavne/d-oauth-server@sha256:<digest>` (see `docs/DEPLOYMENT.md`), `pin-digest` writing the signed digest into `kustomization.yaml` with a `--strict-digest` lint, a committed digest reference with no shippable `:latest`, and a recorded digest per rollout in `RUNBOOK.md`; the local-build fallback still signs (`cosign sign`) and verifies (`cosign verify`) before loading | A registry compromise of an unpinned tag still ships if the operator skips the verify step; an unsigned `docker save \\| docker load` tarball bypasses every control, so it is forbidden except through the documented local sign/verify fallback. |
+| Image/registry compromise | Keyless cosign v3 signing (OCI 1.1 referrer bundles) in the `supply-chain.yml` `merge` job (`Keyless sign (cosign, OIDC)`), verify-before-rollout with `cosign verify` on `ghcr.io/syavne/d-oauth-server:latest` (see `docs/DEPLOYMENT.md`), a mutable `:latest` reference that trades immutability for a simple admin flow, and a local-build fallback that still signs (`cosign sign`) and verifies (`cosign verify`) before loading; each rollout records the digest the tag resolved to in `RUNBOOK.md` | A registry compromise of the mutable `:latest` tag ships if the operator skips the verify step, and the tag can move between verify and pull (TOCTOU), so the verified digest may not be the one a node pulls; an unsigned `docker save \\| docker load` tarball bypasses every control, so it is forbidden except through the documented local sign/verify fallback. |
 | Rate-limit evasion via spoofed `X-Forwarded-For` behind an L7 proxy | Default-empty   `rate_limit.trusted_proxies` (headers ignored unless the TCP peer is an allowlisted proxy IP/CIDR); rightmost-untrusted selection; malformed headers fall back to the verified peer; attribution is bucket selection only, never authentication | An allowlist entry that is too broad (or a compromised proxy) attributes attacker-chosen IPs; keep entries to the proxy peers and never a `/0` (rejected at load). Proxies must emit bare IPs (no port/brackets/`unknown`); anything else is skipped, and only the rightmost 32 entries are considered. |
 | Rate-limit evasion via spoofed `X-Forwarded-For` behind an L7 proxy | Default-empty   `rate_limit.trusted_proxies` (headers ignored unless the TCP peer is an allowlisted proxy IP/CIDR); rightmost-untrusted selection; malformed headers fall back to the verified peer; attribution is bucket selection only, never authentication | An allowlist entry that is too broad (or a compromised proxy) attributes attacker-chosen IPs; keep entries to the proxy peers and never a `/0` (rejected at load). Proxies must emit bare IPs (no port/brackets/`unknown`); anything else is skipped, and only the rightmost 32 entries are considered. |
 
 
-Last reviewed: 2026-10-09. File-mounted DB/Redis passwords, digest pinning,
-NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
-implemented in the shipped manifests and workflows. Deploy provenance is a
-documented verify-before-rollout path (`docs/DEPLOYMENT.md`, `RUNBOOK.md`):
-the signed `ghcr.io` image with `cosign verify` and a recorded digest, with
-local builds kept only as a signed fallback.
+Last reviewed: 2026-10-13. File-mounted DB/Redis passwords, a mutable `:latest`
+image reference, NetworkPolicy, `automountServiceAccountToken: false`, and
+scheduled fuzzing are implemented in the shipped manifests and workflows.
+Deploy provenance is a documented verify-before-rollout path
+(`docs/DEPLOYMENT.md`, `RUNBOOK.md`): the signed `ghcr.io` image verified with
+`cosign verify` and a recorded digest, with local builds kept only as a signed
+fallback.
 
 
 ## Toplevel rules
 ## Toplevel rules
 
 

+ 5 - 16
scripts/check_manifests.py

@@ -46,10 +46,6 @@ contract WITHOUT a live cluster:
 
 
 Usage:
 Usage:
   python3 scripts/check_manifests.py [--manifest-dir deploy/k8s]
   python3 scripts/check_manifests.py [--manifest-dir deploy/k8s]
-                                     [--strict-digest]
-
-`--strict-digest` (used by supply-chain CI after the pin step) additionally
-requires every workload image to be digest-pinned (`@sha256:<64 hex>`).
 """
 """
 
 
 from __future__ import annotations
 from __future__ import annotations
@@ -75,7 +71,6 @@ SECRET_NAME_RE = re.compile(
   r")"
   r")"
 )
 )
 PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
 PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
-DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
 HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
 HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
 # A DSN that carries `user:password@` (or `:password@`) embeds a credential.
 # A DSN that carries `user:password@` (or `:password@`) embeds a credential.
 EMBEDDED_PASSWORD_RE = re.compile(r"://[^/@?#]*:[^/@?#]*@")
 EMBEDDED_PASSWORD_RE = re.compile(r"://[^/@?#]*:[^/@?#]*@")
@@ -397,7 +392,7 @@ def check_networkpolicy(doc: dict, fails: Failures) -> None:
       fails.add(f"networkpolicy/{name}: egress must allow port {port}")
       fails.add(f"networkpolicy/{name}: egress must allow port {port}")
 
 
 
 
-def check_deployment(doc: dict, fails: Failures, strict_digest: bool) -> None:
+def check_deployment(doc: dict, fails: Failures) -> None:
   name = doc.get("metadata", {}).get("name", "?")
   name = doc.get("metadata", {}).get("name", "?")
   spec = doc.get("spec", {})
   spec = doc.get("spec", {})
   if spec.get("replicas") != 3:
   if spec.get("replicas") != 3:
@@ -452,13 +447,10 @@ def check_deployment(doc: dict, fails: Failures, strict_digest: bool) -> None:
           f"deployment/{name}/{cname}: {probe_name} must httpGet "
           f"deployment/{name}/{cname}: {probe_name} must httpGet "
           f"{path} on port {HEALTH_PORT} (plaintext health listener)"
           f"{path} on port {HEALTH_PORT} (plaintext health listener)"
         )
         )
-    image = container.get("image", "")
-    if strict_digest and not DIGEST_RE.search(image):
-      fails.add(f"deployment/{name}/{cname}: image must be digest-pinned")
     check_file_mounted_secrets(f"deployment/{name}", tpl, container, fails)
     check_file_mounted_secrets(f"deployment/{name}", tpl, container, fails)
 
 
 
 
-def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
+def check_job(doc: dict, fails: Failures) -> None:
   name = doc.get("metadata", {}).get("name", "?")
   name = doc.get("metadata", {}).get("name", "?")
   spec = doc.get("spec", {}).get("template", {}).get("spec", {})
   spec = doc.get("spec", {}).get("template", {}).get("spec", {})
   if spec.get("restartPolicy") != "Never":
   if spec.get("restartPolicy") != "Never":
@@ -477,8 +469,6 @@ def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
   ):
   ):
     fails.add(f"job/{name}: exactly one migrate Job must run `/d-oauth-server migrate`")
     fails.add(f"job/{name}: exactly one migrate Job must run `/d-oauth-server migrate`")
   for container in containers:
   for container in containers:
-    if strict_digest and not DIGEST_RE.search(container.get("image", "")):
-      fails.add(f"job/{name}: image must be digest-pinned")
     check_file_mounted_secrets(f"job/{name}", spec, container, fails)
     check_file_mounted_secrets(f"job/{name}", spec, container, fails)
 
 
 
 
@@ -517,7 +507,6 @@ def check_l7_mtls(docs: list[dict], fails: Failures) -> None:
 def main() -> int:
 def main() -> int:
   parser = argparse.ArgumentParser()
   parser = argparse.ArgumentParser()
   parser.add_argument("--manifest-dir", default="deploy/k8s")
   parser.add_argument("--manifest-dir", default="deploy/k8s")
-  parser.add_argument("--strict-digest", action="store_true")
   args = parser.parse_args()
   args = parser.parse_args()
 
 
   fails = Failures()
   fails = Failures()
@@ -543,9 +532,9 @@ def main() -> int:
     name = (doc.get("metadata") or {}).get("name")
     name = (doc.get("metadata") or {}).get("name")
     check_configmap(doc, fails, requires_dsn=name in required_dsn_configmaps)
     check_configmap(doc, fails, requires_dsn=name in required_dsn_configmaps)
   for doc in by_kind(docs, "Deployment"):
   for doc in by_kind(docs, "Deployment"):
-    check_deployment(doc, fails, args.strict_digest)
+    check_deployment(doc, fails)
   for doc in by_kind(docs, "Job"):
   for doc in by_kind(docs, "Job"):
-    check_job(doc, fails, args.strict_digest)
+    check_job(doc, fails)
   for doc in by_kind(docs, "PodDisruptionBudget"):
   for doc in by_kind(docs, "PodDisruptionBudget"):
     check_pdb(doc, fails)
     check_pdb(doc, fails)
   for doc in by_kind(docs, "Service"):
   for doc in by_kind(docs, "Service"):
@@ -555,7 +544,7 @@ def main() -> int:
   check_l7_mtls(docs, fails)
   check_l7_mtls(docs, fails)
 
 
   if fails.ok():
   if fails.ok():
-    print(f"manifest lint: OK ({len(docs)} documents, strict-digest={args.strict_digest})")
+    print(f"manifest lint: OK ({len(docs)} documents)")
     return 0
     return 0
   print(f"manifest lint: {len(fails.items)} failure(s):")
   print(f"manifest lint: {len(fails.items)} failure(s):")
   for item in fails.items:
   for item in fails.items: