fix(release): resolve the release by id, and make the vulnerability gate cover HIGH (#661)

Two release-pipeline defects found while pre-flighting 2.2.0, plus the image
hardening that the second one exposed.

The release job would have died immediately after pushing the v2.2.0 tag.
draftRelease was turned on in #649 and never executed, and GitHub's
/releases/tags/{tag} endpoint does not return draft releases, so all nine tag
lookups in release.yml would have 404'd against the draft semantic-release had
just created. Verified against this repo with a throwaway draft: the tag
endpoint 404s while gh release view reads it and /releases/{id} returns the same
REST shape. Every site now resolves the numeric id first, so existing jq
expressions are untouched.

The unfixed-vulnerability gate was measuring almost nothing. The blocking Trivy
steps run ignore-unfixed, and trivy-unfixed-gate.mjs was meant to cover the
remainder but defaults to CRITICAL with neither call site passing --severity. An
unfixed HIGH was gated by nothing, and the arm64 image carried 79 of them while
the summary read clean.

Rather than document 79 findings, the image lost what it did not need:
libde265 1.1.1 and libheif 1.23.1 are now built from source (the old libheif pin
was itself affected by CVE-2026-3950, and Debian's libde265 1.0.11 was the
decoder every .heic upload actually reached), and xvfb, wget and openssh-client
are purged. 15 CVEs left the image outright and the HIGH gap fell to 65, each
now carrying a rationale verified against the running container.

curl gets its own section: bookworm-backports has a fixed 8.14.1, so claiming no
fix was available would have been false. It is recorded as a declined fix.

Verified on both architectures: gate exits 0, the source-built libde265 is the
one libheif links, and HEIC, RAW, ImageMagick, Sharp AVIF and headless chromium
all still work after the purge.
This commit is contained in:
SnapOtter
2026-07-28 22:49:56 +08:00
committed by GitHub
parent a75a22dd3f
commit 935861bced
7 changed files with 426 additions and 60 deletions
+6
View File
@@ -34,6 +34,7 @@ permissions: {}
jobs:
validate-inputs:
name: Validate release inputs
timeout-minutes: 10
if: inputs.release_commit != ''
runs-on: ubuntu-latest
permissions: {}
@@ -755,6 +756,7 @@ jobs:
sign-ocr-index:
name: Sign verified OCR runtime index
timeout-minutes: 20
needs: [build-ocr, verify-ocr, verify-ocr-nvidia]
runs-on: ubuntu-latest
permissions:
@@ -1318,6 +1320,10 @@ jobs:
publish:
name: Publish verified bundles to HuggingFace
# Uploads the full v3 set to HuggingFace and then re-downloads and hashes
# every published object. Untimed, a stalled transfer burns the 360 minute
# default before anyone notices.
timeout-minutes: 60
needs: [verify-ocr, sign-ocr-index, verify-signed-ocr-index]
# Hugging Face exposes one repository-wide branch head. Keep expensive
# builds parallel across releases, then serialize only the short CAS write
+66 -22
View File
@@ -127,21 +127,36 @@ jobs:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.check.outputs.version }}
run: |
release_endpoint="repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}"
if ! gh api "${release_endpoint}" > /tmp/release.json 2> /tmp/release.error; then
if ! grep -Fq "(HTTP 404)" /tmp/release.error; then
cat /tmp/release.error >&2
echo "::error::Could not determine whether the GitHub draft exists"
exit 1
fi
# Resolve the release by numeric id, never by tag. GitHub's
# /releases/tags/{tag} endpoint returns 404 for a draft, and
# draftRelease is on, so a tag lookup here would 404 on the release
# semantic-release just created and take the whole job down with it.
# gh release view reads drafts, and /releases/{id} then returns the
# same REST shape a tag lookup would.
resolve_release_id() {
gh release view "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--json databaseId \
--jq .databaseId 2>/dev/null
}
release_id="$(resolve_release_id || true)"
if [[ ! "${release_id}" =~ ^[0-9]+$ ]]; then
gh release create "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--draft \
--verify-tag \
--title "v${VERSION}" \
--notes-file /tmp/release-notes.md
gh api "${release_endpoint}" > /tmp/release.json
release_id="$(resolve_release_id)"
fi
[[ "${release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::Could not resolve the GitHub release id for v${VERSION}"
exit 1
}
release_endpoint="repos/${GITHUB_REPOSITORY}/releases/${release_id}"
gh api "${release_endpoint}" > /tmp/release.json
jq -e --arg tag "v${VERSION}" \
'.draft == true and .tag_name == $tag' /tmp/release.json >/dev/null || {
echo "::error::Expected release is missing, public, or bound to the wrong tag"
@@ -422,13 +437,14 @@ jobs:
format: json
output: snapotter-v${{ needs.release.outputs.new_version }}-archive-linux-${{ matrix.arch }}-trivy.json
- name: Gate unfixed CRITICAL findings
- name: Gate unfixed CRITICAL and HIGH findings
if: always()
env:
REPORT: snapotter-v${{ needs.release.outputs.new_version }}-archive-linux-${{ matrix.arch }}-trivy.json
LABEL: archive linux/${{ matrix.arch }}
run: |
node scripts/trivy-unfixed-gate.mjs "${REPORT}" \
--severity CRITICAL,HIGH \
--label "${LABEL}" --summary "${GITHUB_STEP_SUMMARY}"
- name: Publish verified immutable archive assets
@@ -438,8 +454,10 @@ jobs:
VERSION: ${{ needs.release.outputs.new_version }}
run: |
REPOSITORY="snapotter-hq/SnapOtter"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(
gh api "repos/${REPOSITORY}/releases/tags/v${VERSION}" --jq .id
gh release view "v${VERSION}" --repo "${REPOSITORY}" \
--json databaseId --jq .databaseId
)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::GitHub release did not resolve to one immutable ID"
@@ -758,7 +776,17 @@ jobs:
exit 1
fi
asset_name="snapotter-v${VERSION}-${PLATFORM_PAIR}.digest"
if ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
reuse_release_id="$(
gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" \
--json databaseId --jq .databaseId 2>/dev/null || true
)"
if [[ ! "${reuse_release_id}" =~ ^[0-9]+$ ]]; then
echo "::warning::Could not resolve the release id; rebuilding ${PLATFORM}"
echo "reused=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! gh api "repos/${GITHUB_REPOSITORY}/releases/${reuse_release_id}" \
--jq ".assets[] | select(.name == \"${asset_name}\") | .id" \
> /tmp/existing-platform-asset-ids 2> /tmp/existing-platform-asset.error; then
echo "::warning::Could not read ${asset_name}; rebuilding ${PLATFORM}"
@@ -915,7 +943,16 @@ jobs:
VERSION: ${{ needs.release.outputs.new_version }}
run: |
asset_name="snapotter-v${VERSION}-${PLATFORM_PAIR}.digest"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
persist_release_id="$(
gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" \
--json databaseId --jq .databaseId
)"
[[ "${persist_release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::GitHub release did not resolve to one immutable ID"
exit 1
}
gh api "repos/${GITHUB_REPOSITORY}/releases/${persist_release_id}" \
--jq ".assets[] | select(.name == \"${asset_name}\") | .id" \
> /tmp/platform-digest-asset-ids
mapfile -t asset_ids < /tmp/platform-digest-asset-ids
@@ -1053,13 +1090,14 @@ jobs:
format: "json"
output: "snapotter-v${{ needs.release.outputs.new_version }}-image-${{ matrix.platform }}-trivy.json"
- name: Gate unfixed CRITICAL findings
- name: Gate unfixed CRITICAL and HIGH findings
if: always()
env:
REPORT: "snapotter-v${{ needs.release.outputs.new_version }}-image-${{ matrix.platform }}-trivy.json"
LABEL: "image ${{ matrix.platform }}"
run: |
node scripts/trivy-unfixed-gate.mjs "${REPORT}" \
--severity CRITICAL,HIGH \
--label "${LABEL}" --summary "${GITHUB_STEP_SUMMARY}"
- name: Upload Trivy report to GitHub Release
@@ -1070,7 +1108,9 @@ jobs:
run: |
repository="snapotter-hq/SnapOtter"
report="snapotter-v${VERSION}-image-${{ matrix.platform }}-trivy.json"
release_id="$(gh api "repos/${repository}/releases/tags/v${VERSION}" --jq .id)"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(gh release view "v${VERSION}" --repo "${repository}" \
--json databaseId --jq .databaseId)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || exit 1
mapfile -t asset_ids < <(
gh api "repos/${repository}/releases/${release_id}/assets?per_page=100" \
@@ -1183,7 +1223,9 @@ jobs:
VERSION: ${{ needs.release.outputs.new_version }}
run: |
repository="snapotter-hq/SnapOtter"
release_id="$(gh api "repos/${repository}/releases/tags/v${VERSION}" --jq .id)"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(gh release view "v${VERSION}" --repo "${repository}" \
--json databaseId --jq .databaseId)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || exit 1
for sbom in \
"snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.cdx.json" \
@@ -1664,8 +1706,10 @@ jobs:
}
release_subjects_name="snapotter-v${VERSION}-release-subjects.json"
echo "release_subjects_name=${release_subjects_name}" >> "$GITHUB_ENV"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" --jq .id
gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" \
--json databaseId --jq .databaseId
)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::GitHub release did not resolve to one immutable ID"
@@ -2006,9 +2050,9 @@ jobs:
VERSION: ${{ needs.release.outputs.new_version }}
run: |
draft="$(
gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
--jq .draft
gh release view "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--json isDraft --jq .isDraft
)"
[[ "${draft}" == "true" ]] || {
echo "::error::Approved release is not a draft before final publication"
@@ -2024,9 +2068,9 @@ jobs:
--repo "${GITHUB_REPOSITORY}" \
--draft=false
[[ "$(
gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
--jq .draft
gh release view "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--json isDraft --jq .isDraft
)" == "false" ]] || {
echo "::error::Approved release remained a draft after publication"
exit 1
+250 -26
View File
@@ -1,4 +1,4 @@
# Unfixed CRITICAL findings we ship knowingly.
# Unfixed CRITICAL and HIGH findings we ship knowingly.
#
# Read with .trivyignore, which does the opposite job. That file hides findings
# that are absent from the artifact. This one keeps findings that are PRESENT
@@ -8,7 +8,7 @@
#
# The release workflow's blocking scan uses ignore-unfixed, which is right for
# what it gates (a fix exists and we did not take it) and blind to everything
# here. Without this file the count of unfixed criticals in a shipped image was
# here. Without this file the count of unfixed findings in a shipped image was
# structurally zero.
#
# Adding an entry means claiming three things and writing down the evidence:
@@ -17,25 +17,126 @@
# every release. Removing the cause beats adding a line.
#
# Every entry below is Debian 12 (arm64). amd64 is on Ubuntu 24.04 and carries
# none of them, so the amd64 release jobs will report the whole list as absent.
# That gap is the real fix for most of this: see IMG-20260726-005.
# NONE of them: a full scan of the 2.2.0 amd64 image reports zero unfixed
# CRITICAL and zero unfixed HIGH. So the amd64 release jobs report this whole
# list as absent, and the arm64 base is the single cause of every line here.
# Moving arm64 to Ubuntu 24.04 deletes this file. See IMG-20260726-005.
#
# Verified against the real 2.2.0 arm64 image rather than inferred from the
# Dockerfile: package presence by dpkg-query, load paths by LD_DEBUG=libs and
# readelf DT_NEEDED, reverse dependencies by apt-cache rdepends --installed,
# and fix availability by apt-cache policy plus the Debian security tracker.
#
# What was fixed rather than listed, in this pass:
# libraw20/libraw-bin removed with the apt entry in #649; LibRaw 0.22.2 is
# built from source and is the only copy in the image.
# libde265 1.1.1 built from source and loaded in place of Debian's
# 1.0.11, so the HEVC decoder that actually receives an
# attacker's .heic bitstream is the fixed line.
# libheif pinned build moved 1.21.2 to 1.23.1; the old pin was
# itself listed as affected by CVE-2026-3950.
# xvfb, wget, purged. 12 packages and 14 findings gone, each verified
# openssh-client by dry-run to take nothing else with it.
# ---------------------------------------------------------------------------
# Reachable from a user upload. These are the ones that matter.
# ---------------------------------------------------------------------------
# OpenEXR 3.1.5. ImageMagick's exr.so coder links libOpenEXR-3_1.so.30, and an
# .exr upload reaches it through decodeExr, so a malformed EXR gets to the
# vulnerable parser. CVE-2023-5841 is fixed in Debian trixie (3.1.13) but has
# no bookworm backport. The other two are unfixed in every Debian suite
# including sid (3.4.6) and untriaged by Ubuntu, so the amd64 image is equally
# exposed and reports zero for them. Fixing this means either rebuilding
# ImageMagick against a patched OpenEXR or dropping the EXR coder; neither is a
# release-window change. Re-check when Debian or upstream ships a fix.
# .exr upload reaches it through decodeExr (apps/api/src/lib/format-decoders.ts),
# so a malformed EXR gets to the vulnerable parser. Confirmed with LD_DEBUG=libs
# convert sample.exr. /usr/bin/djxl links OpenEXR too, so every .jxl decode also
# initialises it, though only the JXL parser touches those bytes.
#
# This entry used to list three IDs. The tracker now has sixteen open against
# bookworm's 3.1.5 and ten are unfixed in sid's 3.4.6 as well, so a base bump
# does not clear them either. Trixie's 3.1.13 would clear CVE-2023-5841 and
# CVE-2025-64181 but not the 2026 batch.
#
# The library arrives as a dependency of libmagickcore-6.q16-6-extra and of
# libjxl-tools. Removing it means dropping the EXR coder, which takes .jxl
# support with it because they share libmagickcore-6.q16-6-extra. That is a
# product decision about two accepted upload formats, not a hardening one, so
# it is filed rather than done. Re-check when Debian or upstream ships a fix.
CVE-2023-5841
CVE-2025-12495
CVE-2025-12839
CVE-2025-12840
CVE-2025-64181
CVE-2026-27622
CVE-2026-34379
CVE-2026-34543
CVE-2026-34544
CVE-2026-34545
CVE-2026-34588
CVE-2026-40244
CVE-2026-40250
CVE-2026-41142
CVE-2026-42216
CVE-2026-42217
# expat 2.5.0. LibreOffice's SAX parser is expat: libexpwraplo.so and
# libmergedlo.so both carry a direct DT_NEEDED on libexpat.so.1, and every
# office-format upload is XML that reaches it through convertDocument
# (packages/doc-engine/src/libreoffice.ts). That covers docx, doc, odt, rtf,
# xlsx, xls, ods, pptx, ppt and odp across word-to-pdf, excel-to-pdf,
# powerpoint-to-pdf, the three convert-* tools and the file preview. A direct
# path from an upload to the vulnerable parser, so it is filed as reachable.
#
# SVG is not that path. Sharp's libvips is statically self-contained and vendors
# its own glib, so sanitizeSvg into Sharp never touches distro expat, and
# ImageMagick only picks expat up transitively through fontconfig.
# Bookworm is at its ceiling with no backport.
CVE-2025-59375
CVE-2026-25210
CVE-2026-45186
CVE-2026-56131
CVE-2026-56407
CVE-2026-56408
# libtiff 4.5.0, through LibreOffice. The image pipeline is not the path: Sharp's
# libvips is self-contained, Pillow vendors its own libtiff, and ghostscript is
# only ever invoked with -sDEVICE=pdfwrite or pnggray, so its TIFF devices are
# unreachable. libreoffice-core lists libtiff6 as a Depends, and converting a
# document carrying an embedded TIFF loads /lib/aarch64-linux-gnu/libtiff.so.6
# with attacker bytes. Verified with a hand-built docx under LD_DEBUG=libs.
# Nothing for bookworm and no backport. Trixie's 4.7.0 clears four of the five
# open advisories; CVE-2026-36849 is ignored there too.
CVE-2023-52355
CVE-2026-12912
CVE-2026-36849
# libjxl 0.7.0, decoder side. .jxl is an accepted input, jxl is in
# CLI_DECODED_FORMATS, and decodeJxl runs djxl on the user's file before falling
# back to ImageMagick (apps/api/src/lib/format-decoders.ts), which puts an
# attacker's bitstream into libjxl.so.0.7. The encoder side is not exposed:
# encodeJxl always feeds cjxl a Sharp-produced PNG. Bookworm ships 0.7.0 with no
# backport, and while trixie's 0.11.2 clears this one, the package cannot be
# dropped without also dropping the EXR coder they share.
CVE-2025-70103
# harfbuzz 6.0.0, quadratic blowup on a crafted font. Two live paths, both
# confirmed with LD_DEBUG=libs: a LibreOffice conversion initialises
# libharfbuzz.so.0, and WeasyPrint's html-to-pdf and markdown-to-pdf reach it
# through pango. The second supplies the font as well as the document:
# doc_html_pdf.py blocks remote references but deliberately passes data: URLs to
# the default fetcher, so a @font-face carrying a base64 payload in user HTML is
# fetched, parsed and subsetted. Bookworm and bookworm-proposed-updates are both
# at 6.0.0. Fixed in trixie's 10.2.0.
CVE-2023-25193
# python3.11, the distro interpreter. /opt/venv is a venv over /usr/bin/python3
# and /data/ai/venv is a copy of it, so this interpreter runs every AI sidecar
# script against user uploads. There is no second interpreter in the image. The
# individual defects sit in stdlib modules the sidecars do not obviously call,
# but the process is squarely on the upload path, so these are filed here rather
# than argued away. Bookworm ceiling, no backport.
CVE-2025-69534
CVE-2026-11940
CVE-2026-15308
CVE-2026-3644
CVE-2026-7210
# libxml2 2.9.14, use-after-free on crafted XML, denial of service. Linked into
# convert, tesseract and LibreOffice, all of which run on user-supplied files.
# No confirmed path from an upload to the affected parser (SVG goes through
@@ -43,13 +144,38 @@ CVE-2026-42217
# filed as reachable rather than argued away. Debian has deferred the fix.
CVE-2026-6653
# ---------------------------------------------------------------------------
# A fix exists and we are not taking it. Not the same as unfixable.
# ---------------------------------------------------------------------------
# curl 7.88.1. bookworm-backports carries 8.14.1-2+deb13u2~bpo13+1 and the
# dry-run install is clean, so "no fix in any channel we can consume" would be a
# false claim here. We are declining it for this release: a major version jump
# on a stable base, pulling in four new packages (libnghttp3, libngtcp2 and
# friends), for a library nothing calls with user input. The only runtime curl
# invocation is the HEALTHCHECK against http://localhost:1349/api/v1/health.
# Outbound HTTP is Node undici (apps/api/src/lib/ssrf.ts) and Python urllib,
# never libcurl.
#
# One caveat that keeps this honest: libmergedlo.so links libcurl-gnutls.so.4
# directly and LibreOffice runs with no network kill switch, so a document
# carrying a remote linked image is a plausible route into libcurl's response
# handling. Not empirically confirmed. If it is confirmed, take the backport
# instead of extending this entry. Re-check next release.
CVE-2026-12064
CVE-2026-6276
CVE-2026-8286
CVE-2026-8927
CVE-2026-8932
# ---------------------------------------------------------------------------
# Present in a process that handles user input, but the vulnerable code is not
# on any path user input can take.
# ---------------------------------------------------------------------------
# perl 5.36, all four via libimage-exiftool-perl. exiftool runs on uploads, so
# perl itself is in the path; these specific defects are not.
# perl 5.36, all via libimage-exiftool-perl. exiftool runs on uploads through
# format-decoders.ts, info.ts and edit-metadata.ts, so perl itself is in the
# path; these specific defects are not.
# CVE-2026-8376 heap overflow compiling regexes, 32-bit builds only. Both
# shipped architectures are 64-bit.
# CVE-2026-42496 Archive::Tar path traversal. Nothing in the image runs it.
@@ -57,20 +183,84 @@ CVE-2026-6653
# input to deserialize. Nothing deserializes Storable.
# CVE-2026-13221 silently incorrect regex results. A correctness bug, not a
# memory-safety one, and NVD's 9.1 does not reflect that.
# The 2026-42497, -48962, -57432 and -9538 set is the same shape: defects in
# modules exiftool never loads. It is invoked only with -b, -JpgFromRaw,
# -PreviewImage and metadata reads, never as a general perl host.
CVE-2026-8376
CVE-2026-42496
CVE-2026-57433
CVE-2026-13221
CVE-2026-42497
CVE-2026-48962
CVE-2026-57432
CVE-2026-9538
# glib 2.74, integer underflow in g_dbus_node_info_new_for_xml. Needs
# attacker-controlled D-Bus introspection XML. The container runs no D-Bus
# broker and nothing feeds it introspection data.
# glib 2.74. CVE-2026-58016 is the integer underflow in
# g_dbus_node_info_new_for_xml and needs attacker-controlled D-Bus introspection
# XML. The container runs no broker: the dbus package is not installed, there is
# no dbus-daemon, gdbus or /run/dbus socket, the gio modules directory is empty,
# and neither gvfs nor dconf-service is present. The 2026-5801x set is the same
# GDBus and GVariant family and dies on the same fact.
#
# Note that libglib2.0-data ships 308 paths under /usr/share and zero shared
# objects, so half the rows Trivy reports against this source package carry no
# code at all. GLib's string and GObject code does run, through pango,
# gdk-pixbuf, ImageMagick, LibreOffice and tesseract, so a future advisory
# landing outside GDBus needs its own line rather than this one.
CVE-2026-58016
CVE-2026-58010
CVE-2026-58011
CVE-2026-58012
CVE-2026-58013
CVE-2026-58014
CVE-2026-58015
# sqlite 3.40, integer overflow. Needs attacker-controlled SQL. The app is on
# Postgres; libsqlite3 is here as a python3 and LibreOffice dependency.
CVE-2025-7458
# libssh2 1.10.0. In the image only because Debian's libcurl4 and
# libcurl3-gnutls link it for SCP and SFTP, and those are its only reverse
# dependencies. Nothing invokes curl with an scp:// or sftp:// URL: the one
# runtime curl call is the healthcheck against http://localhost, and
# user-supplied URLs go through Node's undici, never libcurl. The vulnerable
# transport is never negotiated. No backport.
CVE-2026-55199
CVE-2026-55200
CVE-2026-7598
# libcups2 2.4.2. Present twice over, as a Playwright chromium dependency and as
# a libreoffice-core and libgs10 one. Both linkers are on the upload path but
# neither prints: ghostscript is only ever called with -sDEVICE=pdfwrite or
# pnggray under -dSAFER, never the cups device, and LibreOffice runs
# --headless --convert-to. Only the client library is installed, with no
# cups-daemon and no lp, lpstat or ipptool, so nothing ever opens an IPP
# connection. No backport.
CVE-2026-34980
# libldap 2.5.13. Consumers are libcurl4, libcurl3-gnutls, libpq5,
# postgresql-17, libreoffice-core and dirmngr. Reachable only if something dials
# ldap:// or ldaps://. The database URL is a plain postgres:// with no
# pg_service LDAP lookup, headless LibreOffice never opens an address book, curl
# is only called on http://localhost, and no LDAP client tools are installed
# (ldapsearch and ldapwhoami absent, /etc/ldap/ldap.conf has no active
# directives). No backport.
CVE-2023-2953
# Base-system packages that no application code path touches. gzip has no
# installed reverse dependencies at all and the app decompresses in-process
# (node:zlib in svg-sanitize.ts, the node tar package in feature-status.ts,
# Python tarfile in install_feature.py). ncurses serves bash, readline and
# procps in a container with no TTY that never feeds user data to a terminal
# library. libacl1 is exercised only by coreutils and tar, on paths the app
# itself creates under /tmp/workspace and /data. util-linux ships mount, blkid
# and the smartcols table formatter, none of which is invoked with user input.
# All at the bookworm candidate with no backport.
CVE-2026-41992
CVE-2025-69720
CVE-2026-54369
CVE-2026-53615
# ---------------------------------------------------------------------------
# Not loaded or not present at all. These inflate the count and nothing else.
# ---------------------------------------------------------------------------
@@ -80,14 +270,48 @@ CVE-2025-7458
# why the status is will_not_fix. The vulnerable function is not in the image.
CVE-2023-45853
# libaom3, heap overflow on frame size change. aom is pulled in only as a
# dependency of the distro libheif1, which is shadowed at runtime by the
# libheif 1.21.2 built in this Dockerfile (LD_LIBRARY_PATH=/usr/local/lib) and
# configured with WITH_AOM_DECODER=OFF. ffmpeg is a self-contained static
# build. Nothing loads libaom.
CVE-2023-6879
# libheif1 1.15.1, shadowed. The Dockerfile builds libheif 1.23.1 into
# /usr/local/lib and sets LD_LIBRARY_PATH=/usr/local/lib, which the app
# propagates to every child process (execFile inherits it, and the Python
# dispatcher passes it explicitly in packages/ai/src/bridge.ts). Verified:
# LD_DEBUG=libs heif-dec resolves libheif.so.1 to /usr/local/lib and reports
# 1.23.1, and ImageMagick's heic.so resolves the same way. The distro package
# stays installed only as a transitive dependency of libmagickcore-6.q16-6 and
# cannot be purged without removing ImageMagick.
#
# `convert -list format` prints HEIC (1.15.1) because that string is baked at
# ImageMagick build time. It does not reflect the loaded library.
# bookworm-backports has 1.19.7, which is older than what we build.
CVE-2025-68431
CVE-2026-32740
CVE-2026-32741
CVE-2026-32882
CVE-2026-41071
CVE-2026-47178
# openssh-client, use-after-free during client-side host key re-exchange. It
# arrives as a dependency of git in the node base image. Nothing in the
# application shells out to ssh or scp. amd64 does not ship it at all.
CVE-2026-60002
# libde265-0 1.0.11, shadowed. This one used to be genuinely reachable: every
# .heic upload goes decodeHeic to heif-dec to libde265, which put an attacker's
# HEVC bitstream into Debian's 1.0.11 and its twelve open advisories. The
# Dockerfile now builds libde265 1.1.1 from source into the same prefix as
# libheif and asserts at build time that libheif linked it, so the copy that
# receives those bytes is the fixed line. Verified: ldd on
# /usr/local/lib/libheif.so resolves libde265.so.0 to /usr/local/lib. The distro
# package remains installed as a dependency of the distro libheif1 and cannot be
# purged without removing ImageMagick.
CVE-2026-33164
# libaom3, heap overflows on frame size change. The only ELF in the image with a
# DT_NEEDED on libaom.so.3 is the distro libheif.so.1.15.1, itself shadowed. Our
# libheif is configured WITH_AOM_DECODER=OFF, WITH_AOM_ENCODER=OFF and
# WITH_DAV1D=OFF, the /usr/local/lib/libheif plugin directory is empty, and
# ffmpeg is a self-contained static binary. LD_DEBUG=libs across the HEIC, AVIF,
# JXL, EXR and RAW paths never initialises libaom. AVIF still works because
# Sharp's libvips decodes it natively, which is the primary path anyway.
# CVE-2023-6879 and CVE-2023-39616 are fixed in trixie; the 2026-562xx set is
# vulnerable in bookworm, trixie and sid alike.
CVE-2023-6879
CVE-2023-39616
CVE-2026-56208
CVE-2026-56209
CVE-2026-56210
CVE-2026-56211
+46 -5
View File
@@ -169,30 +169,59 @@ FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae
ARG TARGETARCH
FROM libheif-base-${TARGETARCH} AS libheif-builder
ARG LIBHEIF_VERSION=1.21.2
ARG LIBHEIF_VERSION=1.23.1
ARG LIBDE265_VERSION=1.1.1
RUN apt-get update && apt-get install -y --no-install-recommends \
cmake pkg-config gcc g++ make curl ca-certificates \
libde265-dev libx265-dev libjpeg-dev libpng-dev \
libx265-dev libjpeg-dev libpng-dev \
&& rm -rf /var/lib/apt/lists/*
# libde265 is the HEVC decoder libheif hands every .heic and .heif upload to, so
# attacker-controlled bitstreams reach it directly through heif-dec (decodeHeic in
# apps/api/src/lib/heic-converter.ts). Debian 12 ships 1.0.11, which the tracker
# marks vulnerable to twelve unfixed advisories, mostly heap overflows, and
# trixie's 1.0.15 is still vulnerable to most of them. Upstream cleared them in
# the 1.1.x line, so build that from source into the same prefix as libheif and
# let LD_LIBRARY_PATH shadow the distro copy. Built BEFORE libheif so libheif
# links this one rather than the distro headers.
RUN curl --fail --location --silent --show-error --retry 3 \
--proto '=https' --tlsv1.2 \
--output /tmp/libde265.tar.gz \
"https://github.com/strukturag/libde265/releases/download/v${LIBDE265_VERSION}/libde265-${LIBDE265_VERSION}.tar.gz" \
&& printf '%s %s\n' \
"fd48a927e94ed74fc7ce8829d222b9d8599fcbfe8b6448ba66705babc56ab219" \
/tmp/libde265.tar.gz | sha256sum --check --strict - \
&& tar -xzf /tmp/libde265.tar.gz \
&& cmake -B build-de265 -S "libde265-${LIBDE265_VERSION}" \
-DCMAKE_INSTALL_PREFIX=/opt/libheif \
-DBUILD_SHARED_LIBS=ON \
-DENABLE_SDL=OFF \
&& cmake --build build-de265 -j$(nproc) \
&& cmake --install build-de265
RUN curl --fail --location --silent --show-error --retry 3 \
--proto '=https' --tlsv1.2 \
--output /tmp/libheif.tar.gz \
"https://github.com/strukturag/libheif/releases/download/v${LIBHEIF_VERSION}/libheif-${LIBHEIF_VERSION}.tar.gz" \
&& printf '%s %s\n' \
"75f530b7154bc93e7ecf846edfc0416bf5f490612de8c45983c36385aa742b42" \
"0de0327f60fcd47de90d5654c6fe152232738d60d84fe084ec3e0f35e03b166a" \
/tmp/libheif.tar.gz | sha256sum --check --strict - \
&& tar -xzf /tmp/libheif.tar.gz \
&& cmake -B build -S "libheif-${LIBHEIF_VERSION}" \
&& PKG_CONFIG_PATH=/opt/libheif/lib/pkgconfig \
cmake -B build -S "libheif-${LIBHEIF_VERSION}" \
-DCMAKE_INSTALL_PREFIX=/opt/libheif \
-DCMAKE_PREFIX_PATH=/opt/libheif \
-DWITH_EXAMPLES=ON \
-DWITH_GDK_PIXBUF=OFF \
-DWITH_AOM_DECODER=OFF \
-DWITH_AOM_ENCODER=OFF \
-DWITH_DAV1D=OFF \
&& cmake --build build -j$(nproc) \
&& cmake --install build
&& cmake --install build \
&& test -e /opt/libheif/lib/libde265.so \
&& readelf -d /opt/libheif/lib/libheif.so | grep -q 'NEEDED.*libde265' \
&& LD_LIBRARY_PATH=/opt/libheif/lib /opt/libheif/bin/heif-dec --version
# ============================================
# Stage 2c: Build LibRaw (camera RAW decoder)
@@ -546,7 +575,19 @@ RUN pnpm --filter @snapotter/api exec playwright install chromium --with-deps &&
# Remove build-time package managers and native build headers from the runtime
# image after all dependency/browser installs are complete.
#
# xvfb and wget are not build tooling, they are dead weight that carries CVEs.
# xvfb arrives because `playwright install --with-deps` installs its "tools"
# group unconditionally, and nothing here ever starts a display server: the one
# browser launch is headless, LibreOffice always runs --headless, and nothing
# sets DISPLAY. wget comes from the node base image and has no installed reverse
# dependency; outbound HTTP is Node's undici and Python's urllib. openssh-client
# arrives via git, which only Recommends it, so it drops without taking git.
# Between them that removes 12 packages and their entire CVE contribution.
RUN apt-get purge -y --auto-remove \
xvfb \
wget \
openssh-client \
autotools-dev \
dpkg-dev \
gcc \
+27 -5
View File
@@ -8,30 +8,52 @@
# ============================================
FROM node:22-bookworm@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37 AS libheif-builder
ARG LIBHEIF_VERSION=1.21.2
ARG LIBHEIF_VERSION=1.23.1
ARG LIBDE265_VERSION=1.1.1
RUN apt-get update && apt-get install -y --no-install-recommends \
cmake pkg-config gcc g++ make curl ca-certificates \
libde265-dev libx265-dev libjpeg-dev libpng-dev \
libx265-dev libjpeg-dev libpng-dev \
&& rm -rf /var/lib/apt/lists/*
# Kept in step with docker/Dockerfile: the test image has to exercise the same
# codec stack that ships, or the docker suite validates decoders we do not use.
RUN curl --fail --location --silent --show-error --retry 3 \
--proto '=https' --tlsv1.2 \
--output /tmp/libde265.tar.gz \
"https://github.com/strukturag/libde265/releases/download/v${LIBDE265_VERSION}/libde265-${LIBDE265_VERSION}.tar.gz" \
&& printf '%s %s\n' \
"fd48a927e94ed74fc7ce8829d222b9d8599fcbfe8b6448ba66705babc56ab219" \
/tmp/libde265.tar.gz | sha256sum --check --strict - \
&& tar -xzf /tmp/libde265.tar.gz \
&& cmake -B build-de265 -S "libde265-${LIBDE265_VERSION}" \
-DCMAKE_INSTALL_PREFIX=/opt/libheif \
-DBUILD_SHARED_LIBS=ON \
-DENABLE_SDL=OFF \
&& cmake --build build-de265 -j$(nproc) \
&& cmake --install build-de265
RUN curl --fail --location --silent --show-error --retry 3 \
--proto '=https' --tlsv1.2 \
--output /tmp/libheif.tar.gz \
"https://github.com/strukturag/libheif/releases/download/v${LIBHEIF_VERSION}/libheif-${LIBHEIF_VERSION}.tar.gz" \
&& printf '%s %s\n' \
"75f530b7154bc93e7ecf846edfc0416bf5f490612de8c45983c36385aa742b42" \
"0de0327f60fcd47de90d5654c6fe152232738d60d84fe084ec3e0f35e03b166a" \
/tmp/libheif.tar.gz | sha256sum --check --strict - \
&& tar -xzf /tmp/libheif.tar.gz \
&& cmake -B build -S "libheif-${LIBHEIF_VERSION}" \
&& PKG_CONFIG_PATH=/opt/libheif/lib/pkgconfig \
cmake -B build -S "libheif-${LIBHEIF_VERSION}" \
-DCMAKE_INSTALL_PREFIX=/opt/libheif \
-DCMAKE_PREFIX_PATH=/opt/libheif \
-DWITH_EXAMPLES=ON \
-DWITH_GDK_PIXBUF=OFF \
-DWITH_AOM_DECODER=OFF \
-DWITH_AOM_ENCODER=OFF \
-DWITH_DAV1D=OFF \
&& cmake --build build -j$(nproc) \
&& cmake --install build
&& cmake --install build \
&& test -e /opt/libheif/lib/libde265.so \
&& readelf -d /opt/libheif/lib/libheif.so | grep -q 'NEEDED.*libde265'
# ============================================
# Stage 2: Test runner
+21 -1
View File
@@ -67,7 +67,13 @@ describe("release supply-chain closure", () => {
expect(release).toContain('git checkout --detach "${release_commit}"');
expect(release).toContain("node scripts/manage-release-notes.mjs materialize");
expect(release).toContain('"${VERSION}" /tmp/release-notes.md');
expect(release).toContain('grep -Fq "(HTTP 404)" /tmp/release.error');
// The release is created as a draft (draftRelease: true), and GitHub's
// /releases/tags/{tag} endpoint does not return drafts. Recovery therefore
// keys off the release id failing to resolve, not off a 404 from a tag
// lookup that can never succeed here.
expect(release).toContain('gh release view "v${VERSION}"');
expect(release).toContain("--json databaseId");
expect(release).toContain('if [[ ! "${release_id}" =~ ^[0-9]+$ ]]; then');
expect(release).toContain('gh release create "v${VERSION}"');
expect(release).toContain("--draft");
expect(release).toContain("--verify-tag");
@@ -77,6 +83,20 @@ describe("release supply-chain closure", () => {
expect(release).not.toContain("HEAD:main");
});
it("never resolves a drafted release through the tag endpoint", () => {
// Regression guard. GET /repos/{owner}/{repo}/releases/tags/{tag} returns 404
// for a draft, verified against this repo. Every tag lookup in this workflow
// ran against the draft semantic-release had just created, so the release job
// died immediately after pushing the tag. Resolve the numeric id with
// `gh release view` (which reads drafts) and call /releases/{id} instead.
const workflow = readRequired(releaseWorkflowPath);
const tagLookups = workflow
.split("\n")
.filter((line) => !line.trimStart().startsWith("#"))
.filter((line) => /gh api\b[^\n]*releases\/tags\//.test(line));
expect(tagLookups).toEqual([]);
});
it("eliminates the arbitrary manual attestation workflow", () => {
expect(existsSync(manualAttestationPath)).toBe(false);
});
@@ -102,9 +102,18 @@ describe("Dockerfile build args", () => {
["Dockerfile.test", dockerfileTest],
] as const) {
expect(source, `${name} must pin libheif bytes`).toContain(
"75f530b7154bc93e7ecf846edfc0416bf5f490612de8c45983c36385aa742b42",
"0de0327f60fcd47de90d5654c6fe152232738d60d84fe084ec3e0f35e03b166a",
);
// libde265 is built from source too: Debian 12's 1.0.11 is the decoder a
// .heic upload actually reaches, and it carries twelve unfixed advisories.
expect(source, `${name} must pin libde265 bytes`).toContain(
"fd48a927e94ed74fc7ce8829d222b9d8599fcbfe8b6448ba66705babc56ab219",
);
expect(source, `${name} must verify downloads`).toContain("sha256sum --check --strict");
// The source build is worthless if libheif silently links the distro copy.
expect(source, `${name} must prove libheif linked the source libde265`).toContain(
"readelf -d /opt/libheif/lib/libheif.so | grep -q 'NEEDED.*libde265'",
);
}
for (const digest of [