mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user