From f6653e6a8b3abded30993191d2b45ea3ebbebeae Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 18:25:38 +0200 Subject: [PATCH] ci(security): add a supply-chain gate, checksums and an SBOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project shipped signed Android builds and unsigned desktop binaries with no vulnerability scanning of any kind. Nothing checked the ~500 crate Rust graph or the JS packages against an advisory feed, and nothing checked that what we redistribute inside an MIT bundle permits it. The first cargo-deny run found eight vulnerabilities and one unsoundness -- bytes, four in rustls-webpki, time, two in quick-xml and rand -- every one of them closed by a `cargo update` nobody had a reason to run. That update is in this commit; 740 Rust tests and clippy -D warnings pass on the new lockfile. Two structural fixes matter as much as the gate itself: - deny.toml scopes the graph to the targets we actually ship. Without it the Apple targets pull in plist -> quick-xml and report two DoS advisories against a crate that is in no binary we release. Ignoring those by ID would silence them everywhere, including where they would matter; scoping makes them correctly absent. - libmpv is pinned by rev instead of branch = "master". A branch means the revision is whatever Cargo.lock happens to hold and any `cargo update` silently substitutes new upstream code -- in the one dependency that is not from crates.io and that links a C library into the player. The rev is the commit already locked, so this pins current behaviour rather than changing it. Licence findings are recorded rather than waved through. libmpv and libmpv-sys are LGPL-2.1, satisfied here by dynamic linking against the system library; deny.toml carries the two obligations that follow (keep the linkage dynamic, ship libmpv's licence text with any bundle carrying the .so). MPL-2.0 crates are file-level copyleft and fine unmodified. Releases now publish SHA256SUMS (verified in-job with `sha256sum -c` before upload) and a CycloneDX SBOM for both halves, so "does this release contain ?" has an answer that is not "rebuild the tag and re-resolve it". Workflows pin jellytau-builder:2026.08 instead of :latest. While every job said :latest, rebuilding the image changed what every build compiled against, including rebuilds of old release tags. Also folded in, because both were the same class of problem: - publish-docs.yml downloaded mdBook from GitHub releases into /usr/local/bin at job time -- a toolchain install in CI, which CLAUDE.md explicitly forbids, and a hard dependency on GitHub's CDN at publish time. It is in the builder image now. - extract-traces.ts only ever read .ts/.svelte/.rs, so every requirement implemented by *configuration* was invisible to the matrix that measures it. DR-205, DR-206, DR-207 and DR-215 all carry TRACES comments nothing read, and each counted as uncovered while being covered. Coverage was really 90%, not 88%; MIN_THRESHOLD moves to 89 accordingly. CI workflows stay excluded and there is a test saying why: traceability-check.yml quotes "a TRACES: comment" beside deliberately-undefined example IDs, which the extractor would read as real traces and then fail its own dangling-ID check. Supply-chain requirement is DR-216. 🔴 The builder image must be rebuilt and pushed (scripts/build-builder-image.sh 2026.08) before this reaches master -- the workflows now name a tag and tools that do not exist in the registry yet. --- .gitea/workflows/build-and-test.yml | 61 +++++++- .gitea/workflows/build-release.yml | 51 ++++++- .gitea/workflows/publish-docs.yml | 17 ++- .gitea/workflows/traceability-check.yml | 4 +- CLAUDE.md | 2 +- Dockerfile.builder | 23 +++ docs/requirements.md | 1 + scripts/build-builder-image.sh | 29 +++- scripts/extract-traces.test.ts | 47 +++++++ scripts/extract-traces.ts | 93 +++++++++++-- src-tauri/Cargo.lock | 46 +++--- src-tauri/Cargo.toml | 12 +- src-tauri/deny.toml | 178 ++++++++++++++++++++++++ 13 files changed, 504 insertions(+), 60 deletions(-) create mode 100644 src-tauri/deny.toml diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index c7079fb4..9c013c25 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -28,7 +28,7 @@ jobs: if: "!startsWith(github.event.head_commit.message, 'chore(release)')" runs-on: linux/amd64 container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository @@ -156,7 +156,7 @@ jobs: runs-on: linux/amd64 needs: test container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 env: ANDROID_HOME: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk @@ -208,3 +208,60 @@ jobs: export AR_aarch64_linux_android="$TC/llvm-ar" cd src-tauri cargo check --target aarch64-linux-android --lib + + # Supply-chain gate. Until this job existed the project had no vulnerability + # scanning of any kind: nothing checked the ~500-crate Rust graph or the JS + # dependencies against a CVE feed, and nothing checked that everything we + # redistribute is licence-compatible with shipping JellyTau under MIT. + # + # The first run of this found eight vulnerabilities and one unsoundness + # (bytes, four in rustls-webpki, time, two in quick-xml, rand) — all fixed by + # `cargo update`, none of which anybody had reason to run. + # + # Runs in parallel with android-check rather than after `test`: a dependency + # advisory has nothing to do with whether the tests pass, and finding out + # sooner is the point. + security: + name: Supply Chain + runs-on: linux/amd64 + container: + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Cache Rust dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + # cargo-deny is baked into the builder image. It fetches the RustSec + # advisory database at run time — that is *data*, like the crates + # `bun install` fetches, not a toolchain install, so the 🔴 rule in + # CLAUDE.md is not in play here. + # + # Config and every documented exception live in src-tauri/deny.toml. + # Vulnerabilities and unsoundness are hard failures with no override; + # unmaintained transitive crates that have no safe upgrade (Tauri's GTK3 + # stack, the unic-* tables) are ignored there by ID, each with a reason. + - name: cargo-deny (advisories, licences, bans, sources) + run: | + cd src-tauri + cargo deny check + + # Advisory for now, deliberately. The Rust graph was clean after one + # update pass, so gating it costs nothing; the JS graph has not been + # audited before and a first run that fails the build teaches everyone to + # ignore this job. Promote to a hard gate once the output is empty and + # stays empty — same approach that got clippy from advisory to -D warnings. + - name: bun audit (advisory) + run: | + bun install + bun audit || echo "::warning::bun audit reported findings — advisory for now, see CLAUDE.md" diff --git a/.gitea/workflows/build-release.yml b/.gitea/workflows/build-release.yml index 95d2d1c1..affbb855 100644 --- a/.gitea/workflows/build-release.yml +++ b/.gitea/workflows/build-release.yml @@ -21,7 +21,7 @@ jobs: name: Run Tests runs-on: linux/amd64 container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -94,7 +94,7 @@ jobs: runs-on: linux/amd64 needs: test container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -190,7 +190,7 @@ jobs: # baked into the builder image. No toolchain installs here — the image has # cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target. container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -260,7 +260,7 @@ jobs: runs-on: linux/amd64 needs: test container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 env: ANDROID_HOME: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk @@ -359,7 +359,7 @@ jobs: needs: [build-linux, build-windows, build-android] if: startsWith(github.ref, 'refs/tags/v') container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -388,6 +388,42 @@ jobs: name: jellytau-android path: artifacts/android/ + # Software Bill of Materials, one per half of the app. Without it there is + # no answer to "does this release contain ?" other than + # rebuilding the tag and re-resolving it. cargo-cyclonedx is in the builder + # image; the JS side is read straight from the lockfile bun install used. + - name: Generate SBOM + run: | + set -e + mkdir -p artifacts/sbom + cd src-tauri + cargo cyclonedx --format json + find . -maxdepth 2 -name "*.cdx.json" -exec cp -v {} ../artifacts/sbom/ \; + cd .. + bun install --frozen-lockfile + bun pm ls --all > artifacts/sbom/frontend-dependencies.txt + ls -lah artifacts/sbom/ + + # Checksums over everything being published. A release of unsigned Linux + # and Windows binaries with no checksum gives a user no way at all to tell + # a corrupted or substituted download from a good one — and the AppImage + # and NSIS installer are both fetched over plain HTTP redirects. + # + # Written with paths relative to the asset directory so `sha256sum -c + # SHA256SUMS` works in the directory a user downloaded into. + - name: Generate SHA256SUMS + run: | + set -e + mkdir -p artifacts/release + find artifacts/linux artifacts/windows artifacts/android -type f -exec cp -v {} artifacts/release/ \; + cd artifacts/release + sha256sum * > SHA256SUMS + echo "🔐 Published checksums:" + cat SHA256SUMS + # Verify what we just wrote, so a broken checksum file fails the + # release rather than shipping and failing for users. + sha256sum -c SHA256SUMS + - name: Prepare release notes id: release_notes run: | @@ -485,7 +521,10 @@ jobs: fi echo "Release id=$RELEASE_ID" - for f in artifacts/android/* artifacts/linux/* artifacts/windows/*; do + # artifacts/release/ holds a copy of every platform artifact plus the + # SHA256SUMS generated over exactly that set, so the checksums describe + # precisely what is uploaded. artifacts/sbom/ rides along. + for f in artifacts/release/* artifacts/sbom/*; do [ -f "$f" ] || continue echo "⬆️ Uploading $(basename "$f")" curl -fsS -X POST \ diff --git a/.gitea/workflows/publish-docs.yml b/.gitea/workflows/publish-docs.yml index 6aa1b400..a740666c2 100644 --- a/.gitea/workflows/publish-docs.yml +++ b/.gitea/workflows/publish-docs.yml @@ -21,7 +21,7 @@ jobs: name: Build & publish docs to gitea-pages runs-on: linux/amd64 container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout code @@ -34,14 +34,13 @@ jobs: - name: Install dependencies run: bun install - - name: Install mdBook - run: | - set -e - MDBOOK_VERSION=v0.4.40 - URL="https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" - echo "⬇️ Downloading mdBook ${MDBOOK_VERSION}" - curl -fsSL "$URL" | tar -xz -C /usr/local/bin - mdbook --version + # mdBook is baked into jellytau-builder (Dockerfile.builder, MDBOOK_VERSION). + # It used to be curl'd from GitHub releases straight into /usr/local/bin + # right here, which was a toolchain install at job time — the exact thing + # CLAUDE.md's 🔴 rule forbids — and made every docs publish depend on + # GitHub's CDN answering. To move the version, bump it in the image. + - name: Confirm mdBook is present + run: mdbook --version - name: Regenerate traceability matrix (keep published copy current) run: bun run traces:markdown diff --git a/.gitea/workflows/traceability-check.yml b/.gitea/workflows/traceability-check.yml index ed5f6d7b..58e9562d 100644 --- a/.gitea/workflows/traceability-check.yml +++ b/.gitea/workflows/traceability-check.yml @@ -17,7 +17,7 @@ jobs: runs-on: linux/amd64 name: Check Requirement Traces container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository @@ -94,7 +94,7 @@ jobs: # # Keep in sync with MIN_COVERAGE_PERCENT in scripts/extract-traces.ts; # scripts/extract-traces.test.ts fails if the two drift apart. - MIN_THRESHOLD=88 + MIN_THRESHOLD=89 if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)" exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 8d09fa7d..be343f61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ rename that missed a call site can no longer pass silently. **CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not GitHub. `traceability-check.yml` fails the build if coverage drops below -**88%** (`MIN_THRESHOLD`, a *ratchet* — raise it as coverage climbs, never lower +**89%** (`MIN_THRESHOLD`, a *ratchet* — raise it as coverage climbs, never lower it to make a build pass) or if any traced ID is undefined; `build-and-test.yml` runs frontend tests **with coverage thresholds**, `bun run check`, `format:check`, a `--max-warnings` eslint ratchet, Rust tests, `cargo fmt --check`, `cargo clippy diff --git a/Dockerfile.builder b/Dockerfile.builder index 853b9db8..8f581d59 100644 --- a/Dockerfile.builder +++ b/Dockerfile.builder @@ -152,6 +152,29 @@ RUN . $HOME/.cargo/env && \ rustup target add x86_64-pc-windows-msvc && \ cargo install --locked cargo-xwin +# --------------------------------------------------------------------------- +# Supply-chain and docs tooling. +# +# cargo-deny — advisories/licences/bans/sources gate (src-tauri/deny.toml), +# run by the `security` job. It fetches the RustSec advisory +# database at run time; that is *data*, not a toolchain, so it +# does not breach the no-installs-in-CI rule. +# cargo-cyclonedx — SBOM for the Rust half of a release. +# mdbook — builds the docs site. It used to be curl'd from GitHub +# releases *inside* the job (publish-docs.yml), which was both a +# breach of that rule and a hard dependency on GitHub's CDN +# being up at publish time. Pinned to the version that job used. +ENV MDBOOK_VERSION=v0.4.40 +RUN . $HOME/.cargo/env && \ + cargo install --locked cargo-deny cargo-cyclonedx && \ + wget -q "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \ + -O /tmp/mdbook.tar.gz && \ + tar -xzf /tmp/mdbook.tar.gz -C /usr/local/bin && \ + rm /tmp/mdbook.tar.gz && \ + cargo deny --version && \ + cargo cyclonedx --version && \ + mdbook --version + WORKDIR /app ENTRYPOINT ["/bin/bash"] diff --git a/docs/requirements.md b/docs/requirements.md index 23f35bfe..ffa7ce13 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -405,6 +405,7 @@ Internal architecture, components, and application logic. | DR-213 | Containerised builds hand their artifacts back to the host user. The compose services bind-mount the repo and run as root — their caches live at `/root/.cargo` and `/root/.bun`, so a non-root container user cannot write them — which leaves root-owned files accumulating in the developer's working tree: 11,124 of them when this was found, enough that `cargo clean` and `scripts/clean.sh` failed with EACCES and a plain `cargo build` died part-way, since build scripts compile for the host and land in `target/debug` even during a cross-build. Ownership is restored at the end of each containerised build, reading the intended owner from the checkout so no uid needs plumbing through. Running the containers as the host uid is the tidier fix and remains open; it needs the cache volumes relocated off `/root` first | Tooling | - | Done | | DR-214 | The app identifies itself correctly everywhere a user or a package manager reads its name. `productName` was the scaffold's lowercase `jellytau`, which is what the Android release build showed under its icon and what the deb/rpm/NSIS bundles carried as their display name — invisible in development because `build.gradle.kts` overrides the label to "JellyTau Debug" for the debug build type, so the install a developer looks at daily was the only correctly-cased one. `mainBinaryName` pins the executable filename so nothing that resolves a path by name has to change. `strings.xml` moves into the canonical android tree, where `sync-android-sources.sh` already copies `res/values/*.xml`, so the fix survives regenerating `gen/`. Bundle metadata (publisher, copyright, category, descriptions, licence) was entirely absent, which is why the packages shipped with no maintainer or description — the hand-written Arch PKGBUILD and `.desktop` had all of it, so only the *generated* packaging was wrong | Packaging | - | Done | | DR-215 | Frontend test coverage is a ratcheted CI gate rather than a number nobody looks at. `test:coverage` had been configured since the suite was created and was silently broken: `@vitest/coverage-v8` resolved to 4.1.10, whose peer range pins `vitest` exactly, while `package.json` asked for `>=1.0.0 <5.0.0` and got 4.0.16 — so every invocation died on a missing `BaseCoverageProvider` export and no coverage figure had been produced in months. Fixing the range is half the requirement; the other half is that a measured figure that gates nothing decays the same way an unrun script does. Thresholds sit a few points under the measured result (statements 54.6, branches 48.7, functions 49.6, lines 55.1 when this landed) and only ever move up, matching `MIN_THRESHOLD` in the traceability gate and the eslint `--max-warnings` ratchet. The absolute numbers are held down by `.svelte` components, which this project deliberately does not test directly — the pattern is to extract the logic to a plain module and test that | Tooling | - | Done | +| DR-216 | Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — `bytes`, four in `rustls-webpki`, `time`, two in `quick-xml`, `rand` — every one closed by a `cargo update` nobody had reason to run. `cargo deny` (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (`libmpv`) is pinned by revision instead of by branch, since a branch means any `cargo update` silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — `libmpv`/`libmpv-sys` are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so) | Tooling | - | Done | | DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `