From a6139698c01df96eaf068dcafeb97c6c9f1c1fb8 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:53:16 +0100 Subject: [PATCH] ci: close four release-integrity gaps Deno was fetched from releases/latest/download with no version pin and no checksum, into every published GHCR image. It is the JS runtime yt-dlp feeds YouTube's challenge payload to, so it executes against untrusted input, and every build took whatever Deno published that day. The desktop packaging scripts already pin and verify QuickJS with the rationale written out in a comment -- Docker was the one path that did not follow it. Now pinned to v2.9.6 with a per-arch SHA256 verified before the zip is unpacked. linux-release.yml and windows-release.yml uploaded with action-gh-release, whose fail_on_unmatched_files defaults to false, and neither asserted the files existed first -- macOS is the only path that did. A missing updater asset therefore published a release that went green while the in-app updater 404'd for every installed user. Both now check every asset before uploading and set fail_on_unmatched_files. make-portable.ps1 set $PSNativeCommandErrorActionPreference, which exists only in PowerShell 7+, while CI invokes it with `powershell` -- Windows PowerShell 5.1, where it does nothing and $ErrorActionPreference does not cover native commands either. A failed CPU-torch --force-reinstall was ignored, leaving whatever torch was already resolved in place, and the zip labelled CPU shipped a non-CPU torch; the later import checks still passed because torch imports fine either way. Assert-LastExitCode now guards the venv creation and all three pip installs. The preference stays for a pwsh run. Switching the invocation to pwsh would have been the tidier fix, but nothing in this repo uses pwsh and the runner is self-hosted, so there is no way to confirm PowerShell 7 is installed there. An exit-code check works on both. trivy-action was pinned to @master, the only unpinned action in the repo while everything else is SHA-pinned. Now on v0.36.0's commit. Both Deno hashes were verified against the published artifacts, including a negative control confirming a wrong hash is rejected. Refs #517 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/linux-release.yml | 19 +++++++++++++++++++ .github/workflows/windows-release.yml | 20 ++++++++++++++++++++ build/Dockerfile | 16 +++++++++++++--- scripts/windows/make-portable.ps1 | 19 +++++++++++++++++++ 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1496cde..08d74051 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,7 +178,7 @@ jobs: # would scan its bundled extractor files and flag false-positive # secrets that ship inside third-party packages like yt-dlp). - name: trivy fs - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: fs scan-ref: . @@ -190,7 +190,7 @@ jobs: skip-dirs: .venv,jobs # Dedicated Dockerfile + compose static analysis (Trivy's IaC linter). - name: trivy config - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: config scan-ref: build/ diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 5aa06590..fade993b 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -139,6 +139,24 @@ jobs: clamscan --recursive --infected --bell /scan echo "ClamAV scan completed successfully. No infected files reported." + # macos-release.yml asserts its assets exist before uploading; this did + # not. action-gh-release defaults fail_on_unmatched_files to false, so a + # missing updater asset published a release that looked fine and went + # green, and the in-app updater then 404'd for every installed user. + - name: verify every asset exists + if: github.event_name == 'release' + run: | + for f in \ + dist/StemDeck-Linux-x64.tar.gz \ + dist/StemDeck-Linux-x64.tar.gz.sha256 \ + dist/StemDeck-Linux-x64.NVIDIA.tar.gz \ + dist/StemDeck-Linux-x64.NVIDIA.tar.gz.sha256 \ + dist/StemDeck-Linux-x64-app.tar.gz \ + dist/StemDeck-Linux-x64-app.tar.gz.sha256 \ + dist/StemDeck-Linux-x64-runtime-version.json; do + test -f "$f" || { echo "missing release asset: $f" >&2; exit 1; } + done + - name: upload artifacts # Only attach to a real release; a manual test build has nothing to upload to. if: github.event_name == 'release' @@ -150,6 +168,7 @@ jobs: # pushing :latest to GHCR, and makes the in-app updater offer a build # that was never verified. prerelease: ${{ github.event.release.prerelease }} + fail_on_unmatched_files: true files: | dist/StemDeck-Linux-x64.tar.gz dist/StemDeck-Linux-x64.tar.gz.sha256 diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 70883061..6103a65b 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -92,6 +92,25 @@ jobs: } Write-Host "ClamAV scan completed successfully. No infected files reported." + # See the same guard in linux-release.yml: action-gh-release silently + # tolerates missing files, so an absent updater asset shipped a green + # release the in-app updater could not use. + - name: verify every asset exists + shell: powershell + run: | + $required = @( + "dist/StemDeck-Windows-x64.NVIDIA.zip", + "dist/StemDeck-Windows-x64.NVIDIA.zip.sha256", + "dist/StemDeck-Windows-x64.zip", + "dist/StemDeck-Windows-x64.zip.sha256", + "dist/StemDeck-Windows-x64-app.zip", + "dist/StemDeck-Windows-x64-app.zip.sha256", + "dist/StemDeck-Windows-x64-runtime-version.json" + ) + foreach ($f in $required) { + if (-not (Test-Path $f)) { Write-Error "missing release asset: $f"; exit 1 } + } + - name: upload artifacts uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: @@ -101,6 +120,7 @@ jobs: # pushing :latest to GHCR, and makes the in-app updater offer a build # that was never verified. prerelease: ${{ github.event.release.prerelease }} + fail_on_unmatched_files: true files: | dist/StemDeck-Windows-x64.NVIDIA.zip dist/StemDeck-Windows-x64.NVIDIA.zip.sha256 diff --git a/build/Dockerfile b/build/Dockerfile index 5405f55c..cf0c38db 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -29,13 +29,23 @@ RUN apt-get update \ # deno -- yt-dlp uses it as the JS runtime for YouTube format # extraction. Staged here so the runner doesn't need curl/unzip. +# Pinned and checksummed, not "latest". Deno is the JS runtime yt-dlp feeds +# YouTube's challenge payload to, so it executes against untrusted input -- +# and an unverified binary fetched at build time is a supply-chain hole +# whether or not it is small. The desktop packaging scripts already pin and +# verify QuickJS for exactly this reason; this was the one path that did not. +# Bump DENO_VERSION and both hashes together. ARG TARGETARCH +ARG DENO_VERSION=v2.9.6 +ARG DENO_SHA256_AMD64=394f07f4da2bebe6ce6f1e7ce0fa16429b29b08c35e3fac3fe25972676dff4b2 +ARG DENO_SHA256_ARM64=9a46afc6c392c7cd2ff71a31558935545b46408d0e87f7a86908c712721c046e RUN case "${TARGETARCH:-$(dpkg --print-architecture)}" in \ - amd64) DENO_ARCH=x86_64-unknown-linux-gnu ;; \ - arm64) DENO_ARCH=aarch64-unknown-linux-gnu ;; \ + amd64) DENO_ARCH=x86_64-unknown-linux-gnu; DENO_SHA256="${DENO_SHA256_AMD64}" ;; \ + arm64) DENO_ARCH=aarch64-unknown-linux-gnu; DENO_SHA256="${DENO_SHA256_ARM64}" ;; \ *) echo "Unsupported arch: ${TARGETARCH}" && exit 1 ;; \ esac \ - && curl -fsSL -o /tmp/deno.zip "https://github.com/denoland/deno/releases/latest/download/deno-${DENO_ARCH}.zip" \ + && curl -fsSL -o /tmp/deno.zip "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-${DENO_ARCH}.zip" \ + && echo "${DENO_SHA256} /tmp/deno.zip" | sha256sum -c - \ && unzip /tmp/deno.zip -d /usr/local/bin \ && rm /tmp/deno.zip \ && /usr/local/bin/deno --version diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 index 4288810c..759fd2cf 100644 --- a/scripts/windows/make-portable.ps1 +++ b/scripts/windows/make-portable.ps1 @@ -9,7 +9,19 @@ param( ) $ErrorActionPreference = "Stop" +# PowerShell 7+ only. CI invokes this script with `powershell` (Windows +# PowerShell 5.1), where this variable does nothing and $ErrorActionPreference +# does not cover native commands either -- so a failed pip install was ignored +# and the build carried on. Kept for a pwsh run; Assert-LastExitCode below is +# what actually enforces it on 5.1 (#517). $PSNativeCommandErrorActionPreference = "Stop" + +function Assert-LastExitCode { + param([Parameter(Mandatory)][string]$What) + if ($LASTEXITCODE -ne 0) { + throw "$What failed with exit code $LASTEXITCODE" + } +} Set-StrictMode -Version Latest if ($env:OS -ne "Windows_NT") { @@ -231,8 +243,10 @@ if (Get-Command "py" -ErrorAction SilentlyContinue) { } else { & python -m venv $PythonDir } +Assert-LastExitCode "creating the virtualenv" & $PythonExe -m pip install --upgrade pip +Assert-LastExitCode "pip self-upgrade" # The project version is git-derived (hatch-vcs). Pin it from $PackageVersion so # the install doesn't depend on git tags in the build checkout (#169). @@ -240,6 +254,7 @@ if ($PackageVersion) { $env:SETUPTOOLS_SCM_PRETEND_VERSION = ($PackageVersion -replace '^v', '') } & $PythonExe -m pip install "$Root" +Assert-LastExitCode "installing the StemDeck package" if ($CpuOnly) { # Force the slim CPU-only wheel. On Windows the default PyPI torch wheel is @@ -249,6 +264,10 @@ if ($CpuOnly) { & $PythonExe -m pip install torch==2.6.0+cpu torchaudio==2.6.0+cpu ` --index-url https://download.pytorch.org/whl/cpu ` --force-reinstall --no-deps + # Unchecked, a transient network failure here left whatever torch was already + # resolved in place and the zip labelled CPU shipped a non-CPU torch. The + # import checks later still pass, because torch imports fine either way. + Assert-LastExitCode "installing the CPU-only torch wheel" } # Do NOT bundle CUDA torch into the NVIDIA (non-CpuOnly) package. It ships base # torch and the desktop app installs the CUDA build on first run via