| name | cve-remediation |
| description | Use this skill when resolving Docker Scout CVE findings in Omniglass container images (the `make scan` / `/scan` gate). Covers the build→scan→triage→pin→re-scan loop, the SBOM-project-name vs apk-package-name mapping gotcha, when to pin vs allowlist, and the rule that a CVE with an available fix is never allowlisted. |
CVE remediation for container images
make scan runs scripts/scan.sh against the seven published images. CI runs the
identical script (.github/workflows/ci.yml → "CVE gate" step calls
scripts/scan.sh) with the same CVE_ALLOWLIST. So a green local
make scan is an exact proxy for the merge gate — there is no need to push to
CI to find out. Validate locally.
Scan scope (set in scripts/scan.sh): --only-severity critical,high --only-fixed --ignore-base. Only critical/high CVEs that have an available
fix and are not inherited from the base image fail the gate.
When to use
- A scheduled or ad-hoc request to resolve scan vulnerabilities
- The
scan CI job (or make scan) is red
- Bumping base images and needing to confirm the CVE posture afterward
Procedure
1. Worktree from main
Follow worktree-pr. Branch type is fix (these
land as fix: → patch release).
2. Build from the worktree, then scan
WT=.claude/worktrees/fix+<short-name>
make -C "$WT" build
make -C "$WT" scan
make -C <dir> builds the worktree's Dockerfiles regardless of the shell's
cwd — the build scripts resolve their repo root from their own location.
Buildx cache behaviour — read this. An unchanged apk RUN line is a cache
hit, so its apk index is frozen at whenever that layer was last built. A
stale :test image therefore scans against a stale package index and can
"pass" on a fix it never actually pulled, or "fail" on one already released.
Editing the RUN line is what busts the cache and forces apk to re-resolve
against the live Alpine repo. A cache-hit image keeps its old docker images
CreatedSince even after a rebuild — that is expected, not a failure.
Build isolation — also read this. The seven :test tags are shared
global Docker state. build-local.sh is docker build -t …:test per image.
Two builds running against the same daemon race on those tags, and
docker build/BuildKit jobs do not always die when the CLI is killed —
a TaskStop/kill'd or nohup'd build can finish later and re-tag :test
with whatever it built (e.g. an unfixed image from the unmodified primary
checkout). Discipline:
-
Exactly one build at a time. Never start a second while one runs.
-
Never nohup a build and never kill one mid-flight — let it finish.
-
Build from the worktree by absolute path
(docker build -f "$WT/docker/<x>/Dockerfile" -t …:test "$WT").
A bare ./scripts/build-local.sh resolves its repo root from the script's
own location = whatever checkout you launched it from, which is almost
always the unfixed primary checkout, not the worktree.
-
After the build, the build log saying "naming to …:test done" is not
proof. Verify the fix is in the tag empirically:
docker run --rm --entrypoint sh <img>:test -c 'apk list --installed' \
| grep -E '<pkg>'
make scan of a :test clobbered by a stray build will report the old
finding and look like your fix failed when it did not.
If the cache itself is masking a change (rare — only when the RUN text did
not change but the index did), make build-no-cache (NO_CACHE=1) is the
documented escape hatch.
3. Triage every flagged package
For each finding, decide: is there an available fix in Alpine 3.23?
Scout reads the image's SBOM attestation and reports by upstream project
name, which is often not the apk package name. Real example: Scout reports
postgresql17 and postgresql18; the actual installed apk packages are
postgresql17-client (psql) and libpq (built from the postgresql18 aport,
provides libpq.so.5). Pinning postgresql18 would fail — the package does
not exist. Map project → apk package inside the built image before pinning:
IMG=ghcr.io/hyperscaleav/omniglass-<name>:test
docker run --rm --entrypoint sh "$IMG" -c 'apk list --installed' | grep -i <hint>
docker run --rm --entrypoint sh "$IMG" -c 'apk info --who-owns /path/to/the.so'
Confirm the fixed version actually exists in the live Alpine 3.23 repo before
pinning, or the build fails on an unsatisfiable constraint:
docker run --rm alpine:3.23 sh -c 'apk update -q; apk policy <apk-package>'
4a. Fixable → pin in the Dockerfile
Pin the apk package name in the matching docker/<image>/Dockerfile apk
line, mirroring the established style ('pkg>=<fixed-version>') and adding a
CVE comment in the same form as the existing ones:
# curl 8.17.0-r1→8.19.0-r0 (CVE-2026-3805 high)
RUN apk add --no-cache --upgrade ... 'curl>=8.19.0-r0'
For a package family with many sub-packages (e.g. apache2*, php84*) the
existing pattern adds && apk upgrade --no-cache --available 'family*' so
sub-packages move too. A glob only works when the apk names share that prefix
(it would not catch libpq for the postgresql18 project — pin that one
explicitly).
The zabbix-server / zabbix-proxy Dockerfiles carry a "keep the two in sync"
invariant for their shared package line. pgsql-only packages (libpq,
postgresql*-client) belong to the server, not the sqlite3 proxy — add them
as a separate, commented RUN so the shared-line invariant stays true.
4b. No Alpine fix → allowlist with rationale (only then)
Add the CVE id to CVE_ALLOWLIST only if there is no available fix in
Alpine 3.23. Never allowlist a CVE that has a fix — pin it instead. Each
entry needs a comment with (a) why it is not exploitable in our images and
(b) what upstream version the fix needs vs. what Alpine 3.23 ships. The
existing python-3.12 block is the reference: Alpine 3.23 ships python-3.12
only; the fixes require 3.13.13+/3.14.5+, and no code path in our images
calls the affected APIs.
5. Rebuild and re-scan until clean
One build, awaited, nothing else touching Docker (see Build isolation above).
Rebuild only the images you changed, from the worktree by absolute path:
docker build --build-arg BASE_IMAGE="zabbix-server-pgsql:alpine-7.4-local" \
-f "$WT/docker/zabbix-server/Dockerfile" \
-t ghcr.io/hyperscaleav/omniglass-legacy-zabbix-server-pgsql:test "$WT"
Verify the fix landed in the tag (apk list --installed per image), then:
"$WT/scripts/scan.sh"
scan.sh prints either "All images clean" or, per image, "All findings are
allowlisted" — both pass. "CVEs with available fixes found" fails.
6. PR
Open via worktree-pr, title fix: <description>.
The make scan gate is the regression test — no separate test is needed.
docs/src/content/docs/security/image-security.md describes the remediation
mechanism generically; routine pin bumps and allowlist entries do not
change it, so it needs no edit unless the process itself changes.
Gotchas
make scan == the CI gate. Local green = mergeable. Do not push to CI to check.
- Scout reports SBOM project names, not apk package names. Always map before pinning.
- Editing the apk RUN line is the cache-bust. Without an edit,
apk will not re-resolve.
- Cache-hit images keep an old
CreatedSince after a rebuild — not a failure.
- One build at a time. A killed/
nohup'd build can finish later and re-tag :test with unfixed bits — the classic "my fix didn't take" symptom. Verify with apk list --installed in the actual tag, not the build log or a possibly-clobbered make scan.
- Build from the worktree by absolute path.
./scripts/build-local.sh builds whatever checkout it lives in (usually the unfixed primary checkout).
- A CVE with an available fix is never allowlisted. Allowlist is for genuinely unfixable findings only.
- Verify the fixed version is live in Alpine 3.23 (
apk policy in a stock alpine:3.23) before pinning.