Task · MDE-0069

Unblock container publication: refresh the Alpine base pin and rebind the Trivy exception policy

Description

Every container build since 2026-09-05 failed the publication Trivy gate, so no image or chart has reached GHCR since 2026-09-04 and the v2.1.0 tag has a GitHub release with no image behind it.

Mechanism: the digest pinned at Dockerfile:15 and Dockerfile:58 resolved to Alpine 3.23.5, carrying libuuid 2.41.4-r0. Six HIGH CVEs (CVE-2026-53612, -53613, -53614, -76642, -78408, -78410) landed against it, all fixed in 2.41.6-r1, which Alpine had already published into the v3.23 repository. The upstream python:3.14-alpine3.23 image had not been rebuilt, so the pinned base stayed vulnerable. container-publish runs Trivy with severity CRITICAL,HIGH and exit-code 1, so both matrix legs failed and ‘merge + sign + sbom’ and ‘helm publish’ skipped.

Second defect, latent: .trivyignore.yaml still listed fifteen exceptions written against the Debian base (pkg:deb/debian/perl-base, libsqlite3-0, libncursesw6, gzip, libacl1). The image moved to Alpine at 2844a98 on 2026-09-03, so every one of those purls matched nothing. scripts/validate_trivy_exceptions.py validates shape, not applicability, so the policy passed while accepting nothing.

Acceptance Criteria

Definition of Done

Implementation Notes

Verified locally on 2026-09-21 before the commit, on both matrix architectures, with the same scanner CI pins (aquasecurity/trivy-action v0.36.0 ships Trivy 0.70.0) against an OCI directory built from this tree:

Negative result worth keeping: a CodeRabbit major finding asked for a ‘paths’ filter on those two exceptions, scoped to the pip/_vendor paths. It was rejected on evidence, not judgement, and must not be reapplied:

  1. scripts/validate_trivy_exceptions.py enforces the entry key set exactly, so a paths field fails the policy job in publish.yml before any image is built.
  2. Trivy reports PkgPath null for both findings, so a paths filter matches nothing. Measured: adding the filter puts the gate back to exit 1 with 2 HIGH, which is the exact failure this task exists to clear.

The apk upgrade in the runtime stage is deliberate and trades exact package-level reproducibility against the pinned digest for a self-healing gate. The digest pin is retained for the base image identity; the upgrade only patches packages Alpine has already fixed in the v3.23 repository. Removing it to ‘restore reproducibility’ reopens the outage.

Second pre-existing Alpine-migration leftover found while committing, fixed in the same change: the hadolint pre-commit hook ignored DL3008, which is apt’s version-pinning rule. The Alpine move made apk’s DL3018 the applicable one and it was never added, so hadolint had been failing at HEAD since 2844a98 on the untouched builder-stage apk add. Verified against HEAD’s own Dockerfile through hadolint v2.15.1: exit 1, findings at lines 18, 61 and 103.

hadolint’s default failure threshold is info, so DL3066 (‘USER exporter’ is non-numeric) also contributed to the non-zero exit. Fixed rather than ignored: USER is now numeric 1000, matching the chart’s securityContext runAsUser/fsGroup and letting runAsNonRoot compare a uid. Same account, since adduser already creates it with -u 1000. Smoke-tested on the rebuilt arm64 image: id reports uid=1000(exporter) gid=1000(exporter) and the package imports on Python 3.14.7.

Correction to the note above: the numeric USER change was WRONG and was reverted before it reached a release. USER is the name ‘exporter’, not 1000.

Two committed gates assert the literal name and both fail on a numeric uid: justfile’s image-verify recipe (Config.User must equal exporter, the F-119 deploy guard) and .github/container-structure-test.yaml’s metadataTest.user. CI run 35599925491 caught the first one; the second would have caught it next. hadolint’s DL3066 is now ignored instead, which is correct here because the chart sets runAsUser 1000 explicitly, so runAsNonRoot has a uid to compare without the image declaring one.

Process lesson: just check does not cover this. The Docker legs live in just ci (check image image-verify image-structure smoke), which is the right local gate for any Dockerfile change. Running it locally needs container-structure-test, which is not installed on this machine; gcr.io/gcp-runtimes/container-structure-test over the docker socket is an equivalent substitute.

Final local verification on the reverted tree: trivy gate exit 0 on linux/amd64 and linux/arm64, hadolint exit 0, just image-verify exit 0, just smoke exit 0, container-structure-test 7 passes 0 failures.

Landed and verified in production. Commits 075a2d5 and ea0cb0b on main; v2.1.1 released from f40c23a.

Release run 35601760988: release-please, prepend-release-warning, policy, both CodeQL analyses, build (linux/amd64), build (linux/arm64), merge + sign + sbom and helm publish all success. First green publication since 2026-09-04.

GHCR now carries 2.1.1, 2.1, 2, latest, main-f40c23a and main on one digest, plus its cosign signature, and charts/meraki-dashboard-exporter 2.1.1. Pulled ghcr.io/rknightion/meraki-dashboard-exporter:2.1.1 back down and confirmed against the published artefact: uid=1000(exporter), libuuid-2.41.6-r1, MERAKI_EXPORTER_VERSION=v2.1.1.

v2.1.0 is deliberately left as a tag and GitHub release with no image behind it. Republishing it was considered and rejected: publish.yml checks out inputs.release_tag, so a workflow_dispatch at v2.1.0 rebuilds that tag’s tree, which still has the unfixed Dockerfile and fails the same gate. The fix has to be in the published tree, so 2.1.1 is the first version users can actually pull.

View the source file on GitHub