diff --git a/.github/workflows/installer-tests.yaml b/.github/workflows/installer-tests.yaml index 3788384..64a1966 100644 --- a/.github/workflows/installer-tests.yaml +++ b/.github/workflows/installer-tests.yaml @@ -62,13 +62,23 @@ jobs: # common.sh and consumed in other sourced files. Warnings are printed # below for visibility but don't fail the gate. shellcheck --severity=error --shell=bash \ - scripts/install.sh scripts/install-k8s.sh scripts/lib/*.sh \ + scripts/install.sh scripts/install-k8s.sh scripts/gen-manifest.sh scripts/lib/*.sh \ scripts/tests/distro-prereqs.sh scripts/tests/e2e-cluster.sh scripts/tests/e2e-proxy.sh scripts/tests/e2e-auto-upgrade.sh scripts/tests/check-drift.sh echo "── shellcheck warnings (advisory, non-blocking) ──" shellcheck --severity=warning --shell=bash \ - scripts/install.sh scripts/install-k8s.sh scripts/lib/*.sh \ + scripts/install.sh scripts/install-k8s.sh scripts/gen-manifest.sh scripts/lib/*.sh \ scripts/tests/distro-prereqs.sh scripts/tests/e2e-cluster.sh scripts/tests/e2e-proxy.sh scripts/tests/e2e-auto-upgrade.sh scripts/tests/check-drift.sh || true + - name: Installer manifest is current (supply-chain, R8) + # The bootstrap verifies each sub-script against scripts/manifest.sha256 + # before running the privileged steps. If a sub-script changed but the + # manifest didn't, the release would publish a manifest that rejects the + # very scripts it ships — so fail the PR until the manifest is regenerated. + # Also cross-checks the bootstrap's FILES list against gen-manifest.sh. + run: | + chmod +x scripts/gen-manifest.sh + scripts/gen-manifest.sh --check + - name: PSScriptAnalyzer (PowerShell installer) shell: pwsh run: | diff --git a/.github/workflows/release-helm-chart.yaml b/.github/workflows/release-helm-chart.yaml index 61b2f39..52d4883 100644 --- a/.github/workflows/release-helm-chart.yaml +++ b/.github/workflows/release-helm-chart.yaml @@ -137,5 +137,130 @@ jobs: client-*.tgz ingestor-*.tgz generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ─────────────────────────────────────────────────────────────────────────── + # Supply-chain hardening (RFC-0001 R8, backend#889) + # + # The curl|bash bootstrap (scripts/install.sh) refuses to run its sub-scripts + # unless it can verify them against a cosign-signed manifest published on THIS + # release. This job produces that manifest, signs it keyless (same Sigstore + # machinery the CLI binary uses), stamps the published installer with this + # immutable tag, and attaches the four assets the bootstrap fetches: + # install.sh (DEFAULT_REF stamped to this tag) + # manifest.sha256 (sha256 of every sub-script at this tag) + # manifest.sha256.sig (cosign keyless signature over the manifest) + # manifest.sha256.cert (the signing certificate to verify .sig against) + # + # Verified end-to-end by scripts/tests/install-bootstrap.bats; the release- + # notes verification recipe is in docs/SUPPLY_CHAIN.md. + # ─────────────────────────────────────────────────────────────────────────── + sign-installer-manifest: + name: Sign installer manifest (R8) + runs-on: ubuntu-latest + permissions: + contents: write # attach assets to the release + id-token: write # cosign keyless OIDC + steps: + - name: Checkout the released tag + # Same actions/runner#2788 guard as above — pin to the release tag so the + # manifest covers exactly the bytes published at this immutable ref. + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + fetch-depth: 0 + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + with: + cosign-release: 'v2.4.1' # keep in lockstep with scripts/install.sh COSIGN_VERSION + + - name: Generate the sub-script manifest + # gen-manifest.sh recomputes the manifest from the exact FILES the + # bootstrap fetches and cross-checks the two lists are in sync (it + # exits non-zero on drift), so a sub-script can never ship unlisted. + run: | + chmod +x scripts/gen-manifest.sh + scripts/gen-manifest.sh + echo "── manifest.sha256 ──" + cat scripts/manifest.sha256 + + - name: Sign the manifest (cosign keyless) + env: + COSIGN_YES: 'true' + run: | + cd scripts + cosign sign-blob \ + --output-certificate manifest.sha256.cert \ + --output-signature manifest.sha256.sig \ + manifest.sha256 + echo "Signed manifest.sha256" + ls -l manifest.sha256* + + - name: Stamp the published installer with this immutable tag + # The in-repo install.sh carries a placeholder DEFAULT_REF and refuses to + # run un-stamped (fail-closed). The published artifact is pinned to THIS + # tag so `curl | bash` of the release asset transitively pins every + # sub-script. We stamp a copy, never the committed file. + # + # SECURITY: the tag is passed via env (NOT interpolated into the run: + # script). Interpolating `${{ github.event.release.tag_name }}` straight + # into a shell assignment lets a tag containing a single quote break out + # and inject shell into THIS job — which holds id-token:write + + # contents:write, the trust-establishing privileges. As an env var it is + # plain data; the shell never re-parses it (RFC-0001 R8, backend#889). + env: + TAG: ${{ github.event.release.tag_name }} + run: | + install -m 0755 scripts/install.sh scripts/install.stamped.sh + # Replace only the placeholder token; fail loudly if it's absent so a + # refactor that drops it can't silently publish an un-pinned installer. + grep -q '__TRACEBLOC_RELEASE_REF__' scripts/install.stamped.sh \ + || { echo "::error::DEFAULT_REF placeholder not found in install.sh"; exit 1; } + # Robust substitution: do a literal find/replace with awk instead of + # `sed s|...|...|`, so a tag containing sed-special bytes ('/' as the + # delimiter, '&' as match-text, '\' as an escape) can't corrupt the + # stamp or inject regex metacharacters. The index()/substr() loop treats + # BOTH the placeholder and the replacement (read from ENVIRON, never the + # command line) as plain strings — no regex, no backreference, no + # delimiter — so every byte of the tag lands verbatim. + TAG="$TAG" awk ' + BEGIN { ph = "__TRACEBLOC_RELEASE_REF__"; rep = ENVIRON["TAG"] } + { + out = ""; line = $0 + while ((p = index(line, ph)) > 0) { + out = out substr(line, 1, p - 1) rep + line = substr(line, p + length(ph)) + } + print out line + } + ' scripts/install.stamped.sh > scripts/install.stamped.sh.new + mv scripts/install.stamped.sh.new scripts/install.sh + rm -f scripts/install.stamped.sh + # Confirm the stamp landed exactly and the placeholder is fully gone. + grep -n 'DEFAULT_REF=' scripts/install.sh + if grep -q '__TRACEBLOC_RELEASE_REF__' scripts/install.sh; then + echo "::error::placeholder still present after stamping"; exit 1 + fi + + - name: Self-test the stamped installer parses and refuses bad refs + run: | + bash -n scripts/install.sh + # Un-pinned dev ref without the opt-in must fail closed (exit non-zero). + if BRANCH=some-branch bash scripts/install.sh /dev/null 2>&1; then + echo "::error::stamped installer did not fail closed on a mutable BRANCH"; exit 1 + fi + echo "stamped installer fails closed on a mutable ref (expected)" + + - name: Attach installer + signed manifest to the release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name }} + files: | + scripts/install.sh + scripts/manifest.sha256 + scripts/manifest.sha256.sig + scripts/manifest.sha256.cert env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 5258d12..8505ea0 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ bash <(curl -fsSL https://tracebloc.io/i.sh) irm https://tracebloc.io/i.ps1 | iex ``` -The installer pulls helper scripts from this repo at runtime — see [`scripts/install-k8s.sh`](scripts/install-k8s.sh) and [`scripts/install-k8s.ps1`](scripts/install-k8s.ps1). +The installer pulls helper scripts from this repo at runtime — see [`scripts/install-k8s.sh`](scripts/install-k8s.sh) and [`scripts/install-k8s.ps1`](scripts/install-k8s.ps1). Those scripts are pinned to an **immutable release tag** and each is **verified against a cosign-signed manifest** before it runs; the install **fails closed** if verification can't complete (it never silently runs unverified code). See [docs/SUPPLY_CHAIN.md](docs/SUPPLY_CHAIN.md) for the integrity model and how to verify a release by hand. ### Helm install diff --git a/docs/SECURITY.md b/docs/SECURITY.md index ada7ea3..582c8f7 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -51,6 +51,14 @@ The following parts of the system are treated as trusted and are **not** in scop - Tracebloc engineers publishing the training base images (`tracebloc/*-cpu`, `tracebloc/*-gpu`) and the chart artifact. - The Helm chart itself and the values the customer provides at install time. +> **Delivery integrity is verified, not assumed.** Trusting "the chart artifact" +> and "the installer scripts" means trusting the bytes that actually reach the +> box — so the `curl | bash` bootstrap pins to an immutable release tag and +> verifies every sub-script against a cosign-signed manifest before running the +> privileged steps (credential mint/write, Helm). See [§4.8](#48-installer-supply-chain-integrity-g8) +> and [docs/SUPPLY_CHAIN.md](SUPPLY_CHAIN.md). This addresses RFC-0001 R8 (the +> dominant supply-chain gap for a regulated buyer), tracked as backend#889. + ### 1.3 Untrusted components - **The Python file, weight file, and training plan submitted by an external data scientist.** @@ -225,6 +233,37 @@ pod-security.kubernetes.io/audit: restricted `enforce: restricted` is on by default on CSI-backed deployments (EKS/AKS/OC); bare-metal overrides it off via `ci/bm-values.yaml`. See §6.6 and §8.5. +### 4.8 Installer supply-chain integrity (G8) + +**Threat.** The `curl | bash` bootstrap (`scripts/install.sh`) is the most +privileged code in the product: the sub-scripts it fetches mint the machine +credential, write it to disk, and run Helm as a cluster admin. If the bytes that +reach the box can be altered — by moving a mutable branch ref or by an on-path +attacker — none of the in-cluster defenses above matter, because the attacker +runs first, as root. + +**Mechanism (RFC-0001 R8, backend#889):** + +- **Immutable ref.** The bootstrap fetches every sub-script from a pinned release + **tag** (content-addressable), never a mutable branch. A branch / non-`vX.Y.Z` + ref is refused unless an operator sets `TRACEBLOC_ALLOW_UNVERIFIED=1` (a loud, + developer-only escape hatch). +- **Signed manifest.** Each sub-script's sha256 is checked against a + `manifest.sha256` published on the release. A tampered sub-script, or one + missing from the manifest, **aborts the install before any privileged step**. +- **Authenticated + fail-closed.** The manifest is verified with a **cosign + keyless signature** (same Sigstore chain as the CLI binary). If cosign is + absent the bootstrap bootstraps a pinned, checksum-verified cosign; if it can + neither find nor bootstrap cosign it **fails closed** rather than degrading to + a same-channel checksum. + +**Where implemented:** `scripts/install.sh` (verification), `scripts/gen-manifest.sh` +(manifest generation), `.github/workflows/release-helm-chart.yaml` +(`sign-installer-manifest` job: generate + sign + stamp + attach), and the CLI's +own installer for the leaf binary (`tracebloc/cli` `scripts/install.sh`, made +mandatory under the same ticket). Full model + release-pipeline + key-management +follow-ups: [docs/SUPPLY_CHAIN.md](SUPPLY_CHAIN.md). + --- ## 5. Per-platform caveats @@ -457,6 +496,19 @@ If the customer enables the policy on a CNI that doesn't enforce (default EKS, F A malicious model can allocate memory / consume CPU up to the pod's resource limits. `resources.limits` are applied (defaults `cpu=2,memory=8Gi`). A pod running at 100% of its limits is expected behavior for training; OOMKill or eviction is the Kubernetes-native response. The chart does not attempt to detect or prevent resource-intensive pathological inputs. +### 8.9 Cosign-bootstrap trust root on boxes without cosign — **security / operator** + +When cosign is not already installed, the bootstrap (§4.8) downloads a pinned +cosign from the sigstore GitHub release and verifies it against +`cosign_checksums.txt` fetched over the **same TLS channel**. This is a pragmatic +trust root — strictly better than the previous "no verification at all" — but it +is not signature-rooted (you can't verify cosign's signature without cosign). A +sufficiently capable on-path attacker who can also forge a TLS chain to GitHub +could substitute both. **Mitigation for regulated buyers:** pre-install cosign +from the OS package manager (or an internal mirror) before running the +installer, so the bootstrap uses a cosign you already trust. Documented in +[docs/SUPPLY_CHAIN.md §6](SUPPLY_CHAIN.md#6-operator-guidance--verifying-a-release-by-hand). + --- ## 9. If you suspect compromise diff --git a/docs/SUPPLY_CHAIN.md b/docs/SUPPLY_CHAIN.md new file mode 100644 index 0000000..cca87cd --- /dev/null +++ b/docs/SUPPLY_CHAIN.md @@ -0,0 +1,173 @@ +# Installer supply-chain integrity (RFC-0001 R8) + +**Audience:** release engineers and security reviewers. +**Scope:** how the `curl | bash` bootstrap (`scripts/install.sh`) verifies the +sub-scripts it runs, and exactly what the release pipeline + key management must +provide for that verification to be real. + +Tracking issue: **backend#889** (private security ticket). RFC: §9, §14 R8, §13. + +--- + +## 1. The problem this closes + +The bootstrap is the **most privileged code in the product**: the sub-scripts it +fetches install the CLI, run `tracebloc login` + `client create` to **mint the +machine credential**, write that credential to disk, and run **Helm** as a +cluster admin. Before this change it pulled ~15 sub-scripts from a **mutable +branch ref** (`BRANCH`, default `main`) over `raw.githubusercontent.com` with +**no checksum and no signature**. Whoever could move that ref — or sit on-path — +could change what ran as root on every customer box. The cosign-signed CLI +binary covered only the *leaf*; everything upstream of it was unverified. + +## 2. The verification model (what the installer does today) + +1. **Immutable ref.** `scripts/install.sh` fetches every sub-script from a fixed + release **tag** (`DEFAULT_REF`, e.g. `v2.0.1`), not a branch. GitHub serves + tag content immutably, so the tag's bytes can't be moved under us. A `BRANCH` + / non-`vX.Y.Z` ref is refused unless the operator sets + `TRACEBLOC_ALLOW_UNVERIFIED=1` (developer-only, and it shouts). + +2. **Signed manifest.** A `manifest.sha256` lists the sha256 of every sub-script + at that tag. The bootstrap downloads each sub-script, recomputes its digest, + and compares it to the manifest. **Any mismatch, or any sub-script missing + from the manifest, aborts the install** — before `install-k8s.sh` (and thus + `provision.sh` / Helm) runs. + +3. **Authenticated manifest.** The manifest itself is **cosign-signed** (keyless + Sigstore — the same machinery the CLI binary uses). The bootstrap verifies + `manifest.sha256.sig` against `manifest.sha256.cert` with cosign before + trusting a single digest in it. The signing identity is pinned to the + tracebloc/client release workflow's OIDC certificate. + +4. **Fail-closed, never silent-skip.** If cosign is not on PATH, the bootstrap + **bootstraps a pinned cosign** (downloads the release binary for the host + OS/arch and verifies it against cosign's own published checksums) and uses + that. If cosign can be neither found nor bootstrapped, the install **fails + closed** with remediation — it does **not** degrade to a checksum fetched over + the same channel an attacker controls. The only way past is the explicit + `TRACEBLOC_ALLOW_UNVERIFIED=1` opt-in. + +The four assets the bootstrap consumes are published as **GitHub Release +assets** on the pinned tag: + +| Asset | Produced by | Verifies | +|---|---|---| +| `install.sh` | release job, `DEFAULT_REF` stamped to the tag | the entrypoint itself (pin) | +| `manifest.sha256` | `scripts/gen-manifest.sh` | every sub-script's bytes | +| `manifest.sha256.sig` | `cosign sign-blob` (keyless) | authenticity of the manifest | +| `manifest.sha256.cert` | `cosign sign-blob` (keyless) | the cert the sig verifies against | + +Sub-script **content** is still pulled from the immutable tag *tree* +(`raw.githubusercontent.com/...//scripts/...`); only the manifest + +signature live as release assets (signing happens in CI *after* the tag is cut, +so they can't be committed into the tagged commit — same reason the CLI serves +`SHA256SUMS` as a release asset). + +## 3. Why cosign keyless (and not minisign / gpg) + +| Option | Key management | Why / why not | +|---|---|---| +| **cosign keyless (chosen)** | **None.** Signing identity = the GitHub Actions OIDC token for the release workflow; transparency via Rekor. | The CLI binary release already uses exactly this (`cli/.github/workflows/release.yml`). One mechanism, one verification idiom across both repos. No long-lived private key to store, rotate, or leak. | +| minisign | A long-lived private key held in a repo secret or offline. | Adds a secret to manage + a new compromise vector. No transparency log. | +| gpg | A long-lived private key + keyring/web-of-trust. | Heaviest key management; awkward in CI; same secret-custody problem. | + +The decisive factor: cosign keyless introduces **no new signing secret**. The +trust root is GitHub's OIDC issuer + the certificate-identity of the release +workflow, both already trusted by the CLI's verification path. + +## 4. What the release pipeline must provide — IMPLEMENTED here + +`.github/workflows/release-helm-chart.yaml` gains a `sign-installer-manifest` +job (runs on `release: published`, same trigger as the chart publish) that: + +1. Checks out the **released tag** (`github.event.release.tag_name`). +2. Installs cosign (`sigstore/cosign-installer`, pinned `v2.4.1`). +3. Runs `scripts/gen-manifest.sh` to (re)generate `manifest.sha256` from the + exact `FILES` the bootstrap fetches — the script cross-checks the two `FILES` + lists and fails on drift, so a sub-script can never ship unlisted. +4. `cosign sign-blob` → `manifest.sha256.sig` + `manifest.sha256.cert` (keyless; + needs `id-token: write`, already granted). +5. Stamps `__TRACEBLOC_RELEASE_REF__` → the tag in a *copy* of `install.sh` and + self-tests that the stamped installer parses and **fails closed** on a + mutable ref. +6. Attaches `install.sh`, `manifest.sha256`, `manifest.sha256.sig`, + `manifest.sha256.cert` to the release. + +A CI gate in `installer-tests.yaml` (`gen-manifest.sh --check`) fails any PR that +changes a sub-script without regenerating the committed manifest, so the +in-repo manifest never drifts from the scripts it covers. + +## 5. Human follow-ups required to make this fully real + +These are **infra / process** items a human must own — they are **not** code in +this PR, and some depend on repo settings or one-time decisions: + +1. **Verify keyless signing works on the first release that includes this job.** + The `release` job in this repo did not previously do cosign signing; confirm + the runner gets an OIDC token (the job sets `id-token: write`) and that + `cosign sign-blob` succeeds end-to-end on a real `release: published` event. + *(Owner: release engineering.)* + +2. **Pin / publish `DEFAULT_REF` for the documented entrypoints.** + - The in-repo `install.sh` keeps the `__TRACEBLOC_RELEASE_REF__` placeholder + and **must not** be curl-piped directly (it fails closed). The canonical + entrypoints must serve the **stamped release asset**: + - `https://tracebloc.io/i.sh` → redirect/proxy to + `https://github.com/tracebloc/client/releases/latest/download/install.sh` + (or a specific tag). **This redirect is infra the website owns** — update + it so customers get the stamped, pinned installer, not the raw `main` + copy. *(Owner: web / infra.)* + - The README / INSTALL `raw.githubusercontent.com/.../main/scripts/install.sh` + one-liners are updated in this PR to point at the release-asset URL. + +3. **Decide the cosign-bootstrap trust posture for hardened/air-gapped sites.** + When cosign is absent the bootstrap downloads it from the sigstore GitHub + release and verifies it against `cosign_checksums.txt` fetched over the same + TLS channel — a pragmatic trust root, strictly better than today's nothing, + but not a signature-rooted one (you can't verify cosign's signature without + cosign). For regulated buyers, document the stronger options: (a) pre-install + cosign via the OS package manager before running the installer, or (b) mirror + a pinned cosign internally. *(Owner: security; doc'd in §6 below.)* + +4. **Certificate-identity hardening.** The bootstrap currently accepts any + workflow under `tracebloc/client/.github/workflows/*` as the signer + (`--certificate-identity-regexp`). Once `sign-installer-manifest` is the + settled signer, tighten this to the exact workflow path + (`.../release-helm-chart.yaml@refs/tags/`) to narrow the trust surface. + *(Owner: security; tracked as a follow-up so we don't lock the regex before + the first signed release proves the exact identity string.)* + +5. **Branch-protect the release workflow + tags.** The keyless trust root is + "whatever the release workflow signs." Protect `release-helm-chart.yaml` and + the `gen-manifest.sh` FILES list under CODEOWNERS, and restrict who can push + `v*` tags / publish releases. *(Owner: repo admin.)* + +## 6. Operator guidance — verifying a release by hand + +Anyone can independently verify a release the same way the bootstrap does: + +```bash +TAG=v2.0.1 +BASE=https://github.com/tracebloc/client/releases/download/$TAG +curl -fsSLO "$BASE/manifest.sha256" +curl -fsSLO "$BASE/manifest.sha256.sig" +curl -fsSLO "$BASE/manifest.sha256.cert" + +cosign verify-blob \ + --certificate-identity-regexp \ + 'https://github.com/tracebloc/client/\.github/workflows/.*@.*' \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + --certificate manifest.sha256.cert \ + --signature manifest.sha256.sig \ + manifest.sha256 +# → "Verified OK" + +# Then confirm a sub-script matches the manifest: +curl -fsSL "https://raw.githubusercontent.com/tracebloc/client/$TAG/scripts/lib/provision.sh" \ + | sha256sum # compare to the provision.sh line in manifest.sha256 +``` + +**Hardened / air-gapped sites:** install cosign from your OS package manager (or +an internal mirror) *before* running the installer, so the bootstrap uses a +cosign you already trust rather than downloading one. diff --git a/scripts/gen-manifest.sh b/scripts/gen-manifest.sh new file mode 100755 index 0000000..9a8a08e --- /dev/null +++ b/scripts/gen-manifest.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# ============================================================================= +# gen-manifest.sh — produce scripts/manifest.sha256 (RFC-0001 R8, backend#889) +# +# The bootstrap (scripts/install.sh) verifies every sub-script it fetches +# against this manifest before running the privileged steps. This script +# regenerates the manifest from the exact set the bootstrap fetches, so the +# two never drift. +# +# The manifest is the *integrity surface*; its *authenticity* is established +# separately by a cosign keyless signature over this file, produced by the +# release workflow (see docs/SUPPLY_CHAIN.md). This script does NOT sign — it +# only computes digests, so it needs no secrets and is safe to run anywhere +# (locally to preview, in CI to publish). +# +# Usage: +# scripts/gen-manifest.sh # write scripts/manifest.sha256 +# scripts/gen-manifest.sh --check # verify the committed manifest is current +# # (CI gate; non-zero on drift) +# ============================================================================= +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# The single source of truth for what the bootstrap fetches. Keep in lockstep +# with the FILES array in scripts/install.sh — the --check mode below fails CI +# if a file the bootstrap fetches is missing from this list (or vice versa). +FILES=( + "scripts/install-k8s.sh" + "scripts/lib/common.sh" + "scripts/lib/preflight.sh" + "scripts/lib/detect-gpu.sh" + "scripts/lib/gpu-nvidia.sh" + "scripts/lib/gpu-amd.sh" + "scripts/lib/setup-macos.sh" + "scripts/lib/setup-linux.sh" + "scripts/lib/cluster.sh" + "scripts/lib/gpu-plugins.sh" + "scripts/lib/install-client-helm.sh" + "scripts/lib/install-cli.sh" + "scripts/lib/provision.sh" + "scripts/lib/summary.sh" + "scripts/lib/diagnose.sh" +) + +MANIFEST="scripts/manifest.sha256" + +_sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + echo "[ERROR] no sha256sum / shasum on PATH" >&2 + exit 1 + fi +} + +# Cross-check: the bootstrap's FILES array must match this one exactly, or the +# manifest will be missing an entry for something the bootstrap runs (or carry +# a stale one). Extract the array from install.sh and diff. +_check_bootstrap_in_sync() { + local boot + boot="$(awk '/^FILES=\(/{f=1;next} /^\)/{f=0} f' scripts/install.sh \ + | sed -e 's/^[[:space:]]*"//' -e 's/"[[:space:]]*$//')" + local here + here="$(printf '%s\n' "${FILES[@]}")" + if [[ "$boot" != "$here" ]]; then + echo "[ERROR] scripts/install.sh FILES array and gen-manifest.sh FILES differ:" >&2 + diff <(printf '%s\n' "$here") <(printf '%s\n' "$boot") >&2 || true + exit 1 + fi +} + +generate() { + local f + { + for f in "${FILES[@]}"; do + [[ -f "$f" ]] || { echo "[ERROR] missing file: $f" >&2; exit 1; } + printf '%s %s\n' "$(_sha256_of "$f")" "$f" + done + } > "$MANIFEST.tmp" + mv "$MANIFEST.tmp" "$MANIFEST" +} + +_check_bootstrap_in_sync + +if [[ "${1:-}" == "--check" ]]; then + generate_to="$(mktemp)" + trap 'rm -f "$generate_to"' EXIT + for f in "${FILES[@]}"; do + [[ -f "$f" ]] || { echo "[ERROR] missing file: $f" >&2; exit 1; } + printf '%s %s\n' "$(_sha256_of "$f")" "$f" + done > "$generate_to" + if ! diff -u "$MANIFEST" "$generate_to" >/dev/null 2>&1; then + echo "[ERROR] $MANIFEST is out of date. Run scripts/gen-manifest.sh and commit." >&2 + diff -u "$MANIFEST" "$generate_to" >&2 || true + exit 1 + fi + echo "$MANIFEST is up to date." + exit 0 +fi + +generate +echo "Wrote $MANIFEST:" +cat "$MANIFEST" diff --git a/scripts/install.sh b/scripts/install.sh index 75dae78..22091ba 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,11 +1,35 @@ #!/usr/bin/env bash # ============================================================================= -# Bootstrap installer — downloads scripts from GitHub and runs install-k8s.sh +# Bootstrap installer — downloads the installer sub-scripts from GitHub and +# runs install-k8s.sh. +# +# SUPPLY-CHAIN HARDENING (RFC-0001 R8, backend#889) +# ------------------------------------------------- +# This is the most privileged code in the whole product: the sub-scripts it +# fetches mint the machine credential, write it to disk, and run Helm. So the +# fetch is verified, not trusted: +# +# 1. Pin to an IMMUTABLE release tag (REF), not a mutable branch. Tag content +# on GitHub is content-addressable and cannot be moved under us; a branch +# ref can. The default REF below is bumped by the release pipeline on each +# release (see docs/SUPPLY_CHAIN.md). `curl | bash` of THIS script from a +# tag therefore transitively pins every sub-script to the same tag. +# 2. Fetch a signed manifest (manifest.sha256) listing the expected sha256 of +# every sub-script, verify each file against it, and ABORT on any mismatch +# or missing entry — BEFORE install-k8s.sh (and thus provision.sh / Helm) +# runs. +# 3. Anchor the manifest's authenticity with a cosign keyless signature +# (same Sigstore machinery the CLI binary already uses). On the default +# path the signature is REQUIRED: if cosign is unavailable AND cannot be +# bootstrapped, the install fails closed rather than silently degrading to +# a checksum fetched over the same channel an on-path attacker controls. # # Usage (macOS / Linux): -# curl -fsSL https://raw.githubusercontent.com/tracebloc/client/main/scripts/install.sh | bash -# curl -fsSL ... | BRANCH=develop bash -# curl -fsSL ... | BRANCH=develop CLIENT_ENV=dev bash +# curl -fsSL https://raw.githubusercontent.com/tracebloc/client//scripts/install.sh | bash +# bash <(curl -fsSL https://tracebloc.io/i.sh) +# +# Developer / unreleased-branch override (UNVERIFIED — not for customers): +# curl -fsSL ... | BRANCH=develop TRACEBLOC_ALLOW_UNVERIFIED=1 bash # # Windows (PowerShell as Administrator): # irm https://raw.githubusercontent.com/tracebloc/client/main/scripts/install.ps1 | iex @@ -20,16 +44,104 @@ case "$(uname -s)" in exit 1 ;; esac -BRANCH="${BRANCH:-main}" -[[ "$BRANCH" =~ ^[a-zA-Z0-9._/-]+$ ]] || { echo "[ERROR] Invalid BRANCH name: $BRANCH"; exit 1; } -REPO_RAW="https://raw.githubusercontent.com/tracebloc/client/${BRANCH}" +# ── Pinned, immutable release ref ────────────────────────────────────────── +# DEFAULT_REF is the immutable git tag this bootstrap fetches from. The release +# pipeline rewrites this line on every release so the published installer always +# pins itself to its own release (see docs/SUPPLY_CHAIN.md "Release pipeline"). +# It MUST be a tag (vX.Y.Z), never a branch — a tag's bytes can't be moved. +DEFAULT_REF="__TRACEBLOC_RELEASE_REF__" + +# Escape hatch for engineers iterating on an unreleased branch. This BYPASSES +# the immutable-tag guarantee and (combined with the unsigned-dev manifest path) +# the signature chain, so it is gated behind an explicit opt-in and shouts. +ALLOW_UNVERIFIED="${TRACEBLOC_ALLOW_UNVERIFIED:-0}" + +# Resolve the ref to fetch from. Precedence: explicit REF env (pin a different +# release tag) > legacy BRANCH (dev only) > the pinned DEFAULT_REF. BRANCH is +# retained for backward compatibility (CI, dev docs) but now requires the +# unverified opt-in below. +if [[ -n "${REF:-}" ]]; then + : # explicit pin — honored as-is +elif [[ -n "${BRANCH:-}" ]]; then + REF="$BRANCH" + USING_BRANCH=1 +else + REF="$DEFAULT_REF" +fi + +# If the published installer wasn't stamped with a real tag (e.g. someone ran a +# raw checkout of scripts/install.sh off a branch instead of the released +# artifact), DEFAULT_REF is still the placeholder. Refuse rather than silently +# fetch from an unpinned location. +if [[ "$REF" == "__TRACEBLOC_RELEASE_REF__" ]]; then + if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then + REF="main" + USING_BRANCH=1 + echo "[WARN] No pinned release ref baked into this installer; falling back to 'main' because TRACEBLOC_ALLOW_UNVERIFIED=1." >&2 + else + echo "[ERROR] This installer wasn't stamped with a pinned release tag, so it can't verify what it fetches." >&2 + echo " Install from a release URL:" >&2 + echo " curl -fsSL https://raw.githubusercontent.com/tracebloc/client//scripts/install.sh | bash" >&2 + echo " or, for local development only, re-run with TRACEBLOC_ALLOW_UNVERIFIED=1." >&2 + exit 1 + fi +fi + +# Validate the ref shape (defends the URL we build from it). +[[ "$REF" =~ ^[a-zA-Z0-9._/-]+$ ]] || { echo "[ERROR] Invalid ref: $REF"; exit 1; } + +# A ref that isn't a vX.Y.Z tag is a mutable branch — the exact thing R8 closes. +# Allow it only under the explicit unverified opt-in, and say so loudly. +# The version-suffix class is restricted to [A-Za-z0-9.] (e.g. -rc1, .4): a looser +# trailer like ([.-].+)? admits '/' and '..', so a ref such as +# 'v1.2.3-../../heads/main' would pass this gate and then curl would collapse the +# '..' to fetch sub-scripts off the MUTABLE 'main' branch — the immutable-tag +# guarantee bypassed with no opt-in (RFC-0001 R8, backend#889). +if [[ "${USING_BRANCH:-0}" == "1" || ! "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.]+)?$ ]]; then + if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then + echo "============================================================================" >&2 + echo "[WARN] UNVERIFIED INSTALL: fetching from mutable ref '$REF', signature checks" >&2 + echo " relaxed. This is for tracebloc development only — never for a customer" >&2 + echo " or production box. A moved ref here can run arbitrary privileged code." >&2 + echo "============================================================================" >&2 + else + echo "[ERROR] '$REF' is not an immutable release tag (expected vX.Y.Z)." >&2 + echo " The bootstrap only trusts content-addressable release tags so a moved" >&2 + echo " branch ref can't change what runs as root on your box (RFC-0001 R8)." >&2 + echo " Use a release tag, or for local dev set TRACEBLOC_ALLOW_UNVERIFIED=1." >&2 + exit 1 + fi +fi + +# Belt-and-suspenders: even after the shape checks above, refuse a ref carrying a +# path separator or a parent-dir token before it is interpolated into a URL. A +# '/' or '..' here is a path-traversal lever (curl collapses '..', so the fetch +# could escape the pinned tag onto a mutable branch) — independent of which +# branch above let the ref through (RFC-0001 R8, backend#889). +case "$REF" in + */*|*..*) + echo "[ERROR] Ref '$REF' contains a path separator or '..' — refusing to build a" >&2 + echo " fetch URL from it (path-traversal guard, RFC-0001 R8)." >&2 + exit 1 ;; +esac + +REPO_RAW="https://raw.githubusercontent.com/tracebloc/client/${REF}" +# The signed manifest + its cosign sig/cert are published as RELEASE ASSETS +# (not committed into the tagged tree), because signing happens in CI *after* +# the tag is cut — the same pattern the CLI uses for SHA256SUMS. The sub-script +# *content* is still pinned to the immutable tag tree above; only the manifest's +# authenticity material is served from the release. +REPO_REL="https://github.com/tracebloc/client/releases/download/${REF}" TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT -echo "Downloading Tracebloc client installer (branch: $BRANCH)..." +echo "Downloading Tracebloc client installer (ref: $REF)..." mkdir -p "$TMPDIR/lib" +# The sub-scripts to fetch. This list is the integrity surface — every entry +# must have a digest in manifest.sha256 (the manifest is generated from exactly +# this set at release time; see scripts/gen-manifest.sh). Keep it in sync. FILES=( "scripts/install-k8s.sh" "scripts/lib/common.sh" @@ -52,6 +164,9 @@ download_with_retry() { local url="$1" dest="$2" local attempt max_attempts=3 delay=5 for attempt in 1 2 3; do + # --tlsv1.2 floor; honor any proxy / custom-CA env the corporate-proxy + # segment relies on (#172/#722) — curl picks up HTTPS_PROXY/NO_PROXY/ + # CURL_CA_BUNDLE from the environment automatically. if curl -fsSL --tlsv1.2 "$url" -o "$dest"; then return 0; fi if [[ $attempt -ge $max_attempts ]]; then echo "[ERROR] Failed to download $url after $max_attempts attempts." @@ -62,11 +177,190 @@ download_with_retry() { done } +# ── Fetch the sub-scripts ───────────────────────────────────────────────── for f in "${FILES[@]}"; do dest="$TMPDIR/${f#scripts/}" download_with_retry "$REPO_RAW/$f" "$dest" done +# ── Pick a sha256 tool (coreutils on Linux, shasum on macOS) ─────────────── +_sha256_of() { + local file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + else + return 1 + fi +} + +# ── Verify each sub-script against the signed manifest ───────────────────── +# manifest.sha256 lines: ␠␠scripts/ +# A missing manifest, a missing line, or a digest mismatch ABORTS — before any +# privileged sub-script (provision.sh mints+writes the credential; install- +# client-helm.sh runs Helm) is executed. +verify_against_manifest() { + local manifest="$TMPDIR/manifest.sha256" + + if ! _sha256_of "$TMPDIR/install-k8s.sh" >/dev/null 2>&1; then + echo "[ERROR] No sha256 tool (sha256sum / shasum) on PATH — can't verify the" >&2 + echo " installer's integrity. Install coreutils (Linux) or ensure" >&2 + echo " /usr/bin/shasum is on PATH (macOS), then re-run." >&2 + exit 1 + fi + + if ! download_manifest "$manifest"; then + if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then + echo "[WARN] No manifest.sha256 at ref '$REF' — skipping integrity check (TRACEBLOC_ALLOW_UNVERIFIED=1)." >&2 + return 0 + fi + echo "[ERROR] Couldn't fetch manifest.sha256 for ref '$REF' — refusing to run" >&2 + echo " unverified installer scripts (RFC-0001 R8). If this ref pre-dates" >&2 + echo " signed manifests, pin a newer release tag." >&2 + exit 1 + fi + + # Authenticate the manifest itself (cosign keyless) before trusting a single + # digest in it. Fail-closed unless explicitly opted out. + verify_manifest_signature "$manifest" + + local f rel expected actual + for f in "${FILES[@]}"; do + rel="$f" # manifest keys are repo-relative: scripts/... + # Match the line whose LAST field is exactly this path (independent of how + # many spaces the sha tool emits); take its first field as the digest. + expected="$(awk -v p="$rel" '$NF == p {print $1; exit}' "$manifest")" + if [[ -z "$expected" ]]; then + echo "[ERROR] $rel has no entry in manifest.sha256 — refusing to run it (RFC-0001 R8)." >&2 + exit 1 + fi + actual="$(_sha256_of "$TMPDIR/${f#scripts/}")" + if [[ "$actual" != "$expected" ]]; then + echo "[ERROR] Integrity check FAILED for $rel" >&2 + echo " expected: $expected" >&2 + echo " actual: $actual" >&2 + echo " Someone may have tampered with the installer. Aborting before any" >&2 + echo " privileged step runs (RFC-0001 R8)." >&2 + exit 1 + fi + done + echo " ✓ all installer scripts verified against the signed manifest" +} + +download_manifest() { + local dest="$1" + # Authoritative source: the signed release asset. Fall back to the in-repo + # copy in the tag tree only under the unverified dev opt-in (a branch checkout + # has no release assets). + if curl -fsSL --tlsv1.2 "$REPO_REL/manifest.sha256" -o "$dest" 2>/dev/null; then + return 0 + fi + if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then + curl -fsSL --tlsv1.2 "$REPO_RAW/scripts/manifest.sha256" -o "$dest" 2>/dev/null + return $? + fi + return 1 +} + +# Verify manifest.sha256 with cosign keyless. The signing identity is the +# release workflow's OIDC certificate (same chain as the CLI binary). When +# cosign is missing we try to bootstrap a pinned one; if that fails we fail +# closed (never trust the manifest's digests without authenticating the +# manifest), unless the operator explicitly accepted the risk. +verify_manifest_signature() { + local manifest="$1" + local sig="$TMPDIR/manifest.sha256.sig" + local cert="$TMPDIR/manifest.sha256.cert" + + if ! ensure_cosign; then + if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then + echo "[WARN] cosign unavailable — manifest signature NOT verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." >&2 + echo "[WARN] Proceeding on checksum-only integrity. Not for production." >&2 + return 0 + fi + echo "[ERROR] cosign is required to verify the installer's signed manifest and" >&2 + echo " couldn't be found or bootstrapped. Refusing to fall back to an" >&2 + echo " unauthenticated, same-channel checksum (RFC-0001 R8)." >&2 + echo " Fix: install cosign (https://docs.sigstore.dev/cosign/installation/)" >&2 + echo " and re-run, or for local development only set TRACEBLOC_ALLOW_UNVERIFIED=1." >&2 + exit 1 + fi + + if ! curl -fsSL --tlsv1.2 "$REPO_REL/manifest.sha256.sig" -o "$sig" 2>/dev/null \ + || ! curl -fsSL --tlsv1.2 "$REPO_REL/manifest.sha256.cert" -o "$cert" 2>/dev/null; then + if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then + echo "[WARN] manifest signature/cert not published for ref '$REF' — not verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." >&2 + return 0 + fi + echo "[ERROR] manifest.sha256.sig / .cert not published for release '$REF' — can't" >&2 + echo " authenticate the manifest. Pin a release tag that ships them (RFC-0001 R8)." >&2 + exit 1 + fi + + if "$COSIGN_BIN" verify-blob \ + --certificate-identity-regexp \ + 'https://github.com/tracebloc/client/\.github/workflows/.*@.*' \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + --certificate "$cert" \ + --signature "$sig" \ + "$manifest" >/dev/null 2>&1; then + echo " ✓ manifest signature verified (cosign keyless)" + else + echo "[ERROR] cosign signature verification FAILED for manifest.sha256 — refusing" >&2 + echo " to install (RFC-0001 R8)." >&2 + exit 1 + fi +} + +# Resolve a usable cosign into $COSIGN_BIN. Prefer one already on PATH; else +# fetch the pinned release binary for this OS/arch from the cosign GitHub +# release and verify it against its published checksums before use (a cosign we +# can't vouch for is no better than no cosign). +COSIGN_BIN="" +COSIGN_VERSION="v2.4.1" # keep in lockstep with cli release.yml's cosign-installer pin +ensure_cosign() { + if command -v cosign >/dev/null 2>&1; then + COSIGN_BIN="cosign" + return 0 + fi + + local os arch + case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) return 1 ;; + esac + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) return 1 ;; + esac + + local base="https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}" + local asset="cosign-${os}-${arch}" + local bin="$TMPDIR/cosign" + local sums="$TMPDIR/cosign_checksums.txt" + + echo " cosign not found — bootstrapping pinned ${COSIGN_VERSION} to verify the manifest…" + curl -fsSL --tlsv1.2 "$base/$asset" -o "$bin" 2>/dev/null || return 1 + curl -fsSL --tlsv1.2 "$base/cosign_checksums.txt" -o "$sums" 2>/dev/null || return 1 + + local want got + want="$(grep " ${asset}\$" "$sums" | awk '{print $1}' | head -1)" + [[ -n "$want" ]] || return 1 + got="$(_sha256_of "$bin")" || return 1 + if [[ "$want" != "$got" ]]; then + echo "[ERROR] Bootstrapped cosign failed its own checksum — not using it." >&2 + return 1 + fi + chmod +x "$bin" + COSIGN_BIN="$bin" + return 0 +} + +verify_against_manifest + chmod +x "$TMPDIR/install-k8s.sh" echo "Running Tracebloc environment setup..." diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 new file mode 100644 index 0000000..b8d235a --- /dev/null +++ b/scripts/manifest.sha256 @@ -0,0 +1,15 @@ +43046b1047af86c51e805467874184030fbfe5f6de24416bde44a80fdd2ef1c1 scripts/install-k8s.sh +5bdcbae06d6fddca16ca616bdbd83ba68d4206361ea5c2e73c09ac492ea2dfa0 scripts/lib/common.sh +a370fc0c170f34f21d36d67e5434443b4ac8c671fba259826878b9840838b9c4 scripts/lib/preflight.sh +e14e4dfc43f0ac8648a01b09f597d8c6b9e3d3dfb5a2953b099de7a09b4c29f5 scripts/lib/detect-gpu.sh +ff17020ab0fe30bde430a54b33150084bb52c9543eabce8897706df94f0c48f5 scripts/lib/gpu-nvidia.sh +fd8d37d698c242ab081095625aa430bb91971db646e35393a02bbadd41325898 scripts/lib/gpu-amd.sh +2552776f16a7e697034a1ba8137cc87797ebfb8b29fee84f2aa6662fd6fefbbd scripts/lib/setup-macos.sh +2e64a2c47bdd14c861f8b5e86b06d1dfe4eb349ea6f76d9d22ecf3bb1ec12e04 scripts/lib/setup-linux.sh +dd2e1f517b0f6bb665eacebfb7885c6a81443f69c2ebc772433a6baed53c7a8e scripts/lib/cluster.sh +5de1afac8b1062a7f8d0691b46ad39ccdc2287e0e422b53c48628ecfd47d852e scripts/lib/gpu-plugins.sh +b469e666eb1c0c90c46540f5242456ddcc0bd7bca372cef0fcb8e8390ab94c84 scripts/lib/install-client-helm.sh +c5e0ed7aab268c91a131f09d753998cd5a2836a315b8d51428926f82387ad632 scripts/lib/install-cli.sh +d596c1f048cf9b40d5d4aaf57aca3846fa21af9a5ac083fb7e507fb6b78a8790 scripts/lib/provision.sh +58bd83c06ca16e1d9346700a3f6bcafc67f20130f0bc1f160d2faedab19fd2cf scripts/lib/summary.sh +f22b3f57722feaf3a05a8527988c4404c27b772dd8ba8d6d4c6fe08077081a4a scripts/lib/diagnose.sh diff --git a/scripts/tests/install-bootstrap.bats b/scripts/tests/install-bootstrap.bats new file mode 100644 index 0000000..d658456 --- /dev/null +++ b/scripts/tests/install-bootstrap.bats @@ -0,0 +1,205 @@ +#!/usr/bin/env bats +# Tests for scripts/install.sh — the curl|bash BOOTSTRAP (RFC-0001 R8, backend#889). +# +# The load-bearing security properties: +# 1. It only trusts an IMMUTABLE release tag; a mutable BRANCH ref fails closed +# unless TRACEBLOC_ALLOW_UNVERIFIED=1 is set explicitly. +# 2. Each sub-script is verified against a signed manifest; a tampered file or a +# file missing from the manifest ABORTS before install-k8s.sh runs. +# 3. The manifest signature is verified with cosign; on the default path a +# missing/failed signature fails closed (no degrade to same-channel sha256). +# +# install.sh is a standalone `curl | bash` entrypoint, not a lib of sourceable +# functions — so we exercise it as a subprocess with curl / cosign / sha-tools +# replaced by PATH shims, and a fake "repo" served from a temp dir. No network. +load test_helper + +BOOT="${BATS_TEST_DIRNAME}/../install.sh" + +# Build a sandbox: a fake bin/ on PATH (mock curl + cosign + sha256sum), and a +# "served" tree the mock curl maps URLs into. SERVE/ stands in for any +# URL ending in ; SERVE_REL/ for a release asset. +setup() { + SBX="$(mktemp -d)" + BIN="$SBX/bin"; SERVE="$SBX/serve"; SERVE_REL="$SBX/serve-rel" + mkdir -p "$BIN" "$SERVE/scripts/lib" "$SERVE_REL" + + # ---- Populate the "repo" with stand-in sub-scripts the bootstrap fetches ---- + # Each is trivial but real bash; install-k8s.sh is the privileged entrypoint — + # it writes a sentinel so a test can prove it was (or was NOT) reached. + for rel in install-k8s.sh \ + lib/common.sh lib/preflight.sh lib/detect-gpu.sh lib/gpu-nvidia.sh \ + lib/gpu-amd.sh lib/setup-macos.sh lib/setup-linux.sh lib/cluster.sh \ + lib/gpu-plugins.sh lib/install-client-helm.sh lib/install-cli.sh \ + lib/provision.sh lib/summary.sh lib/diagnose.sh; do + printf '#!/usr/bin/env bash\n# stub %s\n' "$rel" > "$SERVE/scripts/$rel" + done + cat > "$SERVE/scripts/install-k8s.sh" < "$SBX/k8s-ran" +EOF + + # ---- Build a manifest.sha256 over exactly those files (real digests) ------- + ( cd "$SERVE" && for f in \ + scripts/install-k8s.sh scripts/lib/common.sh scripts/lib/preflight.sh \ + scripts/lib/detect-gpu.sh scripts/lib/gpu-nvidia.sh scripts/lib/gpu-amd.sh \ + scripts/lib/setup-macos.sh scripts/lib/setup-linux.sh scripts/lib/cluster.sh \ + scripts/lib/gpu-plugins.sh scripts/lib/install-client-helm.sh \ + scripts/lib/install-cli.sh scripts/lib/provision.sh scripts/lib/summary.sh \ + scripts/lib/diagnose.sh; do + printf '%s %s\n' "$(_real_sha "$SERVE/$f")" "$f" + done ) > "$SERVE_REL/manifest.sha256" + printf 'FAKE-SIG\n' > "$SERVE_REL/manifest.sha256.sig" + printf 'FAKE-CERT\n' > "$SERVE_REL/manifest.sha256.cert" + + # ---- Mock curl: map any -o download from a known URL tail to the served file. + cat > "$BIN/curl" <&2; exit 22 ;; +esac +[ -f "\$src" ] || { echo "mock curl: 404 \$url" >&2; exit 22; } +if [ -n "\$out" ]; then cp "\$src" "\$out"; else cat "\$src"; fi +EOF + chmod +x "$BIN/curl" + + # ---- Mock cosign: succeed by default; flip via COSIGN_RESULT for the fail test. + cat > "$BIN/cosign" <<'EOF' +#!/usr/bin/env bash +exit "${COSIGN_RESULT:-0}" +EOF + chmod +x "$BIN/cosign" + + # Symlink the real shell utilities the bootstrap needs into $BIN, so a test + # can run with PATH=$BIN ALONE — that's the only reliable way to make cosign + # genuinely "absent" on a dev box that has a real /usr/local/bin/cosign (the + # host's cosign would otherwise shadow a removed shim). bash is invoked by + # path, but it re-resolves `command -v` against PATH, so the tools must be here. + for tool in bash sh env mkdir mktemp cp cat awk grep sed head tr uname chmod mv rm ln sleep printf install dirname basename sha256sum shasum; do + p="$(command -v "$tool" 2>/dev/null)" && ln -sf "$p" "$BIN/$tool" + done +} + +teardown() { rm -rf "$SBX"; } + +# A sha256 helper usable both in setup (host PATH) and assertions. +_real_sha() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'; + else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +# Run the bootstrap with our mock bin first on PATH, a stamped REF, and no real +# install-k8s.sh args. Keeps the real sha tools (we WANT genuine hashing). +run_boot() { + PATH="$BIN:$PATH" run bash "$BOOT" "$@" +} + +# Run with PATH=$BIN ALONE so the host's real cosign can't shadow a removed +# shim — the only reliable way to simulate "cosign genuinely absent". $BIN has +# the needed coreutils symlinked in setup(); the sha tools come along for free. +run_boot_no_cosign() { + rm -f "$BIN/cosign" + PATH="$BIN" run bash "$BOOT" "$@" +} + +@test "mutable BRANCH ref fails closed without the opt-in" { + REF="" BRANCH="develop" run_boot + [ "$status" -ne 0 ] + [[ "$output" == *"not an immutable release tag"* ]] + [ ! -f "$SBX/k8s-ran" ] # never reached the privileged step +} + +@test "path-traversal ref disguised as a tag fails closed without the opt-in" { + # 'v1.2.3-../../heads/main' once passed the tag gate (trailer was ([.-].+)?), + # so curl collapsed the '..' and fetched sub-scripts off the MUTABLE 'main' + # branch — the immutable-tag pin bypassed with no opt-in (RFC-0001 R8). It must + # now be REJECTED: exit non-zero, never fetch, never reach the privileged step. + REF="v1.2.3-../../heads/main" COSIGN_RESULT=0 run_boot + [ "$status" -ne 0 ] + # Either the tag-shape gate or the path-separator belt rejects it; both name R8. + [[ "$output" == *"not an immutable release tag"* \ + || "$output" == *"path separator or '..'"* ]] + [ ! -f "$SBX/k8s-ran" ] # privileged step never reached +} + +@test "tag with a bare path separator fails closed without the opt-in" { + # A '/' in the ref (e.g. a heads/ ref dressed as a tag) is a traversal lever + # into a mutable location; reject it like the '..' case above. + REF="v1.2.3/heads/main" COSIGN_RESULT=0 run_boot + [ "$status" -ne 0 ] + [[ "$output" == *"not an immutable release tag"* \ + || "$output" == *"path separator or '..'"* ]] + [ ! -f "$SBX/k8s-ran" ] +} + +@test "un-stamped DEFAULT_REF fails closed (placeholder still present)" { + # The committed install.sh ships the __TRACEBLOC_RELEASE_REF__ placeholder; + # running it directly (no REF/BRANCH) must refuse rather than guess. + run_boot + [ "$status" -ne 0 ] + [[ "$output" == *"wasn't stamped with a pinned release tag"* ]] + [ ! -f "$SBX/k8s-ran" ] +} + +@test "happy path: immutable tag + valid manifest + good signature runs install-k8s.sh" { + REF="v9.9.9" COSIGN_RESULT=0 run_boot + [ "$status" -eq 0 ] + [[ "$output" == *"verified against the signed manifest"* ]] + [ -f "$SBX/k8s-ran" ] # privileged step reached only after verify +} + +@test "tampered sub-script aborts before the privileged step" { + # Mutate a fetched file AFTER the manifest was built → digest mismatch. + echo "rm -rf / # evil" >> "$SERVE/scripts/lib/provision.sh" + REF="v9.9.9" COSIGN_RESULT=0 run_boot + [ "$status" -ne 0 ] + [[ "$output" == *"Integrity check FAILED"* ]] + [[ "$output" == *"provision.sh"* ]] + [ ! -f "$SBX/k8s-ran" ] +} + +@test "a file missing from the manifest aborts" { + # Drop provision.sh's line from the manifest → no expected digest for it. + grep -v 'scripts/lib/provision.sh' "$SERVE_REL/manifest.sha256" > "$SERVE_REL/m.tmp" + mv "$SERVE_REL/m.tmp" "$SERVE_REL/manifest.sha256" + REF="v9.9.9" COSIGN_RESULT=0 run_boot + [ "$status" -ne 0 ] + [[ "$output" == *"no entry in manifest"* ]] + [ ! -f "$SBX/k8s-ran" ] +} + +@test "cosign signature failure aborts (no degrade to same-channel sha256)" { + REF="v9.9.9" COSIGN_RESULT=1 run_boot + [ "$status" -ne 0 ] + [[ "$output" == *"signature verification FAILED"* ]] + [ ! -f "$SBX/k8s-ran" ] +} + +@test "cosign absent on default path fails closed (can't bootstrap in sandbox)" { + # cosign genuinely absent (PATH=$BIN only). The cosign download is unmapped in + # mock curl (exit 22), so ensure_cosign fails → fail-closed on the default path. + REF="v9.9.9" run_boot_no_cosign + [ "$status" -ne 0 ] + [[ "$output" == *"cosign is required"* ]] + [ ! -f "$SBX/k8s-ran" ] +} + +@test "unverified opt-in degrades gracefully when cosign is absent" { + REF="v9.9.9" TRACEBLOC_ALLOW_UNVERIFIED=1 run_boot_no_cosign + [ "$status" -eq 0 ] + [[ "$output" == *"manifest signature NOT verified"* ]] + [ -f "$SBX/k8s-ran" ] # checksum integrity still enforced; runs +}