diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 9e5292b5..cc0baab6 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -32,6 +32,10 @@ jobs: wheel-platform: win_amd64 runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write + attestations: write steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -92,12 +96,46 @@ jobs: tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}" fi + # Sigstore keyless provenance (GitHub OIDC). Attest the published archive + # and wheel on the runner that built them so the installer can pin + # usestrix/strix/.github/workflows/build-release.yml. The bundle is also + # copied into dist/release/ so it ships as a release asset for manual / + # offline verification (#1267). + - name: Attest Unix archive and wheel + id: attest-unix + if: runner.os != 'Windows' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + dist/release/*.tar.gz + dist/*.whl + + - name: Attest Windows archive and wheel + id: attest-windows + if: runner.os == 'Windows' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + dist/release/*.zip + dist/*.whl + + - name: Publish attestation bundle + shell: bash + env: + # Only one of the attest steps runs; the other output is empty. + BUNDLE_PATH: ${{ steps.attest-unix.outputs.bundle-path }}${{ steps.attest-windows.outputs.bundle-path }} + run: | + test -n "$BUNDLE_PATH" + test -f "$BUNDLE_PATH" + cp "$BUNDLE_PATH" "dist/release/strix-${{ matrix.target }}.intoto.jsonl" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: strix-${{ matrix.target }} path: | dist/release/*.tar.gz dist/release/*.zip + dist/release/*.intoto.jsonl dist/*.whl if-no-files-found: error @@ -106,6 +144,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + id-token: write + attestations: write steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -113,9 +153,65 @@ jobs: path: release merge-multiple: true + # Same-origin integrity manifest for product assets (archives + wheels). + # Provenance bundles are published alongside the release but excluded here + # so SHA256SUMS stays a stable product-byte list the installer greps. + - name: Generate SHA256SUMS + working-directory: release + shell: bash + run: | + : > SHA256SUMS + while IFS= read -r -d '' f; do + ( cd "$(dirname "$f")" && sha256sum "$(basename "$f")" ) + done < <(find . -type f ! -name SHA256SUMS ! -name '*.intoto.jsonl' -print0 | sort -z) >> SHA256SUMS + echo "=== SHA256SUMS ===" + cat SHA256SUMS + + - name: Attest SHA256SUMS + id: attest-sums + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: release/SHA256SUMS + + - name: Publish SHA256SUMS attestation bundle + shell: bash + env: + BUNDLE_PATH: ${{ steps.attest-sums.outputs.bundle-path }} + run: | + test -n "$BUNDLE_PATH" + test -f "$BUNDLE_PATH" + cp "$BUNDLE_PATH" release/SHA256SUMS.intoto.jsonl + - name: Create Release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }} generate_release_notes: true + # Prepended to the generated changelog. Keep in sync with + # docs/quickstart.mdx "Verify a downloaded release". + body: | + ## Verify this release + + After downloading your platform archive from this release: + + ```bash + # Example for linux-x86_64 — set ARCHIVE to the asset you downloaded. + TARGET=linux-x86_64 + ARCHIVE=strix-VERSION-${TARGET}.tar.gz # VERSION without the leading v + + curl -fsSLO "https://github.com/usestrix/strix/releases/download/${{ github.ref_name }}/SHA256SUMS" + curl -fsSLO "https://github.com/usestrix/strix/releases/download/${{ github.ref_name }}/strix-${TARGET}.intoto.jsonl" + + sha256sum -c --ignore-missing SHA256SUMS + + gh attestation verify "${ARCHIVE}" \ + --repo usestrix/strix \ + --bundle "strix-${TARGET}.intoto.jsonl" \ + --signer-workflow usestrix/strix/.github/workflows/build-release.yml \ + --predicate-type https://slsa.dev/provenance/v1 \ + --deny-self-hosted-runners + ``` + + Full instructions (including `cosign`): https://docs.strix.ai/quickstart#verify-a-downloaded-release + # Includes SHA256SUMS and *.intoto.jsonl provenance bundles. files: release/** diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index dcd2e7c5..48236e6c 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -7,6 +7,7 @@ description: "Install Strix and run your first security scan" - Docker (running) - An LLM API key from any [supported provider](/llm-providers/overview) (OpenAI, Anthropic, Google, etc.) +- For the curl installer: [GitHub CLI](https://cli.github.com/) (`gh`) or [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) — used to verify Sigstore provenance before extract ## Installation @@ -15,6 +16,14 @@ description: "Install Strix and run your first security scan" ```bash curl -sSL https://strix.ai/install | bash ``` + + The installer downloads the release archive, checks `SHA256SUMS`, then verifies + Sigstore provenance with `gh` or `cosign` before extracting. To skip those checks + (not recommended): + + ```bash + curl -sSL https://strix.ai/install | STRIX_INSTALL_SKIP_VERIFY=1 bash + ``` ```bash @@ -23,6 +32,44 @@ description: "Install Strix and run your first security scan" +## Verify a downloaded release + +If you download assets from GitHub Releases yourself: + +```bash +# Replace VERSION and TARGET (e.g. 1.6.2 and linux-x86_64). +VERSION=1.6.2 +TARGET=linux-x86_64 +ARCHIVE="strix-${VERSION}-${TARGET}.tar.gz" + +curl -fsSLO "https://github.com/usestrix/strix/releases/download/v${VERSION}/${ARCHIVE}" +curl -fsSLO "https://github.com/usestrix/strix/releases/download/v${VERSION}/SHA256SUMS" +curl -fsSLO "https://github.com/usestrix/strix/releases/download/v${VERSION}/strix-${TARGET}.intoto.jsonl" + +# 1) Integrity +sha256sum -c --ignore-missing SHA256SUMS + +# 2) Provenance (GitHub CLI) +gh attestation verify "${ARCHIVE}" \ + --repo usestrix/strix \ + --bundle "strix-${TARGET}.intoto.jsonl" \ + --signer-workflow usestrix/strix/.github/workflows/build-release.yml \ + --predicate-type https://slsa.dev/provenance/v1 \ + --deny-self-hosted-runners +``` + +With `cosign` instead of `gh`: + +```bash +cosign verify-blob-attestation \ + --bundle "strix-${TARGET}.intoto.jsonl" \ + --new-bundle-format \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github.com/usestrix/strix/.github/workflows/build-release.yml' \ + --type slsaprovenance1 \ + "${ARCHIVE}" +``` + ## Configuration Set your LLM provider: diff --git a/scripts/install.sh b/scripts/install.sh index e179b558..c6f98e5e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -85,6 +85,12 @@ fi filename="$APP-${specific_version}-${target}${archive_ext}" url="https://github.com/$REPO/releases/download/v${specific_version}/$filename" +sums_name="SHA256SUMS" +sums_url="https://github.com/$REPO/releases/download/v${specific_version}/$sums_name" +bundle_name="strix-${target}.intoto.jsonl" +bundle_url="https://github.com/$REPO/releases/download/v${specific_version}/$bundle_name" +SIGNER_WORKFLOW="$REPO/.github/workflows/build-release.yml" +CERT_IDENTITY_REGEXP="^https://github.com/${REPO}/.github/workflows/build-release.yml" print_message() { local level=$1 @@ -99,6 +105,9 @@ print_message() { echo -e "${color}${message}${NC}" } +# Remove other copies of strix from PATH (pipx, leftover binaries) only after +# a verified install has been written to INSTALL_DIR. Calling this earlier +# would leave the user with no working Strix if checksum/provenance then fail. check_existing_installation() { local found_paths=() while IFS= read -r -d '' path; do @@ -129,9 +138,129 @@ check_existing_installation() { fi } -check_version() { - check_existing_installation +abort_unverified() { + echo -e "${RED}✗ Refusing to install an unverified binary.${NC}" + if [[ -x "$INSTALL_DIR/strix" || -x "$INSTALL_DIR/strix.exe" ]]; then + echo -e "${MUTED}Existing Strix installation left unchanged.${NC}" + fi + echo -e "${RED}Re-run with: curl -sSL https://strix.ai/install | STRIX_INSTALL_SKIP_VERIFY=1 bash${NC}" + echo -e "${MUTED}(at your own risk)${NC}" + exit 1 +} +# Fail-closed checksum check against the published SHA256SUMS manifest. +# Same-origin only (detects corruption / single-asset swap). Exact field +# match — do not grep the filename as a regex ('.' would be wild). +verify_checksum() { + local file=$1 + + if [ -n "${STRIX_INSTALL_SKIP_VERIFY:-}" ]; then + echo -e "${YELLOW}⚠ STRIX_INSTALL_SKIP_VERIFY set — skipping checksum verification (at your own risk).${NC}" + return 0 + fi + + local sha_cmd="" + if command -v sha256sum >/dev/null 2>&1; then + sha_cmd="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + sha_cmd="shasum -a 256" + else + echo -e "${RED}✗ Neither 'sha256sum' nor 'shasum' is available; cannot verify integrity.${NC}" + abort_unverified + fi + + if [ ! -s "$sums_name" ]; then + echo -e "${RED}✗ Missing checksum manifest ${sums_name}.${NC}" + abort_unverified + fi + + echo -e "${MUTED}Verifying checksum...${NC}" + + local expected + expected=$(awk -v file="$file" ' + $2 == file || $2 == ("*" file) { print $1; exit } + ' "$sums_name") + if [ -z "$expected" ]; then + echo -e "${RED}✗ No SHA256SUMS entry for ${file}.${NC}" + abort_unverified + fi + + local actual + actual=$($sha_cmd "$file" | awk '{print $1}') + if [ "$actual" != "$expected" ]; then + echo -e "${RED}✗ Checksum mismatch for ${file}.${NC}" + echo -e "${MUTED}Expected: ${NC}$expected" + echo -e "${MUTED}Actual: ${NC}$actual" + abort_unverified + fi + + echo -e "${GREEN}✓ Checksum verified${NC}" +} + +gh_can_verify_attestation() { + command -v gh >/dev/null 2>&1 && gh attestation verify --help >/dev/null 2>&1 +} + +cosign_can_verify_attestation() { + command -v cosign >/dev/null 2>&1 && cosign verify-blob-attestation --help >/dev/null 2>&1 +} + +# Fail-closed provenance check. The bundle is signed Sigstore SLSA provenance +# for this workflow; checksum verification (same-origin) is a separate step. +# Prefer `gh attestation verify`; otherwise a local `cosign`. We do not +# bootstrap cosign — current releases are ~140MB, which is too heavy for +# curl|bash. Missing verifier or a failed check aborts before extract. +verify_provenance() { + local file=$1 + local bundle=$2 + + if [ -n "${STRIX_INSTALL_SKIP_VERIFY:-}" ]; then + echo -e "${YELLOW}⚠ STRIX_INSTALL_SKIP_VERIFY set — skipping provenance verification (at your own risk).${NC}" + return 0 + fi + + if [ ! -s "$bundle" ]; then + echo -e "${RED}✗ Missing provenance bundle ${bundle}.${NC}" + abort_unverified + fi + + echo -e "${MUTED}Verifying Sigstore provenance...${NC}" + + if gh_can_verify_attestation; then + if gh attestation verify "$file" \ + --repo "$REPO" \ + --bundle "$bundle" \ + --signer-workflow "$SIGNER_WORKFLOW" \ + --predicate-type "https://slsa.dev/provenance/v1" \ + --deny-self-hosted-runners; then + echo -e "${GREEN}✓ Provenance verified${NC} ${MUTED}(gh)${NC}" + return 0 + fi + echo -e "${RED}✗ gh attestation verify failed.${NC}" + abort_unverified + fi + + if cosign_can_verify_attestation; then + if cosign verify-blob-attestation \ + --bundle "$bundle" \ + --new-bundle-format \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + --certificate-identity-regexp "$CERT_IDENTITY_REGEXP" \ + --type slsaprovenance1 \ + "$file"; then + echo -e "${GREEN}✓ Provenance verified${NC} ${MUTED}(cosign)${NC}" + return 0 + fi + echo -e "${RED}✗ cosign verify-blob-attestation failed.${NC}" + abort_unverified + fi + + echo -e "${RED}✗ Neither a usable 'gh' nor 'cosign' was found; cannot verify provenance.${NC}" + echo -e "${MUTED}Install GitHub CLI (gh) or cosign, then re-run.${NC}" + abort_unverified +} + +check_version() { if [[ -x "$INSTALL_DIR/strix" ]]; then installed_version=$("$INSTALL_DIR/strix" --version 2>/dev/null | awk '{print $2}' || echo "") if [[ "$installed_version" == "$specific_version" ]]; then @@ -147,7 +276,20 @@ download_and_install() { print_message info "\n${CYAN}🦉 Installing Strix${NC} ${MUTED}version: ${NC}$specific_version" print_message info "${MUTED}Platform: ${NC}$target\n" - local tmp_dir=$(mktemp -d) + local tmp_dir + tmp_dir=$(mktemp -d) + + # Never leave a half-written binary in INSTALL_DIR. Stage to *.new and only + # rename into place after a verified archive has been extracted. On any + # abort (including verification failure), remove the staging file and the + # download temp dir; the current install stays untouched. + cleanup_install_temps() { + cd / >/dev/null 2>&1 || true + rm -rf "$tmp_dir" + rm -f "$INSTALL_DIR/strix.new" "$INSTALL_DIR/strix.exe.new" + } + trap cleanup_install_temps EXIT + cd "$tmp_dir" echo -e "${MUTED}Downloading...${NC}" @@ -158,20 +300,41 @@ download_and_install() { exit 1 fi + if [ -n "${STRIX_INSTALL_SKIP_VERIFY:-}" ]; then + echo -e "${YELLOW}⚠ STRIX_INSTALL_SKIP_VERIFY set — skipping checksum and provenance checks.${NC}" + else + echo -e "${MUTED}Downloading checksums...${NC}" + if ! curl -sfL -o "$sums_name" "$sums_url" || [ ! -s "$sums_name" ]; then + echo -e "${RED}✗ Failed to download checksum manifest.${NC}" + abort_unverified + fi + verify_checksum "$filename" + + echo -e "${MUTED}Downloading provenance...${NC}" + if ! curl -sfL -o "$bundle_name" "$bundle_url" || [ ! -s "$bundle_name" ]; then + echo -e "${RED}✗ Failed to download provenance bundle.${NC}" + abort_unverified + fi + verify_provenance "$filename" "$bundle_name" + fi + echo -e "${MUTED}Extracting...${NC}" if [ "$os" = "windows" ]; then unzip -q "$filename" - mv "strix-${specific_version}-${target}.exe" "$INSTALL_DIR/strix.exe" + mv "strix-${specific_version}-${target}.exe" "$INSTALL_DIR/strix.exe.new" + mv -f "$INSTALL_DIR/strix.exe.new" "$INSTALL_DIR/strix.exe" else tar -xzf "$filename" - mv "strix-${specific_version}-${target}" "$INSTALL_DIR/strix" - chmod 755 "$INSTALL_DIR/strix" + mv "strix-${specific_version}-${target}" "$INSTALL_DIR/strix.new" + chmod 755 "$INSTALL_DIR/strix.new" + mv -f "$INSTALL_DIR/strix.new" "$INSTALL_DIR/strix" fi - cd - > /dev/null - rm -rf "$tmp_dir" + trap - EXIT + cleanup_install_temps echo -e "${GREEN}✓ Strix installed to $INSTALL_DIR${NC}" + check_existing_installation } check_docker() { diff --git a/tests/test_build_release_workflow.py b/tests/test_build_release_workflow.py new file mode 100644 index 00000000..9e7edbd8 --- /dev/null +++ b/tests/test_build_release_workflow.py @@ -0,0 +1,191 @@ +"""Structural checks for release signing + checksum publishing (#1267).""" + +from __future__ import annotations + +import hashlib +import re +import subprocess +from pathlib import Path + +import pytest +import yaml + + +WORKFLOW = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "build-release.yml" +ATTEST_PIN = "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6" + + +def _workflow() -> dict[str, object]: + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + +def _job(name: str) -> dict[str, object]: + jobs = _workflow()["jobs"] + assert isinstance(jobs, dict) + job = jobs[name] + assert isinstance(job, dict) + return job + + +def _steps(job_name: str) -> list[dict[str, object]]: + steps = _job(job_name)["steps"] + assert isinstance(steps, list) + return [step for step in steps if isinstance(step, dict)] + + +def _step_named(job_name: str, name: str) -> dict[str, object]: + for step in _steps(job_name): + if step.get("name") == name: + return step + raise AssertionError(f"missing step {name!r} in job {job_name!r}") + + +def test_build_job_requests_attestation_permissions() -> None: + permissions = _job("build")["permissions"] + assert permissions == { + "contents": "read", + "id-token": "write", + "attestations": "write", + } + + +def test_release_job_requests_attestation_permissions() -> None: + permissions = _job("release")["permissions"] + assert permissions == { + "contents": "write", + "id-token": "write", + "attestations": "write", + } + + +def test_build_attests_unix_and_windows_archives_with_pinned_action() -> None: + unix = _step_named("build", "Attest Unix archive and wheel") + windows = _step_named("build", "Attest Windows archive and wheel") + + assert unix["uses"] == ATTEST_PIN + assert unix["id"] == "attest-unix" + assert unix["if"] == "runner.os != 'Windows'" + assert "dist/release/*.tar.gz" in str(unix["with"]["subject-path"]) + assert "dist/*.whl" in str(unix["with"]["subject-path"]) + + assert windows["uses"] == ATTEST_PIN + assert windows["id"] == "attest-windows" + assert windows["if"] == "runner.os == 'Windows'" + assert "dist/release/*.zip" in str(windows["with"]["subject-path"]) + assert "dist/*.whl" in str(windows["with"]["subject-path"]) + + +def test_build_publishes_and_uploads_per_target_attestation_bundle() -> None: + publish = _step_named("build", "Publish attestation bundle") + assert "strix-${{ matrix.target }}.intoto.jsonl" in str(publish["run"]) + assert "attest-unix.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"]) + assert "attest-windows.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"]) + + upload = next( + step for step in _steps("build") if "upload-artifact@" in str(step.get("uses", "")) + ) + paths = str(upload["with"]["path"]) + assert "dist/release/*.intoto.jsonl" in paths + assert "dist/release/*.tar.gz" in paths + assert "dist/*.whl" in paths + + +def test_release_generates_sha256sums_excluding_provenance_bundles() -> None: + generate = _step_named("release", "Generate SHA256SUMS") + assert generate["working-directory"] == "release" + script = str(generate["run"]) + assert "SHA256SUMS" in script + assert "! -name '*.intoto.jsonl'" in script + assert "sha256sum" in script + + +def test_release_attests_and_publishes_sha256sums_bundle() -> None: + attest = _step_named("release", "Attest SHA256SUMS") + assert attest["uses"] == ATTEST_PIN + assert attest["id"] == "attest-sums" + assert attest["with"]["subject-path"] == "release/SHA256SUMS" + + publish = _step_named("release", "Publish SHA256SUMS attestation bundle") + assert "SHA256SUMS.intoto.jsonl" in str(publish["run"]) + assert "attest-sums.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"]) + + create = _step_named("release", "Create Release") + assert create["with"]["files"] == "release/**" + + +def test_every_action_use_is_sha_pinned() -> None: + text = WORKFLOW.read_text(encoding="utf-8") + uses = re.findall(r"^\s+uses:\s+(\S+)", text, flags=re.MULTILINE) + assert uses + for ref in uses: + assert "@" in ref, ref + digest = ref.rsplit("@", 1)[1] + assert re.fullmatch(r"[0-9a-f]{40}", digest), ref + + +def test_sha256sums_script_hashes_product_files_only(tmp_path: Path) -> None: + """Run the release job's checksum recipe against a fixture tree.""" + release_dir = tmp_path / "release" + release_dir.mkdir() + (release_dir / "strix-1.0.0-linux-x86_64.tar.gz").write_bytes(b"archive-bytes") + (release_dir / "strix_agent-1.0.0-py3-none-any.whl").write_bytes(b"wheel-bytes") + (release_dir / "strix-linux-x86_64.intoto.jsonl").write_text( + '{"attestation":true}\n', + encoding="utf-8", + ) + + script = str(_step_named("release", "Generate SHA256SUMS")["run"]) + result = subprocess.run( # noqa: S603 + ["/bin/bash", "-c", script], + cwd=release_dir, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + sums_path = release_dir / "SHA256SUMS" + assert sums_path.is_file() + lines = [line for line in sums_path.read_text(encoding="utf-8").splitlines() if line.strip()] + names = {line.split()[-1] for line in lines} + assert names == { + "strix-1.0.0-linux-x86_64.tar.gz", + "strix_agent-1.0.0-py3-none-any.whl", + } + assert "strix-linux-x86_64.intoto.jsonl" not in names + assert "SHA256SUMS" not in names + + for line in lines: + digest, name = line.split() + expected = hashlib.sha256((release_dir / name).read_bytes()).hexdigest() + assert digest == expected + + +def test_release_notes_include_manual_verification() -> None: + create = _step_named("release", "Create Release") + body = str(create["with"]["body"]) + assert "## Verify this release" in body + assert "sha256sum -c --ignore-missing SHA256SUMS" in body + assert "gh attestation verify" in body + assert "usestrix/strix/.github/workflows/build-release.yml" in body + assert "docs.strix.ai/quickstart#verify-a-downloaded-release" in body + assert create["with"]["generate_release_notes"] is True + + +@pytest.mark.parametrize( + ("job_name", "step_name"), + [ + ("build", "Attest Unix archive and wheel"), + ("build", "Attest Windows archive and wheel"), + ("release", "Attest SHA256SUMS"), + ], +) +def test_attest_steps_comment_mentions_pinned_version( + job_name: str, + step_name: str, +) -> None: + # Keep the SHA pin and the human version comment in sync with checkout/etc. + text = WORKFLOW.read_text(encoding="utf-8") + assert "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2" in text + step = _step_named(job_name, step_name) + assert step["uses"] == ATTEST_PIN diff --git a/tests/test_install_script.py b/tests/test_install_script.py index 67ca4843..68b1a0c2 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -48,20 +48,86 @@ esac """, ) _write_executable(mock_bin / "docker", "#!/bin/sh\nexit 0\n") + # Shadow any real gh/cosign on PATH. Behaviour is driven by env flags. + _write_executable( + mock_bin / "gh", + """#!/bin/sh +if [ -n "${STRIX_TEST_NO_VERIFIER:-}" ]; then + exit 1 +fi +if [ "$1" = "attestation" ] && [ "$2" = "verify" ]; then + if [ "$3" = "--help" ]; then + exit 0 + fi + printf '%s\\n' "$*" >> "$STRIX_TEST_GH_LOG" + if [ -n "${STRIX_TEST_GH_FAIL_VERIFY:-}" ]; then + exit 1 + fi + exit 0 +fi +exit 1 +""", + ) + _write_executable( + mock_bin / "cosign", + """#!/bin/sh +if [ -n "${STRIX_TEST_NO_VERIFIER:-}" ]; then + exit 1 +fi +if [ "$1" = "verify-blob-attestation" ]; then + if [ "$2" = "--help" ]; then + [ -n "${STRIX_TEST_USE_COSIGN:-}" ] && exit 0 + exit 1 + fi + printf '%s\\n' "$*" >> "$STRIX_TEST_COSIGN_LOG" + [ -n "${STRIX_TEST_USE_COSIGN:-}" ] && exit 0 + exit 1 +fi +exit 1 +""", + ) _write_executable( mock_bin / "curl", """#!/bin/sh output="" +url="" while [ "$#" -gt 0 ]; do if [ "$1" = "-o" ]; then output="$2" shift 2 continue fi + case "$1" in + http://*|https://*) url="$1" ;; + esac printf '%s\\n' "$1" >> "$STRIX_TEST_CURL_LOG" shift done -cp "$STRIX_TEST_ARCHIVE" "$output" +if [ -n "${STRIX_TEST_CURL_FAIL:-}" ]; then + case "$url" in + *"$STRIX_TEST_CURL_FAIL"*) exit 22 ;; + esac +fi +case "$url" in + */SHA256SUMS) + name=$(basename "$STRIX_TEST_ARCHIVE") + if [ -n "${STRIX_TEST_BAD_CHECKSUM:-}" ]; then + printf '%s %s\\n' "0" "$name" > "$output" + elif [ -n "${STRIX_TEST_SUMS_WRONG_NAME:-}" ]; then + hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}') + printf '%s %s\\n' "$hash" "other-file.tar.gz" > "$output" + else + hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}') + printf '%s %s\\n' "$hash" "$name" > "$output" + fi + ;; + *.intoto.jsonl) + printf '{"test":true}\\n' > "$output" + ;; + *) + cp "$STRIX_TEST_ARCHIVE" "$output" + ;; +esac """, ) return mock_bin @@ -83,6 +149,8 @@ def _create_installer_environment( download_path = tmp_path / "downloads" download_path.mkdir() curl_log_path = tmp_path / "curl.log" + gh_log_path = tmp_path / "gh.log" + cosign_log_path = tmp_path / "cosign.log" environment = { "HOME": str(home_path), "XDG_CONFIG_HOME": str(home_path / ".config"), @@ -91,6 +159,8 @@ def _create_installer_environment( "TMPDIR": str(download_path), "STRIX_TEST_ARCHIVE": str(archive_path), "STRIX_TEST_CURL_LOG": str(curl_log_path), + "STRIX_TEST_GH_LOG": str(gh_log_path), + "STRIX_TEST_COSIGN_LOG": str(cosign_log_path), "VERSION": RELEASE_VERSION, } return environment, home_path, curl_log_path @@ -124,7 +194,18 @@ def test_installer_downloads_and_runs_linux_arm64_release(tmp_path: Path) -> Non assert result.returncode == 0, result.stderr expected_filename = f"strix-{RELEASE_VERSION}-{RELEASE_TARGET}.tar.gz" - assert expected_filename in curl_log_path.read_text(encoding="utf-8") + curl_log = curl_log_path.read_text(encoding="utf-8") + assert expected_filename in curl_log + assert f"releases/download/v{RELEASE_VERSION}/SHA256SUMS" in curl_log + assert f"strix-{RELEASE_TARGET}.intoto.jsonl" in curl_log + assert "Checksum verified" in result.stdout + assert "Provenance verified" in result.stdout + gh_log = (tmp_path / "gh.log").read_text(encoding="utf-8") + assert "--repo usestrix/strix" in gh_log + assert "--bundle strix-linux-arm64.intoto.jsonl" in gh_log + assert "--signer-workflow usestrix/strix/.github/workflows/build-release.yml" in gh_log + assert "--deny-self-hosted-runners" in gh_log + assert not (tmp_path / "cosign.log").exists() installed_binary = home_path / ".strix/bin/strix" installed_result = subprocess.run( # noqa: S603 @@ -152,3 +233,216 @@ def test_installer_rejects_unsupported_architecture(tmp_path: Path) -> None: assert "Unsupported OS/Arch: linux/riscv64" in result.stdout assert not curl_log_path.exists() assert not (home_path / ".strix").exists() + + +def test_installer_leaves_external_strix_on_checksum_failure(tmp_path: Path) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="aarch64") + environment, home_path, _curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + environment["STRIX_TEST_BAD_CHECKSUM"] = "1" + + other_bin = tmp_path / "other-bin" + other_bin.mkdir() + external = other_bin / "strix" + _write_executable(external, "#!/bin/sh\nprintf 'strix 1.0.0\\n'\n") + environment["PATH"] = f"{other_bin}:{environment['PATH']}" + before = external.read_bytes() + + result = _run_installer(repository_root, environment) + + assert result.returncode != 0 + assert "Checksum mismatch" in result.stdout + assert external.exists() + assert external.read_bytes() == before + assert not (home_path / ".strix/bin/strix").exists() + installed_result = subprocess.run( # noqa: S603 + [str(external), "--version"], + capture_output=True, + text=True, + check=True, + ) + assert installed_result.stdout.strip() == "strix 1.0.0" + + +def test_installer_leaves_existing_install_on_checksum_failure(tmp_path: Path) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="aarch64") + environment, home_path, _curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + environment["STRIX_TEST_BAD_CHECKSUM"] = "1" + + install_dir = home_path / ".strix" / "bin" + install_dir.mkdir(parents=True) + existing = install_dir / "strix" + _write_executable(existing, "#!/bin/sh\nprintf 'strix 1.0.0\\n'\n") + before = existing.read_bytes() + + result = _run_installer(repository_root, environment) + + assert result.returncode != 0 + assert "Checksum mismatch" in result.stdout + assert "Existing Strix installation left unchanged" in result.stdout + assert existing.read_bytes() == before + assert not (install_dir / "strix.new").exists() + installed_result = subprocess.run( # noqa: S603 + [str(existing), "--version"], + capture_output=True, + text=True, + check=True, + ) + assert installed_result.stdout.strip() == "strix 1.0.0" + + +def test_installer_rejects_missing_checksum_manifest(tmp_path: Path) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="aarch64") + environment, home_path, _curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + environment["STRIX_TEST_CURL_FAIL"] = "SHA256SUMS" + + result = _run_installer(repository_root, environment) + + assert result.returncode != 0 + assert "Failed to download checksum manifest" in result.stdout + assert not (home_path / ".strix/bin/strix").exists() + + +def test_installer_rejects_checksum_manifest_without_archive_entry( + tmp_path: Path, +) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="aarch64") + environment, home_path, _curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + environment["STRIX_TEST_SUMS_WRONG_NAME"] = "1" + + result = _run_installer(repository_root, environment) + + assert result.returncode != 0 + assert "No SHA256SUMS entry" in result.stdout + assert not (home_path / ".strix/bin/strix").exists() + + +def test_installer_rejects_missing_provenance_bundle(tmp_path: Path) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="aarch64") + environment, home_path, _curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + environment["STRIX_TEST_CURL_FAIL"] = "intoto.jsonl" + + result = _run_installer(repository_root, environment) + + assert result.returncode != 0 + assert "Failed to download provenance bundle" in result.stdout + assert "Checksum verified" in result.stdout + assert not (home_path / ".strix/bin/strix").exists() + + +def test_installer_rejects_failed_gh_attestation_verify(tmp_path: Path) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="aarch64") + environment, home_path, _curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + environment["STRIX_TEST_GH_FAIL_VERIFY"] = "1" + + result = _run_installer(repository_root, environment) + + assert result.returncode != 0 + assert "gh attestation verify failed" in result.stdout + assert not (home_path / ".strix/bin/strix").exists() + assert not (tmp_path / "cosign.log").exists() + + +def test_installer_uses_cosign_when_gh_cannot_verify(tmp_path: Path) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="aarch64") + environment, home_path, _curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + environment["STRIX_TEST_USE_COSIGN"] = "1" + _write_executable( + mock_bin / "gh", + "#!/bin/sh\nexit 1\n", + ) + + result = _run_installer(repository_root, environment) + + assert result.returncode == 0, result.stderr + assert "Provenance verified" in result.stdout + assert "(cosign)" in result.stdout + cosign_log = (tmp_path / "cosign.log").read_text(encoding="utf-8") + assert "--new-bundle-format" in cosign_log + assert "--certificate-oidc-issuer https://token.actions.githubusercontent.com" in (cosign_log) + assert "build-release.yml" in cosign_log + assert "--type slsaprovenance1" in cosign_log + assert (home_path / ".strix/bin/strix").exists() + + +def test_installer_rejects_when_no_provenance_verifier_is_available( + tmp_path: Path, +) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="aarch64") + environment, home_path, _curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + environment["STRIX_TEST_NO_VERIFIER"] = "1" + + result = _run_installer(repository_root, environment) + + assert result.returncode != 0 + assert "Neither a usable 'gh' nor 'cosign'" in result.stdout + assert not (home_path / ".strix/bin/strix").exists() + + +def test_installer_skip_verify_does_not_download_manifests(tmp_path: Path) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="aarch64") + environment, home_path, curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + environment["STRIX_INSTALL_SKIP_VERIFY"] = "1" + + result = _run_installer(repository_root, environment) + + assert result.returncode == 0, result.stderr + curl_log = curl_log_path.read_text(encoding="utf-8") + assert "SHA256SUMS" not in curl_log + assert "intoto.jsonl" not in curl_log + assert "skipping checksum and provenance checks" in result.stdout + assert (home_path / ".strix/bin/strix").exists() + assert not (tmp_path / "gh.log").exists()