approval-engine/docs/image-scan-2026-09-06.md
tegwick 119359c33d Reconcile the release record; guard the image pin with tests
The publication at b51d174 pinned the manifest and appended a note, but left
three documents asserting the opposite. docs/image-scan-2026-09-06.md said "It
has not been pushed" in its Outcome block at the top while recording the
successful push at the bottom; its "Release status" section still said no
release digest existed and the manifest carried REPLACE_WITH_RELEASE_DIGEST;
and deploy/README.md still instructed an operator to replace a placeholder that
is now a real digest -- an instruction to undo the pin.

This is the derived-artifact staleness this repo argued for a rule about in
v0.8 §12 and then produced in its own release evidence, in the file another
repository would read to confirm what shipped. Superseded sections are now
marked in place with what replaced them rather than deleted, since they record
why the base changed and why the release was held. The scan doc carries a
status marker naming which parts state current state, including that a clean
scan is point-in-time and ages as advisories are published.

The workplan gains the publication entry it never got, and a re-check of T03's
full acceptance: a published image reads like a finished task, but the digest
was one of five requirements. Nothing is deployed -- the namespace is empty and
no secrets exist -- and production serve refuses to start without authenticated
audit delivery, so a rollout today would fail closed and prove nothing. T03
stays wait on T01's registrations and the audit credential.

Also corrects an earlier caution in the workplan that the digest was "a local
image id, not a release digest". That was wrong: with the containerd/OCI store
the build id is the manifest digest, and the registry resolves it.

tests/test_deploy_manifest.py holds the pin mechanically instead of by review:
both image references exist, are digest-pinned rather than tagged, are
identical, and no placeholder remains. The identity check matters because the
init container migrates the database the server then serves -- two different
digests there is a split-brain migration. Verified the guard fails on the drift
it targets, not only that it passes today. 116 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM5HnEAhokxdfcPqBNpT7D

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715850@bnt-lap001
Assistant-Session: eb557e93-7cb1-45d0-9e57-7d15b3edc60e
2026-09-07 09:04:34 +02:00

10 KiB

artifact_type status_marker last_reconciled
dated status record DATED RECORD. Sections are marked with the date their status was true. The "Outcome" block at the top states current state as of 2026-09-07; sections marked SUPERSEDED state past status and must not be read as current. Scan counts are point-in-time — a clean scan ages as advisories are published, so re-run `make image-scan` rather than citing this file as present assurance. 2026-09-07

Image scan — 2026-09-06

Scanner: trivy (aquasec/trivy:latest, --scanners vuln) — sanctioned by the operator 2026-09-06. Requested by glas-harness under GLAS-WP-0015 / APPROVAL-WP-0002-T03.

Outcome

Base decision: Alpine/musl, adopted 2026-09-06 by operator, alongside the scanner. Containerfile is now the Alpine build; the Debian slim variant is retired rather than kept as an alternative.

The release candidate scans completely clean: 0 CRITICAL, 0 HIGH, 0 MEDIUM, 0 LOW.

It is published. Registry tag 0.1.0 resolves to OCI index digest sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d (linux/amd64 manifest sha256:2064d537…), and both image: references in deploy/approval-engine.yaml pin that index digest. See "Registry publication" at the end for the publication record.

Not rolled out. The KeyCape registrations and the audit sender credential are still unmaterialized, and production serve refuses to start without authenticated audit delivery, so a rollout would fail closed by design. APPROVAL-WP-0002-T03 stays wait on those gates.

The history below is kept because it records why the base changed. Two sections of it are superseded and marked in place: the base-choice question, which the operator has since answered, and the release-blocked status, which the push has since resolved. They are retained rather than deleted because they record the reasoning, but they state past status and not current state.

Results

Image CRITICAL HIGH MEDIUM
As pinned before this session (python:3.12-slim@sha256:d764629c…, Debian 13.5) 3 81 103
slim-hardened — base bumped to @sha256:78387bc3… (Debian 13.6), pip removed 3 51 56
alpinepython:3.12-alpine@sha256:b64631e0… (Alpine 3.24.1), pip removed 0 7 1

Every finding is inherited from the base image or its distro packages. None is in approval_engine code, and after removing pip the Python dependency layer (cryptography 50.0.1, PyJWT 2.13.0, waitress 3.0.2, cffi, pycparser) reports zero findings at MEDIUM and above in all three.

Two fixes applied

The base pin was stale. Containerfile pinned a Debian 13.5 build of python:3.12-slim; current upstream is Debian 13.6. Bumping the pin removes 30 HIGH and 47 MEDIUM findings on its own. The pin is still a digest, not a tag — the fix is a newer immutable pin, not a floating one.

pip is gone from the runtime image. All 10 MEDIUM Python findings were in pip itself, which is a build-time tool with no business in a running approval service. The build is now two-stage: pip installs the venv in the build stage and is deleted from both the venv and the base's /usr/local in the runtime stage. command -v pip pip3 pip3.12 returns nothing in both variants.

The three CRITICALs, and why the base choice was open — SUPERSEDED

Answered. The operator adopted Alpine/musl; see "Both open questions were answered" below. This section records why the question was posed.

The remaining CRITICALs on Debian are all perl-base 5.40.1-6:

  • CVE-2026-13221
  • CVE-2026-42496
  • CVE-2026-8376

All three have no upstream fix (trivy reports no fixed version), so no base bump or package upgrade clears them. perl-base is Essential: yes on Debian and is not safely removable. This service does not use perl at any point.

Alpine carries no perl at all, which is why it reports 0 CRITICAL. That makes the base a real decision rather than a preference:

  • Alpine clears all three CRITICALs and drops HIGH from 51 to 7, but changes the C library from glibc to musl.
  • Debian slim keeps glibc and ships three unfixable CRITICALs in a package the service never calls.

Evidence that Alpine is viable

  • Image builds with no compiler toolchain: cryptography 50.0.1 installs from musllinux wheels.
  • The full test suite passes on musl: 111 passed, the same count as the workstation. (An earlier run showed 102 passed / 1 skipped; the 9-test gap was tests/test_examples.py skipping on a missing jsonschema test dependency in the throwaway container, not a musl failure.)
  • Runtime identity uid=10001(approval) gid=10001(approval), non-root.
  • Carries LATEST_SCHEMA_VERSION = 3 and Engine tenant default tenant:platform.
  • First-install migration on a fresh volume: migrate then verify both report schema_version: 3, schema_current: true, integrity: ["ok"], foreign_key_violations: 0, persistent: true.
  • Fail-closed gates hold in the image: serve --production --db :memory: refuses with "production requires a persistent database"; production without audit configuration refuses with "production requires authenticated audit delivery".

Containerfile.alpine holds this variant. It is a candidate, not the sanctioned base — the sanctioned base is still Containerfile.

Why nothing was released at the time — SUPERSEDED

Superseded. The image has since been published; see "Registry publication". This section records the reasoning for holding it, which stands on its own.

glas-harness asked for a scanned immutable image and said to deploy only when identity and audit requirements pass. The scan gate is not met by the Debian variant, and pushing it would bake three unfixable CRITICALs into a release digest that the manifest then pins by hash.

Choosing the runtime C library for a production approval service is also not a call this repo should make silently. So:

  • No push to forgejo.coulomb.social.
  • deploy/approval-engine.yaml still carries REPLACE_WITH_RELEASE_DIGEST on both image references.
  • APPROVAL-WP-0002-T03 stays wait.

Local build digests, recorded for traceability and explicitly not release digests: slim-hardened sha256:78b87e68e520…, alpine sha256:736647294706….

Both open questions were answered

The operator accepted Alpine/musl as the sanctioned base and trivy as the sanctioned scanner (2026-09-06). Containerfile.alpine was promoted to Containerfile; the Debian variant is retired. The distroless option was never evaluated and is now moot.

Clearing the last findings — libuuid

Promoting Alpine left 7 HIGH and 1 MEDIUM, all in libuuid 2.42.1-r0 as shipped by the pinned Alpine 3.24.1, and all with fixes available in 2.42.3.

The runtime stage now installs libuuid>=2.42.3-r1. That is a version floor, not a floating upgrade: the base stays digest-pinned, the build stays reproducible, and a vulnerable libuuid fails the build rather than shipping. After that patch the image scans clean at every severity including LOW.

Release candidate

Tag forgejo.coulomb.social/coulomb/approval-engine:0.1.0, local image id sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d.

This is a local image id, not the release digest. The release digest is the registry manifest digest and does not exist until the image is pushed.

Verified on this exact artifact:

Gate Result
Vulnerability scan (trivy, all severities) 0 findings
Runtime identity uid=10001(approval) gid=10001(approval), non-root
pip present no — command -v pip pip3 returns nothing
Schema LATEST_SCHEMA_VERSION = 3
Store tenant default tenant:platform
First install, no prior DB migrate then verify: schema 3, schema_current, integrity ["ok"], 0 FK violations, persistent
Restart persistence re-verify on the same volume: ok=True, schema 3, integrity ok
Production without persistent DB refused — "production requires a persistent database"
Production without audit config refused — "production requires authenticated audit delivery"
Full test suite on musl 111 passed
Manifest inputs kubectl apply --dry-run=client passes

Release status — SUPERSEDED, states status as of 2026-09-06 before publication

Superseded by "Registry publication" below. Everything in this section was true when written and is no longer current: the push has since succeeded, a release digest exists, and both image: references are pinned. Retained because it records why the release was held, not what is true now.

docker push forgejo.coulomb.social/coulomb/approval-engine:0.1.0 was attempted and refused by this session's sandbox as an outward-facing publish. It was not retried or worked around.

Consequently, as of that moment:

  • No release digest existed yet.
  • deploy/approval-engine.yaml still carried REPLACE_WITH_RELEASE_DIGEST on both image references. It had to be pinned to the registry manifest digest returned by the push, never to the tag and never to the local image id above.
  • APPROVAL-WP-0002-T03 stayed wait.

The path recorded at the time, since taken by the operator:

docker push forgejo.coulomb.social/coulomb/approval-engine:0.1.0
docker inspect --format '{{index .RepoDigests 0}}' \
  forgejo.coulomb.social/coulomb/approval-engine:0.1.0

Then pin both image: references in deploy/approval-engine.yaml to that digest. Rollout, restart and restore evidence remain gated on the KeyCape registrations and the audit sender credential, which are still unmaterialized.

Registry publication — 2026-09-06, Glas deployment session

The previously blocked push succeeded under the operator's standing production deployment authorization. Registry tag 0.1.0 resolves to OCI index digest sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d. The linux/amd64 image manifest is sha256:2064d5378e01801ecf9966ccec9c93aadd16d6b404ef4890983d89fa738c164c. Both deployment images now pin the registry index digest, verified remotely following publication. Its equality to the earlier local image identifier is not the evidence of publication; the successful push and remote index lookup are.

No StatefulSet rollout yet: live KeyCape registrations and audit sender/token admission remain outstanding. APPROVAL-WP-0002-T03 stays wait for those gates.