Security: verify AppImage build inputs (#2270)

* security: pin and verify release build inputs

* docs: explain verified offline AppImage inputs

* security: validate executable AppImage tooling offline

* security: preserve read-only verified AppImage caches

* test: cover verified runtime downloads and cache diagnostics

* test: assert verified runtime mode and cache diagnostics

* Fix shellcheck ordering in verified download test

* Use external chmod for cache test setup
This commit is contained in:
skylar taylor-barrick 2026-10-11 22:33:26 -06:00 committed by GitHub
parent 80d646fc17
commit 2bf49f10a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 165 additions and 11 deletions

View File

@ -21,3 +21,10 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
verified-appimage-inputs:
name: Verified AppImage inputs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash packaging/linux/verified-download-test.sh

View File

@ -63,7 +63,7 @@ Every binary reports its version, the commit and the build date: `photocraft --v
`cargo build` doesn't set them and reports `0.2.0 (dev build)`.
Every desktop build job (macOS, Windows, Linux, FreeBSD) also checks out [craft-fonts](https://github.com/storytold/craft-fonts)
at the commit in `CRAFT_FONTS_REF` (top of `release.yml`) and builds with `CRAFT_FONTS_DIR` and
at the `craft-fonts-ref` commit in `.github/workflows/release.yml` and builds with `CRAFT_FONTS_DIR` and
`CRAFT_FONTS_REQUIRED=1`, so desktop releases embed its Japanese fonts (the web build embeds none: see
`docs/development.md` › Fonts) and fail rather than ship without them. The packages carry each font's licence as
`OFL-<family>.txt` (`copy_font_licences` in `packaging/env.sh`; the portable zip on Windows;
@ -183,6 +183,13 @@ Why these formats:
- **AppImage** runs on any distribution without installing anything. The
download-and-go option and the fallback for distros the packages don't cover. It's built
with the maintained `AppImage/appimagetool`, whose static runtime doesn't need libfuse2.
The packaging script verifies pinned AppImageTool 1.9.1 and type2 runtime 20251108 binaries.
For an offline build, pre-seed `$CARGO_TARGET_DIR/appimagetool-1.9.1-$ARCH.AppImage` and
`$CARGO_TARGET_DIR/type2-runtime-20251108-$ARCH` with the matching official release assets;
the script checks both SHA-256 digests and makes the cached AppImageTool executable after verification;
the runtime is only read as data.
An `APPIMAGETOOL` override must point to that same verified AppImageTool binary and already be
executable (`chmod +x` it before packaging).
Each AppImage embeds update information (`gh-releases-zsync|…|latest|…`, #349) and ships
with a `.zsync` beside it, so AppImageUpdate, AppImageLauncher and similar tools can find
the next release and download only the blocks that changed.

View File

@ -9,8 +9,8 @@
#
# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"]
#
# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com). By default AppImage tooling
# is downloaded at verified pins into $CARGO_TARGET_DIR. Build on an old distro (CI: Ubuntu 22.04,
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli,
# zsyncmake (the zsync package) for the AppImage's .zsync.
#
@ -21,6 +21,8 @@ set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/linux"
# shellcheck source=verified-download.sh
. "$HERE/verified-download.sh"
APP_ID=ai.storyteller.photocraft
SKIP_BUILD=0
@ -115,19 +117,42 @@ if has appimage; then
cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
if [ -z "$TOOL" ]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
# Digests from the official GitHub release asset metadata:
# https://github.com/AppImage/appimagetool/releases/tag/1.9.1
# https://github.com/AppImage/type2-runtime/releases/tag/20251108
# Keep both pins together:
# appimagetool otherwise downloads the mutable latest type2 runtime itself.
TOOL_VERSION=1.9.1
RUNTIME_VERSION=20251108
case "$ARCH" in
x86_64)
TOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0
RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d
;;
aarch64)
TOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158
RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444
;;
esac
mkdir -p "$CARGO_TARGET_DIR"
TOOL="${APPIMAGETOOL:-$CARGO_TARGET_DIR/appimagetool-$TOOL_VERSION-$ARCH.AppImage}"
if [ -n "${APPIMAGETOOL:-}" ]; then
verify_appimagetool_override "$TOOL" "$TOOL_SHA256" "$TOOL_VERSION" "$ARCH"
else
download_verified \
"https://github.com/AppImage/appimagetool/releases/download/$TOOL_VERSION/appimagetool-$ARCH.AppImage" \
"$TOOL" "$TOOL_SHA256" true
fi
RUNTIME="$CARGO_TARGET_DIR/type2-runtime-$RUNTIME_VERSION-$ARCH"
download_verified \
"https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-$ARCH" \
"$RUNTIME" "$RUNTIME_SHA256"
# Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be
# relative, e.g. target/agent-<name>).
OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
APPDIR="$(cd "$APPDIR" && pwd)"
TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")"
RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")"
# A .zsync left by an earlier run would hide a missing zsyncmake and describe another file.
rm -f "$OUT.zsync"
# Update information (#349): AppImageUpdate, AppImageLauncher and the like read it from the
@ -139,7 +164,8 @@ if has appimage; then
# Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime,
# so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync
# package) appimagetool also writes the .zsync, into its working directory, hence the cd.
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT")
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream \
--runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT")
echo "wrote $OUT"
if [ -s "$OUT.zsync" ]; then
echo "wrote $OUT.zsync"

View File

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Fast, network-free checks of the verified AppImage tooling cache and override.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=verified-download.sh
. "$HERE/verified-download.sh"
sha256() {
if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
}
fail() { echo "FAIL: $*" >&2; exit 1; }
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
source_asset="$TMP/source.AppImage"
cache="$TMP/cached.AppImage"
printf '#!/bin/sh\nexit 0\n' >"$source_asset"
expected="$(sha256 "$source_asset")"
# Setup uses the external binary because failure-path tests below shadow the chmod function.
# Offline preseed: a valid 0644 cache must not try the unavailable URL and must become runnable.
cp "$source_asset" "$cache"
/bin/chmod 644 "$cache"
download_verified "file://$TMP/unavailable" "$cache" "$expected" true || fail "offline cache was not reused"
[ -x "$cache" ] || fail "valid preseed did not become executable"
# If a valid but non-executable cache cannot be chmod'd, fail here rather than at invocation.
/bin/chmod 644 "$cache"
chmod() { return 1; }
if err="$(download_verified "file://$TMP/unavailable" "$cache" "$expected" true 2>&1)"; then fail "unfixable cache was accepted"; fi
case "$err" in *"verified asset is not executable and cannot be chmod'd: $cache"*) ;; *) fail "wrong cache error: $err" ;; esac
unset -f chmod
/bin/chmod 755 "$cache"
# A verified, executable cache can be owned by another user; do not try to chmod it again.
chmod() { fail "chmod called for an already executable cache"; }
download_verified "file://$TMP/unavailable" "$cache" "$expected" true || fail "executable cache was not reused"
unset -f chmod
# The type2 runtime is read as data; a valid 0644 preseed must remain usable as-is.
runtime="$TMP/runtime"
cp "$source_asset" "$runtime"
/bin/chmod 644 "$runtime"
chmod() { fail "chmod called for a readable runtime cache"; }
download_verified "file://$TMP/unavailable" "$runtime" "$expected" || fail "readable runtime was not reused"
unset -f chmod
[ ! -x "$runtime" ] || fail "runtime was needlessly made executable"
# A fresh runtime download is verified, readable data rather than an executable program.
fresh_runtime="$TMP/fresh-runtime"
download_verified "file://$source_asset" "$fresh_runtime" "$expected" || fail "fresh runtime download failed"
cmp -s "$source_asset" "$fresh_runtime" || fail "fresh runtime differs from verified asset"
[ -r "$fresh_runtime" ] || fail "fresh runtime is not readable"
[ ! -x "$fresh_runtime" ] || fail "fresh runtime was made executable"
mode="$(stat -c %a "$fresh_runtime" 2>/dev/null || stat -f %Lp "$fresh_runtime")"
[ "$mode" = 644 ] || fail "fresh runtime mode is $mode, expected 644"
# A corrupt cache is replaced only by bytes with the pinned digest.
printf 'corrupt' >"$cache"
download_verified "file://$source_asset" "$cache" "$expected" true || fail "corrupt cache was not replaced"
cmp -s "$source_asset" "$cache" || fail "replacement differs from verified asset"
[ -x "$cache" ] || fail "replacement is not executable"
wrong_digest="$(printf '%064d' 0)"
bad="$TMP/bad.AppImage"
if download_verified "file://$source_asset" "$bad" "$wrong_digest" >/dev/null 2>&1; then fail "wrong digest accepted"; fi
[ ! -e "$bad" ] || fail "wrong-digest download left a cache entry"
verify_appimagetool_override "$cache" "$expected" 1.9.1 x86_64 || fail "valid override rejected"
/bin/chmod 644 "$cache"
if verify_appimagetool_override "$cache" "$expected" 1.9.1 x86_64 >/dev/null 2>&1; then fail "non-executable override accepted"; fi
/bin/chmod 755 "$cache"
if verify_appimagetool_override "$cache" "$wrong_digest" 1.9.1 x86_64 >/dev/null 2>&1; then fail "wrong-digest override accepted"; fi
echo "verified AppImage tooling ok"

View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Source after packaging/env.sh, which supplies sha256(). Keep the verified cache usable when
# an official asset was pre-seeded by a tool that created it without an execute bit.
download_verified() {
local url="$1" dest="$2" expected="$3" executable="${4:-false}" tmp
if [ -f "$dest" ] && [ "$(sha256 "$dest")" = "$expected" ]; then
if [ "$executable" = true ] && [ ! -x "$dest" ] && ! chmod 755 "$dest"; then
echo "error: verified asset is not executable and cannot be chmod'd: $dest" >&2
return 1
fi
return 0
fi
tmp="$(mktemp "$dest.XXXXXX")"
if ! curl -fsSL -o "$tmp" "$url"; then rm -f "$tmp"; return 1; fi
if [ "$(sha256 "$tmp")" != "$expected" ]; then
echo "error: SHA-256 mismatch for $url" >&2
rm -f "$tmp"
return 1
fi
if [ "$executable" = true ]; then
if ! chmod 755 "$tmp"; then rm -f "$tmp"; return 1; fi
else
if ! chmod 644 "$tmp"; then rm -f "$tmp"; return 1; fi
fi
if ! mv -f "$tmp" "$dest"; then rm -f "$tmp"; return 1; fi
}
verify_appimagetool_override() {
local tool="$1" expected="$2" version="$3" arch="$4"
if [ ! -f "$tool" ] || [ "$(sha256 "$tool")" != "$expected" ]; then
echo "error: APPIMAGETOOL must be the verified $version $arch release asset" >&2
return 1
fi
if [ ! -x "$tool" ]; then
echo "error: APPIMAGETOOL must be executable (chmod +x $tool)" >&2
return 1
fi
}