This commit is contained in:
mohammad junayd 2026-09-18 11:51:41 +03:00 committed by GitHub
commit fa56cf343f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 801 additions and 10 deletions

View file

@ -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/**

View file

@ -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
```
</Tab>
<Tab title="pipx">
```bash
@ -23,6 +32,44 @@ description: "Install Strix and run your first security scan"
</Tab>
</Tabs>
## 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:

View file

@ -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() {

View file

@ -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

View file

@ -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()