Jelajahi Sumber

Removed pin-digest

Efren Yevale Varela 3 minggu lalu
induk
melakukan
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
 # assembles the multi-arch manifest (`imagetools create`), publishes it under
 # 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
 # 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
 # GHCR keeps the superseded (now-untagged) manifests until they are pruned.
 # Scheduled legs (weekly rebuild, Monday 04:00 UTC): rebuild + rescan + resign
@@ -217,39 +216,6 @@ jobs:
         run: |
           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:
     name: release
     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
   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
-`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
   cosign verify \
     --certificate-oidc-issuer https://token.actions.githubusercontent.com \
     --certificate-identity-regexp 'supply-chain' \
-    'ghcr.io/syavne/d-oauth-server@sha256:<digest>'
+    'ghcr.io/syavne/d-oauth-server:latest'
   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 -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
-  `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
 #   environment (`/proc/self/environ`, children, crash dumps, `docker
 #   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
 kind: Deployment
 metadata:
@@ -72,13 +70,9 @@ spec:
           type: RuntimeDefault
       containers:
         - 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
           ports:
             - name: public-tls

+ 3 - 15
deploy/k8s/kustomization.yaml

@@ -3,14 +3,9 @@
 # Validate: `kustomize build 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
 kind: Kustomization
 namespace: d-oauth
@@ -23,13 +18,6 @@ resources:
   - migrate-job.yaml
   - pdb.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
 # verification needs L4/TCP passthrough (NLB gateway / TLSRoute); an L7
 # 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:
 # 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
-# `*_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
 kind: Job
 metadata:
@@ -46,9 +45,8 @@ spec:
           type: RuntimeDefault
       containers:
         - 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
           # Overrides the image ENTRYPOINT (["/d-oauth-server","serve"]).
           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.
 - A `NetworkPolicy` that default-denies pod egress except DNS, Postgres, and
   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
   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).
@@ -527,9 +528,8 @@ on:
   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
   `<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
   keeps superseded manifests as untagged versions, so the job deletes untagged
   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,
   stored as an OCI 1.1 referrer/bundle; the per-arch SBOM and SLSA provenance
   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
   `deny.toml`.
 - 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.
 
-### 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
-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
-# 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.
-#    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 \
   --certificate-oidc-issuer https://token.actions.githubusercontent.com \
   --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
-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 -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 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)
 
@@ -603,21 +599,20 @@ live host:
 ```bash
 # 1. Build the same target CI signs.
 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;
 #    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
-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
 
@@ -651,13 +646,16 @@ digest, not an anonymous tarball.
   the process.
 - A CNI that does not enforce NetworkPolicy leaves pod egress unrestricted;
   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
 
-- 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:
 
 ```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
   (`docker buildx imagetools create`) and runs the trivy/grype HIGH/CRITICAL
   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`
   (non-secret settings only, including the passwordless DSNs), `secret.yaml`
   (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
   to their namespace, no `0.0.0.0/0` peer; ingress to the two pod ports),
   `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,
   one-shot `migrate`, file-mounted dev secrets under `/run/secrets`, random
   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`). |
 | 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. |
-| 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. |
 
-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
 

+ 5 - 16
scripts/check_manifests.py

@@ -46,10 +46,6 @@ contract WITHOUT a live cluster:
 
 Usage:
   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
@@ -75,7 +71,6 @@ SECRET_NAME_RE = re.compile(
   r")"
 )
 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,}$")
 # A DSN that carries `user:password@` (or `:password@`) embeds a credential.
 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}")
 
 
-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", "?")
   spec = doc.get("spec", {})
   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"{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)
 
 
-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", "?")
   spec = doc.get("spec", {}).get("template", {}).get("spec", {})
   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`")
   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)
 
 
@@ -517,7 +507,6 @@ def check_l7_mtls(docs: list[dict], fails: Failures) -> None:
 def main() -> int:
   parser = argparse.ArgumentParser()
   parser.add_argument("--manifest-dir", default="deploy/k8s")
-  parser.add_argument("--strict-digest", action="store_true")
   args = parser.parse_args()
 
   fails = Failures()
@@ -543,9 +532,9 @@ def main() -> int:
     name = (doc.get("metadata") or {}).get("name")
     check_configmap(doc, fails, requires_dsn=name in required_dsn_configmaps)
   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"):
-    check_job(doc, fails, args.strict_digest)
+    check_job(doc, fails)
   for doc in by_kind(docs, "PodDisruptionBudget"):
     check_pdb(doc, fails)
   for doc in by_kind(docs, "Service"):
@@ -555,7 +544,7 @@ def main() -> int:
   check_l7_mtls(docs, fails)
 
   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
   print(f"manifest lint: {len(fails.items)} failure(s):")
   for item in fails.items: