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:
parent
80d646fc17
commit
2bf49f10a7
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue