approval-engine/docs/image-scan-2026-09-06.md
tegwick f88a92fb37 Run the scan gate; harden the image and stop short of release
glas-harness asked for a scanned immutable image. The scan ran, and it failed
on the base we had sanctioned, so no image was pushed.

Every finding is inherited from the base image or its distro packages; none is
in approval_engine code. Two fixes applied here:

The base pin was stale. It named a Debian 13.5 build of python:3.12-slim while
upstream is 13.6. Bumping the digest removes 30 HIGH and 47 MEDIUM on its own.
Still a digest, not a floating tag.

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

What remains is a decision rather than a task. Three CRITICALs persist on
Debian, all perl-base (CVE-2026-13221, CVE-2026-42496, CVE-2026-8376), none
with an upstream fix, in a package this service never invokes and that Debian
marks Essential. An Alpine variant carries no perl and scans 0 CRITICAL / 7
HIGH / 1 MEDIUM against Debian's 3 / 51 / 56.

Alpine is proven viable rather than asserted: musl wheels resolve with no
toolchain, the full suite passes on musl at 111 tests, and non-root uid 10001,
schema v3, tenant:platform, fresh-store migrate/verify and both production
fail-closed gates all hold in the built image. It is parked in
Containerfile.alpine as a candidate; Containerfile remains sanctioned.

Nothing was released. Pushing the Debian variant would pin three unfixable
CRITICALs into a release digest, and choosing the runtime C library for this
service is not a call to make silently. The manifest still carries
REPLACE_WITH_RELEASE_DIGEST. Full record in docs/image-scan-2026-09-06.md.

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-06 22:52:20 +02:00

4.8 KiB

Image scan — 2026-09-06

Scanner: aquasec/trivy:latest (--scanners vuln), run against locally built images. Requested by glas-harness under GLAS-WP-0015 / APPROVAL-WP-0002-T03.

No image was pushed. The scan is why — see "Why nothing was released".

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 is now open

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

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….

Open questions for owners

  1. Is Alpine/musl acceptable as the sanctioned base for this service? If yes, Containerfile.alpine becomes Containerfile and the release is scanned clean of CRITICALs.
  2. If glibc is required, is a documented exception for the three unfixable perl-base CVEs acceptable, given the service never invokes perl? A distroless glibc base is the third option and was not evaluated here.
  3. Which scanner is sanctioned for the gate? This used trivy because it needed no install; the estate may have a different standard.