From 65add5623e858dc203d220314da13aff17a252bb Mon Sep 17 00:00:00 2001 From: Gudge Date: Sat, 6 Jun 2026 18:47:38 -0700 Subject: [PATCH] ci: pin rustup-init version and verify SHA-256 in the public toolchain template `.azure-pipelines/templates/Rust.Toolchain.Public.yml` is the public- bootstrap rustup install used by lint, fork-PR builds, and the macOS build. It previously fetched `rustup-init` from the convenience redirectors `https://win.rustup.rs/x86_64` and `https://sh.rustup.rs` and executed it **without any integrity check** on every trusted run. ## Threat Those URLs are convenience redirectors that always return the latest rustup release. A compromise of the redirect, the rustup origin, or the TLS chain would silently propagate into every trusted build that uses this template. The lint job in particular also rewires crates.io to an anonymous public mirror via `.azure-pipelines/.cargo/config.public.toml`, so a compromised `rustup-init` could expand the blast radius into any compiled artifact the lint job touches. ## Fix This template now: 1. **Pins the rustup version** via a new `rustupInitVersion` parameter (default `1.28.2`) and fetches from the immutable archive URL `static.rust-lang.org/rustup/archive///`. That URL is byte-stable for a given (version, triple); the previous `latest` URL would defeat any pinned SHA the first time rust-lang shipped a new release, which is what made hash pinning impossible under the old layout. 2. **Selects the host triple from the actual agent**, not from the build target. Windows agents are always x64 (windows/arm64 builds run on x64 agents and cross-compile via `--target`), so the Windows download is hardcoded to `x86_64-pc-windows-msvc`. On Linux / macOS the host arch is read from `uname -m` and translated to a Rust triple via an explicit per-OS switch; unknown architectures throw with a clear message rather than silently downloading an unrunnable binary. The TARGET toolchain is still installed via `rustup-init --target \`. The resolved host / target triples are logged to the job output so any future cross-compile mismatch is one `grep` away. 3. **Verifies the SHA-256 sidecar** from the same origin against the downloaded `rustup-init` BEFORE executing. This matches what `rustup` itself does for self-update. On mismatch the job fails loudly with both expected and actual hashes so an operator can audit. 4. **Documents the residual threat clearly:** SHA-256 sidecar verification defends against integrity-of-payload (in-flight corruption, TLS MITM, truncation) but **not** against authenticity-of-origin (a compromise of `static.rust-lang.org` itself would corrupt both the binary and the sidecar). Defending against the latter requires upstream GPG release-key verification and is out of scope here. ## Trade-off considered and rejected Switching the lint job to `RustInstaller@1` + `CargoAuthenticate` (matching `Rust.Build.Steps.Official.yml`) would have removed the public bootstrap entirely BUT also broken fork-PR linting -- external contributors have no `System.AccessToken` for the internal `Mxc-Azure-Feed`. The integrity-verification approach preserves fork-PR linting while still meaningfully hardening trusted runs. ## Compatibility The two existing call sites (`.azure-pipelines/jobs/Lint.Job.yml` and `.azure-pipelines/templates/Rust.Build.Steps.Unofficial.yml`) are unchanged: the new `rustupInitVersion` parameter has a default, so backward compatibility is automatic. Bumping the pin in future is a single-line change in this file. ## Verification YAML parses (PyYAML): 3 parameters, 1 step OK No call-site changes needed (default-valued parameter) OK No Rust code touched -> no cargo build/clippy/test rerun N/A CI rerun deferred to actual pipeline execution on this PR (the lint job exercises the changed template directly). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../templates/Rust.Toolchain.Public.yml | 97 ++++++++++++++++++- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/.azure-pipelines/templates/Rust.Toolchain.Public.yml b/.azure-pipelines/templates/Rust.Toolchain.Public.yml index 57d4ea09..6bda0d9f 100644 --- a/.azure-pipelines/templates/Rust.Toolchain.Public.yml +++ b/.azure-pipelines/templates/Rust.Toolchain.Public.yml @@ -4,6 +4,38 @@ # Public rustup toolchain install (no 1ES tasks, no auth). Used by fork-PR # builds, the macOS build, and the Lint job. Single source of truth for the # public Rust version pin — keep in sync with src/rust-toolchain.toml. +# +# Supply-chain hardening (review finding E1): +# +# The previous version of this template fetched `rustup-init` from +# `https://win.rustup.rs/x86_64` / `https://sh.rustup.rs`, which are +# convenience redirectors that always return the latest rustup release. The +# binary was then executed *without any integrity check* on every lint run +# (and that lint run also rewires crates.io to an anonymous public mirror via +# `.azure-pipelines/.cargo/config.public.toml`). A compromise of the redirect +# or of the rustup origin would silently propagate into every trusted build. +# +# This version: +# 1. Pins the rustup version explicitly (`rustupInitVersion`) and fetches +# from the immutable archive URL `static.rust-lang.org/rustup/archive///`. +# That URL is byte-stable for a given (version, triple) so the SHA-256 +# check below is meaningful — an unpinned `latest` URL would defeat the +# check the first time rust-lang shipped a new release. +# 2. Fetches the accompanying `.sha256` sidecar from the same origin and +# verifies the binary against it before executing. The sidecar is not +# cryptographically signed (defending against an origin compromise +# requires the upstream GPG release key), but matches what `rustup` +# itself does for self-update and catches in-flight corruption / +# truncation / TLS-MITM scenarios. +# 3. Requires the verification step to succeed before invoking +# `rustup-init`; on mismatch the job fails loudly with both expected +# and actual hashes so an operator can audit before bumping. +# +# When upgrading `rustupInitVersion`: confirm the new version's +# release notes / signed sources from https://github.com/rust-lang/rustup +# before merging. The SHA-256 is fetched at job time from the same origin — +# this is intentional (we follow rustup's own self-update model) but means +# the protection is integrity-of-payload, not authenticity-of-origin. parameters: - name: targetTriple @@ -11,19 +43,74 @@ parameters: - name: rustVersion type: string default: '1.93' +- name: rustupInitVersion + type: string + default: '1.28.2' steps: - pwsh: | $ver = '${{ parameters.rustVersion }}' $triple = '${{ parameters.targetTriple }}' + $rustupVer = '${{ parameters.rustupInitVersion }}' + + function Verify-Sha256 { + param([string]$Path, [string]$ExpectedHex) + $actual = (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLower() + $expected = $ExpectedHex.Trim().ToLower() + if ($actual -ne $expected) { + Write-Error "rustup-init SHA-256 mismatch for $Path. Expected $expected, got $actual. Refusing to execute the installer." + exit 1 + } + Write-Host "rustup-init SHA-256 verified: $actual" + } + if ($IsWindows) { - Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe + # Windows agents are always x64 (windows/arm64 builds also run on + # x64 agents and cross-compile via `--target $triple`), so the + # host triple for rustup-init is fixed. The TARGET toolchain is + # selected by the `--target $triple` flag below. + $base = "https://static.rust-lang.org/rustup/archive/$rustupVer/x86_64-pc-windows-msvc" + Invoke-WebRequest -Uri "$base/rustup-init.exe" -OutFile rustup-init.exe -UseBasicParsing + # Fetch the sidecar SHA-256 and verify. The sidecar's payload is + # ` rustup-init.exe\n`; we want only the hex prefix. + Invoke-WebRequest -Uri "$base/rustup-init.exe.sha256" -OutFile rustup-init.exe.sha256 -UseBasicParsing + $expected = ((Get-Content -Raw rustup-init.exe.sha256) -split '\s+', 2)[0] + Verify-Sha256 -Path rustup-init.exe -ExpectedHex $expected + .\rustup-init.exe -y --default-toolchain $ver --target $triple --profile minimal --component clippy --component rustfmt --no-modify-path Write-Host "##vso[task.prependpath]$env:USERPROFILE\.cargo\bin" } else { - Invoke-WebRequest -Uri https://sh.rustup.rs -OutFile rustup-init.sh - chmod +x rustup-init.sh - ./rustup-init.sh -y --default-toolchain $ver --target $triple --profile minimal --component clippy --component rustfmt --no-modify-path + # On non-Windows the archive ships `rustup-init` (no extension). + # The downloaded binary must match the AGENT host architecture, + # NOT the build target — cross-compiles (e.g. linux/arm64 built + # on an x64 ubuntu agent, the only Linux pool flavour we use) + # would otherwise download an aarch64 `rustup-init` that cannot + # execute on the x64 host. Detect the host from `uname -m`; the + # target toolchain still installs via `--target $triple` below. + $hostArch = (& uname -m).Trim() + if ($IsMacOS) { + $hostTriple = switch ($hostArch) { + 'arm64' { 'aarch64-apple-darwin' } + 'x86_64' { 'x86_64-apple-darwin' } + default { throw "Unsupported macOS host arch '$hostArch' (expected arm64 or x86_64)" } + } + } else { + # Linux (the only other supported non-Windows agent OS). + $hostTriple = switch ($hostArch) { + 'x86_64' { 'x86_64-unknown-linux-gnu' } + 'aarch64' { 'aarch64-unknown-linux-gnu' } + default { throw "Unsupported Linux host arch '$hostArch' (expected x86_64 or aarch64)" } + } + } + Write-Host "rustup-init host triple: $hostTriple (uname -m=$hostArch); target triple: $triple" + $base = "https://static.rust-lang.org/rustup/archive/$rustupVer/$hostTriple" + Invoke-WebRequest -Uri "$base/rustup-init" -OutFile rustup-init -UseBasicParsing + Invoke-WebRequest -Uri "$base/rustup-init.sha256" -OutFile rustup-init.sha256 -UseBasicParsing + $expected = ((Get-Content -Raw rustup-init.sha256) -split '\s+', 2)[0] + Verify-Sha256 -Path rustup-init -ExpectedHex $expected + + chmod +x rustup-init + ./rustup-init -y --default-toolchain $ver --target $triple --profile minimal --component clippy --component rustfmt --no-modify-path Write-Host "##vso[task.prependpath]$env:HOME/.cargo/bin" } - displayName: Install Rust toolchain (rustup) + displayName: Install Rust toolchain (verified rustup-init)